mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 18:10:39 +09:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - That operator experience depends not just on issue chat, but also on how workspaces, inbox groups, and navigation state behave over long-running sessions > - The current branch included a separate cluster of workspace-runtime controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes > - Those changes cross server, shared contracts, database state, and UI navigation, but they still form one coherent operator workflow area > - This pull request isolates the workspace/runtime and navigation ergonomics work into one standalone branch > - The benefit is better workspace recovery and navigation persistence without forcing reviewers through the unrelated issue-detail/chat work ## What Changed - Improved execution workspace and project workspace controls, request wiring, layout, and JSON editor ergonomics - Hardened linked worktree reuse/startup behavior and documented the `worktree repair` flow for recovering linked worktrees safely - Added inbox workspace grouping, mobile collapse, archive undo, keyboard navigation, shared group-header styling, and persisted collapsed-group behavior - Added persistent sidebar order preferences with the supporting DB migration, shared/server contracts, routes, services, hooks, and UI integration - Scoped issue-list preferences by context and added targeted UI/server tests for workspace controls, inbox behavior, sidebar preferences, and worktree validation ## Verification - `pnpm vitest run server/src/__tests__/sidebar-preferences-routes.test.ts ui/src/pages/Inbox.test.tsx ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/api/workspace-runtime-control.test.ts` - `server/src/__tests__/workspace-runtime.test.ts` was attempted, but the embedded Postgres suite self-skipped/hung on this host after reporting an init-script issue, so it is not counted as a local pass here ## Risks - Medium: this branch includes migration-backed preference storage plus worktree/runtime behavior, so merge review should pay attention to state persistence and worktree recovery semantics - The sidebar preference migration is standalone, but it should still be watched for conflicts if another migration lands first ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
70 lines
2.7 KiB
TypeScript
70 lines
2.7 KiB
TypeScript
import type { ProjectWorkspaceRuntimeConfig } from "@paperclipai/shared";
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function cloneRecord(value: unknown): Record<string, unknown> | null {
|
|
return isRecord(value) ? { ...value } : null;
|
|
}
|
|
|
|
function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] {
|
|
return value === "running" || value === "stopped" ? value : null;
|
|
}
|
|
|
|
function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] {
|
|
if (!isRecord(value)) return null;
|
|
const entries = Object.entries(value).filter(([, state]) => state === "running" || state === "stopped");
|
|
if (entries.length === 0) return null;
|
|
return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"];
|
|
}
|
|
|
|
export function readProjectWorkspaceRuntimeConfig(
|
|
metadata: Record<string, unknown> | null | undefined,
|
|
): ProjectWorkspaceRuntimeConfig | null {
|
|
const raw = isRecord(metadata?.runtimeConfig) ? metadata.runtimeConfig : null;
|
|
if (!raw) return null;
|
|
|
|
const config: ProjectWorkspaceRuntimeConfig = {
|
|
workspaceRuntime: cloneRecord(raw.workspaceRuntime),
|
|
desiredState: readDesiredState(raw.desiredState),
|
|
serviceStates: readServiceStates(raw.serviceStates),
|
|
};
|
|
|
|
const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null || config.serviceStates !== null;
|
|
return hasConfig ? config : null;
|
|
}
|
|
|
|
export function mergeProjectWorkspaceRuntimeConfig(
|
|
metadata: Record<string, unknown> | null | undefined,
|
|
patch: Partial<ProjectWorkspaceRuntimeConfig> | null,
|
|
): Record<string, unknown> | null {
|
|
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
|
|
const current = readProjectWorkspaceRuntimeConfig(metadata) ?? {
|
|
workspaceRuntime: null,
|
|
desiredState: null,
|
|
serviceStates: null,
|
|
};
|
|
|
|
if (patch === null) {
|
|
delete nextMetadata.runtimeConfig;
|
|
return Object.keys(nextMetadata).length > 0 ? nextMetadata : null;
|
|
}
|
|
|
|
const nextConfig: ProjectWorkspaceRuntimeConfig = {
|
|
workspaceRuntime:
|
|
patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime,
|
|
desiredState:
|
|
patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState,
|
|
serviceStates:
|
|
patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates,
|
|
};
|
|
|
|
if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null && nextConfig.serviceStates === null) {
|
|
delete nextMetadata.runtimeConfig;
|
|
} else {
|
|
nextMetadata.runtimeConfig = nextConfig;
|
|
}
|
|
|
|
return Object.keys(nextMetadata).length > 0 ? nextMetadata : null;
|
|
}
|