From 71b5c35d9b3c7ae96ec01bc3f59e0da9df7b72d0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 15 Jun 2026 14:23:02 +0800 Subject: [PATCH] Run multiple independent workspaces per directory (#1539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1) Model B EX-Phase 1: the binding layer. Records gain an additive workspaceId that answers "which workspace does this belong to" without replacing the existing required cwd. - protocol: add optional workspaceId to agent/terminal records and a workspaceOwnership capability under server_info.features.* - server: resolve and persist workspaceId on agent and terminal creation; carry it through agent-manager, projections, storage, loading, sessions, and the terminal manager/worker pipeline - workspace-directory / workspace-registry-model: derive and expose workspace ownership; bind status to workspaceId - client: surface workspaceId via daemon-client - app: filter terminals and agent visibility by workspaceId, thread it through workspace screen / terminal panel / session store - tests: cover workspaceId binding across create, agent-manager, mcp-server, workspace-directory, workspace-registry-model, and agent-visibility cwd stays required on every record; workspaceId is purely additive and back-compatible (optional/defaulted), so old and new peers still parse each other's messages. Known follow-ups: none. * feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2) - Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage - Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key) - Add editable workspace title, decoupled from the backing directory/branch - Persist empty projects (no workspaces yet) across daemon restarts - Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename Known follow-ups: none * feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3) - workspace.create RPC always creates a new workspace record; no directory dedup - dropped directory dedup in open_project and createPaseoWorktree paths - non-git directories are first-class: local-checkout workspaces no longer require a repo - service-proxy collision resolved by defaulting to the owning workspace instead of failing - new creation UI: choose backing directory (local checkout or worktree) per workspace - e2e coverage for creating multiple workspaces over the same directory Known follow-ups: - COMPAT cwd->workspaceId resolver remains until client floor advances - prune any remaining workspace==directory assumptions surfaced by usage * feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4) - A workspace is now the explicit home of a `paseo run`: run.ts resolves a workspace before creating any agent, then stamps the agent with that workspaceId — no run leans on createAgent's legacy cwd->workspace fallback. - Precedence: --workspace > $PASEO_WORKSPACE_ID > --worktree > bare run. --worktree mints its own workspace and overrides the ambient PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream. - New-workspace-per-bare-run: every bare run mints a fresh local-backed workspace for its cwd rather than reattaching to an existing one in that dir. - Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an existing workspace, same as --workspace. - Help text: new --workspace option documents the default (new workspace per run) and the env fallback; created runs print the workspace id plus a tip. - e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd, --workspace attach, and env attach against an isolated daemon. Known follow-ups: - createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no longer relies on it but it is not yet removable. * feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5) - Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId. - Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory. - Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted. - Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory. - Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved). - Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt. Known follow-ups: - Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today). - COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97. * feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6) - P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference. - Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent. - Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind. - Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update. - The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar. - Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants. Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row. * fix(protocol): use literal :: separator in terminal subscription key The committed blob contained a NUL byte as the separator instead of the :: the comment documents, which broke text diffs and review tooling (git flagged the file as binary). Functionally the key is an in-memory Map key only, but source must stay text. * feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7) - Pin and document the directory-backed vs workspace-owned right-sidebar boundary: same-cwd workspaces share directory-keyed git/PR/file surfaces but never share workspace-owned drafts, attachments, or file-explorer state. New docs section in architecture.md plus data-model.md keying convention and two glossary terms. - Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the right sidebar is shared (a directory change appears in both same-dir workspaces) while tabs/terminals stay independent per workspace. - Accumulated follow-up fixes: - MCP child agents created in a new worktree are stamped with the new worktree's workspaceId (not the parent's, not unstamped), mirroring the session path so workspaceId-scoped archive can find and tear them down. - session-store mergeWorkspaces prunes a stale empty-project descriptor when a workspace lands in that project, so it stops governing the project's metadata. - CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just --workspace), so worktree resolution never races an existing-workspace select. New run.test.ts pins the three validation outcomes. - Full-branch reshape outcomes: - Consolidate the four placeholder server_info.features flags (workspaceOwnership, workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single workspaceMultiplicity capability gate. - Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit creation always mints a new same-cwd record via createLocalCheckoutWorkspace. - Resolve every Model B COMPAT marker from v0.1.X to v0.1.97. - Drop the obsolete workspaceOwnership feature assertion from the same-cwd isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds. - Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch. - typecheck, lint, format all green. Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders (out of Model B scope, left untouched). * test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions) Reshape Model B test changes flagged by audit-tests so they exercise real behavior and observable state instead of mock scaffolding. Categories fixed: - Mocks -> real dependencies + persisted state: create.test.ts and mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects and mock.calls[...] assertions, now run the real AgentManager/AgentStorage with a fake agent client and assert on the stored agent record (workspaceId, parent label). - Module-internal spying -> injected seam: acp-agent stopped spying on the tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator injection seam to ACPAgentClient/ACPAgentSession (production defaults to terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the recorded children plus observable stream .destroyed state. - Poking internals -> public API: acp close()/killTerminal/releaseTerminal tests now create terminals through the public createTerminal API instead of mutating internals.terminalEntries. - Reimplemented production logic deleted: cli-run-workspace-precedence dropped its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation it then asserted against) and now proves only the daemon behaviors the CLI builds on; flag precedence stays covered in the CLI's own run.test.ts. - Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with expect.poll on the observed snapshot state to remove the race. - Internal protocol-frame assertions removed: sidebar-workspace-rename dropped the captureWsSessionFrames workspace.title.set.request assertions, relying on the user-visible rename + reload checks already present. * fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects Source regression: - packages/app/src/screens/new-workspace-screen.tsx - packages/app/src/screens/new-workspace-empty.ts When a new workspace is created with an initial agent, optimistically merge it with status "running" (statusEnteredAt now) so it lands in the "Working" bucket instead of defaulting into the wrong bucket. The empty-workspace path passes withInitialAgent: false so a bare workspace keeps its descriptor status. Test-drift (intentional P3 flow change — backing picker now required): - packages/app/e2e/new-workspace.spec.ts - packages/app/e2e/helpers/new-workspace.ts The reshaped creation flow requires choosing a backing ("New worktree") before the branch / starting-ref picker is reachable. Branch-picker specs now call selectWorkspaceBacking(page, "worktree") first, and the helper waits for the worktree control to drop aria-disabled (it stays disabled until the checkout status query confirms the project is a git repo) before clicking. Test-drift (protocol field added — sdk emptyProjects): - packages/client/src/index.test.ts fetch_workspaces response now carries emptyProjects; the toEqual expectation includes emptyProjects: []. Flake hardening: - packages/app/e2e/same-directory-workspaces.spec.ts - packages/app/e2e/helpers/seed-client.ts Out-of-band working-tree writes raced the daemon's debounced filesystem watcher, so the UI could subscribe before the new file was in the git snapshot. Force a checkout refresh (same path as the UI's manual refresh) to make the write authoritative before asserting, removing the timing dependency. * test(app): expect withInitialAgent:false in empty-workspace create call The withInitialAgent flag (fix for the new-workspace Done-bucket regression) added a field to the ensureWorkspace call; the empty-path unit test pinned the exact args. Test-drift, not behavior — update the expectation. * feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance Restructure the new-workspace creation screen into a vertical stack of Project / Isolation / Base parameters instead of the previous mixed layout. - Isolation/backing is now a dropdown (Local vs New worktree) matching the app's existing dropdown/combobox primitives, replacing the inline segmented switcher. - The Base (starting ref) row now reserves its space even when the backing is Local and the picker is hidden, so switching backing no longer causes the form to shift vertically. - The form avoids the keyboard so the title input and submit stay visible while typing on native and web. - Updated the e2e new-workspace helper to drive the dropdown-based backing selector and the reserved Base row. * fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns - Render the three rows (formStack) at the top, under the "New workspace" heading and above the composer input, not in the composer footer. - Drop the card/surface/border chrome; rows sit on the plain background. - Stack Project, Isolation (multiplicity only), and Base; remove the title field — the title is set server-side and create works with no user title. - Each row is a Label immediately followed by its dropdown control; no columns, fixed label widths, or reserved horizontal space, with the label glyph aligned to the heading's text x. - All three triggers share the ghost badge style of the Project control (ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger + Combobox); keep the workspace-create-backing-* test IDs. - Omit "New worktree" from Isolation entirely when the project is non-git. - Reserve the Base row height always but render nothing on Local backing, showing the Base label + ref picker only for New worktree. - Keep keyboard avoidance on the centered content. - E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and remove the now-gone workspace-create-title-input usage and title field. * feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select - Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice. - Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate. - Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon. - Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm. - e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row. * fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles - Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars). - Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator. - Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE. - Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant. * fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing - Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift. - Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider. * feat(app): rename Sessions to History (clock icon, Agent history header) - Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare) - Sessions screen header now reads "Agent history" - Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es) - Route and testIDs unchanged (sidebar-sessions, /sessions) * fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched. * fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title) - Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null) - Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded) - Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title - Header branch-switcher left untouched; diff-pane re-home decision still pending * feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence) - branch switcher now lives in the diff panel Changes header on desktop+mobile - workspace header title is a plain static title; branch removed from it - no new workspace-screen header row - git diff-stat unchanged, stays where it is - no duplicate git actions - unified descriptor name fallbacks via resolveWorkspaceName - rename-then-switch e2e proves header title and real branch stay independent --- docs/architecture.md | 31 + docs/data-model.md | 7 + docs/glossary.md | 2 + packages/app/e2e/branch-switcher.spec.ts | 95 ++- .../app/e2e/empty-project-persists.spec.ts | 78 +++ packages/app/e2e/helpers/archive-tab.ts | 2 +- packages/app/e2e/helpers/branch-switcher.ts | 37 +- packages/app/e2e/helpers/new-workspace.ts | 42 +- packages/app/e2e/helpers/seed-client.ts | 18 + packages/app/e2e/helpers/sidebar.ts | 30 + packages/app/e2e/new-workspace-entry.spec.ts | 155 +++++ packages/app/e2e/new-workspace.spec.ts | 5 + .../app/e2e/same-directory-workspaces.spec.ts | 122 ++++ packages/app/e2e/sidebar-model-b.spec.ts | 174 +++++ .../app/e2e/sidebar-workspace-rename.spec.ts | 70 +- .../app/e2e/workspace-multiplicity.spec.ts | 226 ++++++ .../e2e/worktree-archive-keep-prompt.spec.ts | 69 ++ .../app/src/components/branch-switcher.tsx | 84 ++- .../app/src/components/explorer-sidebar.tsx | 1 - packages/app/src/components/left-sidebar.tsx | 39 +- .../src/components/sidebar-workspace-list.tsx | 587 +++------------- .../components/sidebar/sidebar-header-row.tsx | 57 +- .../sidebar/sidebar-status-list.tsx | 143 ++-- .../src/components/workspace-hover-card.tsx | 29 +- packages/app/src/composer/index.tsx | 4 + packages/app/src/composer/input/input.tsx | 9 + packages/app/src/contexts/session-context.tsx | 18 +- packages/app/src/git/actions-split-button.tsx | 6 +- packages/app/src/git/actions-store.ts | 10 +- packages/app/src/git/diff-pane.tsx | 57 +- .../git/{use-actions.ts => use-actions.tsx} | 116 +++- packages/app/src/git/workspace-actions.tsx | 13 +- .../hooks/sidebar-status-view-model.test.ts | 2 + .../hooks/sidebar-workspaces-view-model.ts | 7 + packages/app/src/hooks/use-projects.test.ts | 1 + .../src/hooks/use-sidebar-shortcut-model.ts | 9 +- .../src/hooks/use-sidebar-workspaces-list.ts | 10 + packages/app/src/i18n/resources.test.ts | 4 +- packages/app/src/i18n/resources/ar.ts | 22 +- packages/app/src/i18n/resources/en.ts | 22 +- packages/app/src/i18n/resources/es.ts | 22 +- packages/app/src/i18n/resources/fr.ts | 22 +- packages/app/src/i18n/resources/ru.ts | 22 +- packages/app/src/i18n/resources/zh-CN.ts | 22 +- packages/app/src/panels/terminal-panel.tsx | 11 +- .../app/src/projects/workspace-structure.ts | 18 +- .../src/screens/new-workspace-empty.test.ts | 1 + .../app/src/screens/new-workspace-empty.ts | 2 + .../app/src/screens/new-workspace-screen.tsx | 644 ++++++++++++++---- .../src/screens/workspace/terminals/state.ts | 8 +- .../terminals/use-workspace-terminals.ts | 44 +- .../screens/workspace/workspace-screen.tsx | 14 +- .../workspace-source-of-truth.test.ts | 39 ++ .../session-store-hooks/selectors.test.ts | 23 + .../stores/session-store-hooks/selectors.ts | 20 +- packages/app/src/stores/session-store.ts | 84 ++- packages/app/src/utils/agent-snapshots.ts | 1 + .../utils/sidebar-project-row-model.test.ts | 87 +-- .../src/utils/sidebar-project-row-model.ts | 44 +- .../app/src/utils/sidebar-shortcuts.test.ts | 36 +- packages/app/src/utils/sidebar-shortcuts.ts | 3 +- .../workspace-tabs/agent-visibility.test.ts | 61 ++ .../src/workspace-tabs/agent-visibility.ts | 25 +- .../src/workspace/use-workspace-archive.ts | 164 +++++ .../src/workspace/worktree-delete-prompt.tsx | 71 ++ packages/cli/src/commands/agent/run.test.ts | 51 ++ packages/cli/src/commands/agent/run.ts | 100 ++- packages/client/src/daemon-client.ts | 99 ++- packages/client/src/index.test.ts | 1 + packages/protocol/src/messages.ts | 113 +++ .../protocol/src/terminal-subscription-key.ts | 8 + .../server/src/server/agent/agent-loading.ts | 5 +- .../src/server/agent/agent-manager.test.ts | 34 + .../server/src/server/agent/agent-manager.ts | 14 + .../src/server/agent/agent-projections.ts | 3 + .../server/src/server/agent/agent-storage.ts | 1 + .../agent/create-agent-lifecycle-dispatch.ts | 13 +- .../server/agent/create-agent/create.test.ts | 154 +++++ .../src/server/agent/create-agent/create.ts | 34 +- .../src/server/agent/mcp-server.test.ts | 54 +- .../server/src/server/agent/mcp-server.ts | 19 +- .../server/agent/providers/acp-agent.test.ts | 227 +++--- .../src/server/agent/providers/acp-agent.ts | 22 +- .../archive-if-safe.test.ts | 8 +- .../auto-archive-on-merge/archive-if-safe.ts | 28 +- packages/server/src/server/bootstrap.ts | 14 +- .../cli-run-workspace-precedence.e2e.test.ts | 94 +++ .../daemon-e2e/checkout-pr-merge.e2e.test.ts | 4 +- .../daemon-e2e/checkout-ship.e2e.test.ts | 6 +- .../empty-project-persists.e2e.test.ts | 121 ++++ .../daemon-e2e/git-operations.e2e.test.ts | 10 +- .../server/paseo-worktree-archive-service.ts | 318 +++++---- .../src/server/paseo-worktree-service.test.ts | 34 +- .../src/server/paseo-worktree-service.ts | 98 ++- .../server/src/server/persistence-hooks.ts | 2 + packages/server/src/server/script-proxy.ts | 1 + .../script-route-branch-handler.test.ts | 6 +- .../server/src/server/service-proxy.test.ts | 9 +- packages/server/src/server/service-proxy.ts | 2 +- ...ate-agent-worktree-autoarchive.e2e.test.ts | 20 +- packages/server/src/server/session.ts | 345 +++++++++- .../src/server/session.workspaces.test.ts | 202 +++++- .../server/src/server/websocket-server.ts | 2 + ...orkspace-archive-record-scoped.e2e.test.ts | 298 ++++++++ .../src/server/workspace-archive-service.ts | 18 +- .../workspace-create-errors.e2e.test.ts | 57 ++ .../src/server/workspace-directory.test.ts | 180 ++++- .../server/src/server/workspace-directory.ts | 179 +++-- .../workspace-reconciliation-service.test.ts | 17 +- .../workspace-reconciliation-service.ts | 38 +- .../server/workspace-registry-model.test.ts | 74 +- .../src/server/workspace-registry-model.ts | 36 + .../src/server/workspace-registry.test.ts | 31 + .../server/src/server/workspace-registry.ts | 24 + .../workspace-same-cwd-isolation.e2e.test.ts | 195 ++++++ .../server/worktree-bootstrap.posix.test.ts | 6 + .../src/server/worktree-bootstrap.test.ts | 3 + .../server/src/server/worktree-bootstrap.ts | 4 + .../src/server/worktree-session.test.ts | 508 +++++++++----- .../server/src/server/worktree-session.ts | 7 + .../server/src/server/worktree/commands.ts | 4 + .../server/src/terminal/terminal-manager.ts | 23 +- .../terminal-session-controller.test.ts | 70 ++ .../terminal/terminal-session-controller.ts | 74 +- .../src/terminal/terminal-worker-process.ts | 5 +- .../src/terminal/terminal-worker-protocol.ts | 3 + packages/server/src/terminal/terminal.ts | 13 +- .../src/terminal/worker-terminal-manager.ts | 17 +- packages/server/src/utils/tree-kill.ts | 9 +- 129 files changed, 6252 insertions(+), 1818 deletions(-) create mode 100644 packages/app/e2e/empty-project-persists.spec.ts create mode 100644 packages/app/e2e/new-workspace-entry.spec.ts create mode 100644 packages/app/e2e/same-directory-workspaces.spec.ts create mode 100644 packages/app/e2e/sidebar-model-b.spec.ts create mode 100644 packages/app/e2e/workspace-multiplicity.spec.ts create mode 100644 packages/app/e2e/worktree-archive-keep-prompt.spec.ts rename packages/app/src/git/{use-actions.ts => use-actions.tsx} (90%) create mode 100644 packages/app/src/workspace/use-workspace-archive.ts create mode 100644 packages/app/src/workspace/worktree-delete-prompt.tsx create mode 100644 packages/cli/src/commands/agent/run.test.ts create mode 100644 packages/protocol/src/terminal-subscription-key.ts create mode 100644 packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts create mode 100644 packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts create mode 100644 packages/server/src/server/workspace-create-errors.e2e.test.ts create mode 100644 packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index d9d7565fd..21e1a8a63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -234,6 +234,37 @@ initializing → idle ⇄ running - Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up. - Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id. +## Right-sidebar boundary: directory-backed vs workspace-owned + +Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `local_checkout` workspace on the same folder, or several workspaces opened against one checkout). Model B keeps these distinct: they share everything the directory determines, but nothing the workspace owns. The right-sidebar surfaces split cleanly along this line, and the split is enforced purely by **what each piece of state is keyed by**. + +**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:** + +| Surface | Key | Source | +| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- | +| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` | +| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` | +| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` | +| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` | +| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` | +| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` | + +**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):** + +| State | Key builder / store | Source | +| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- | +| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` | +| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` | +| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` | +| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` | +| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` | + +`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys. + +**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism. + +One deliberate non-violation: `AgentFileExplorerState.directories`/`files` cache directory listings inside the `workspaceId`-keyed explorer map. Same-`cwd` workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via `listDirectory(workspaceRoot, …)`. This is duplication, not leakage, and is left as-is. + ## Agent providers Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`. diff --git a/docs/data-model.md b/docs/data-model.md index 2371eb5d9..048e51160 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -432,6 +432,13 @@ These small files are not validated as full Zod schemas but are persisted under These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem. +### Keying convention: directory-backed vs workspace-owned + +Right-sidebar client state splits on whether it is determined by the directory or owned by the workspace (two workspaces can share one `cwd`). The split is enforced by the cache key, so changing a key changes the sharing semantics — see [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned) for the full table. + +- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores. +- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path. + ### Draft Store **AsyncStorage key:** `paseo-drafts` (version 2) diff --git a/docs/glossary.md b/docs/glossary.md index 8448e450d..1bdae66eb 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -13,6 +13,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`). - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym. - **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label. +- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). +- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, status, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). - **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log. - **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. diff --git a/packages/app/e2e/branch-switcher.spec.ts b/packages/app/e2e/branch-switcher.spec.ts index e1e2f5132..e80f38b27 100644 --- a/packages/app/e2e/branch-switcher.spec.ts +++ b/packages/app/e2e/branch-switcher.spec.ts @@ -1,17 +1,49 @@ -import { expect, test } from "./fixtures"; +import { expect, test, type Page } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; -import { expectWorkspaceBranch, switchBranchFromHeader } from "./helpers/branch-switcher"; +import { + expectNoBranchSwitcherInWorkspaceHeader, + expectWorkspaceBranch, + openChangesPanel, + switchBranchFromChangesPanel, +} from "./helpers/branch-switcher"; import { seedWorkspace } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; import { readWorktreeBranchInfo } from "./helpers/workspace"; import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui"; +async function renameWorkspaceViaSidebar( + page: Page, + input: { workspaceId: string; title: string }, +): Promise { + const serverId = getServerId(); + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${input.workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${input.workspaceId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + const renameItem = page.getByTestId( + `sidebar-workspace-menu-rename-${serverId}:${input.workspaceId}`, + ); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`; + const renameInput = page.getByTestId(`${modalPrefix}-input`); + await expect(renameInput).toBeVisible({ timeout: 10_000 }); + await renameInput.fill(input.title); + await page.getByTestId(`${modalPrefix}-submit`).click(); + await expect(renameInput).toHaveCount(0, { timeout: 15_000 }); +} + test.describe("Branch switcher", () => { // The first test after a spec-file switch can fail while the shared daemon // releases stale sessions from the previous spec; one retry stabilizes it. test.describe.configure({ retries: 1 }); - test("switches the workspace branch from the header for an opaque workspace id", async ({ + test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({ page, }) => { test.setTimeout(90_000); @@ -26,8 +58,9 @@ test.describe("Branch switcher", () => { await waitForSidebarHydration(page); await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId }); + await openChangesPanel(page); await expectWorkspaceBranch(page, "main"); - await switchBranchFromHeader(page, { from: "main", to: "dev" }); + await switchBranchFromChangesPanel(page, { from: "main", to: "dev" }); await expectWorkspaceBranch(page, "dev"); await expect @@ -41,4 +74,58 @@ test.describe("Branch switcher", () => { await workspace.cleanup(); } }); + + test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({ + page, + }) => { + test.setTimeout(90_000); + const serverId = getServerId(); + const workspace = await seedWorkspace({ + repoPrefix: "branch-coherence-", + repo: { branches: ["main", "dev"] }, + }); + + try { + expect(workspace.workspaceName).toBe("main"); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId }); + + const customTitle = "Payments Refactor"; + await renameWorkspaceViaSidebar(page, { + workspaceId: workspace.workspaceId, + title: customTitle, + }); + + // The header shows the custom title verbatim (a plain static title), never a + // branch name, and the branch switcher does not live in the header. + const headerTitle = page + .getByTestId("workspace-header-title") + .filter({ visible: true }) + .first(); + await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 }); + await expectNoBranchSwitcherInWorkspaceHeader(page); + + // The diff panel's switcher tracks the real branch ("main"), not the title, + // and switching it checks out the real branch on disk. + await openChangesPanel(page); + await expectWorkspaceBranch(page, "main"); + await switchBranchFromChangesPanel(page, { from: "main", to: "dev" }); + await expectWorkspaceBranch(page, "dev"); + + // The custom title is unaffected by the branch switch. + await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 }); + + await expect + .poll( + async () => + (await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch, + { timeout: 30_000 }, + ) + .toBe("dev"); + } finally { + await workspace.cleanup(); + } + }); }); diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts new file mode 100644 index 000000000..48972483d --- /dev/null +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; + +function workspaceRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise { + const serverId = getServerId(); + const row = page.getByTestId(workspaceRowTestId(workspaceId)); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + // Hiding a checkout from the sidebar raises a browser confirm; accept it so the + // user-confirmed archive proceeds deterministically. + page.once("dialog", (dialog) => void dialog.accept()); + + const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`); + await expect(archiveItem).toBeVisible({ timeout: 10_000 }); + await archiveItem.click(); +} + +// Model B makes the project a first-class parent: archiving its last workspace +// must not delete the project. The per-project "+ New workspace" row is gone; +// the empty project keeps its parent row, and creation stays reachable from the +// project row's own new-worktree icon (git projects) and the global button. +test.describe("Empty project persists", () => { + test("archiving the only workspace keeps the project row with creation still reachable", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" }); + + try { + const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`); + const projectNewWorktreeIcon = page.getByTestId( + `sidebar-project-new-worktree-${workspace.projectId}`, + ); + const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace"); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({ + timeout: 30_000, + }); + + await hideWorkspaceFromSidebar(page, workspace.workspaceId); + + // The workspace row goes away, but its project parent stays as an empty + // project row. Creation is still reachable: the project row keeps its own + // new-worktree icon (revealed on hover) and the global button persists. + await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, { + timeout: 30_000, + }); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 }); + await projectRow.hover(); + await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 }); + + // The empty project survives a reload — it is persisted, not a transient + // artifact of the just-archived workspace still lingering in memory. + await page.reload(); + await waitForSidebarHydration(page); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await projectRow.hover(); + await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 }); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index beaab3ccf..1428d6744 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -212,7 +212,7 @@ export async function openSessions(page: Page): Promise { await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), { timeout: 30_000, }); - await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({ + await expect(page.getByText("Agent history", { exact: true }).last()).toBeVisible({ timeout: 30_000, }); } diff --git a/packages/app/e2e/helpers/branch-switcher.ts b/packages/app/e2e/helpers/branch-switcher.ts index 6fa78e426..25588280c 100644 --- a/packages/app/e2e/helpers/branch-switcher.ts +++ b/packages/app/e2e/helpers/branch-switcher.ts @@ -1,22 +1,42 @@ import { expect, type Page } from "@playwright/test"; import { escapeRegex } from "./regex"; -// The header branch switcher renders as a button whose accessible name carries the -// current branch ("Current branch: . Press to switch branch."). Matching on the -// accessible name keeps these helpers tied to what a screen reader user hears, and it -// proves the header resolved a real checkout directory from the opaque workspace id. +// The branch switcher lives in the git diff panel's Changes header (right-side +// ExplorerSidebar), not in the workspace header. It renders as a button whose +// accessible name carries the current branch ("Current branch: . Press to +// switch branch."). Matching on the accessible name keeps these helpers tied to +// what a screen reader user hears, and it proves the panel resolved a real +// checkout directory from the opaque workspace id. Scoping to the changes header +// keeps the matcher unambiguous even when the header is shared with diff actions. function branchSwitcherTrigger(page: Page, branchName: string) { return page + .getByTestId("changes-header") .getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) }) .filter({ visible: true }) .first(); } +// Opens the right-side explorer and lands on the Changes tab, where the branch +// switcher and diff live. Git checkouts default to the Changes tab when the +// explorer opens, so this is enough to reveal the switcher on desktop and mobile. +export async function openChangesPanel(page: Page): Promise { + await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({ + timeout: 30_000, + }); + await page.getByTestId("workspace-explorer-toggle").first().click(); + const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }).first(); + await expect(changesTab).toBeVisible({ timeout: 30_000 }); + await changesTab.click(); + await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({ + timeout: 30_000, + }); +} + export async function expectWorkspaceBranch(page: Page, branchName: string): Promise { await expect(branchSwitcherTrigger(page, branchName)).toBeVisible({ timeout: 30_000 }); } -export async function switchBranchFromHeader( +export async function switchBranchFromChangesPanel( page: Page, input: { from: string; to: string }, ): Promise { @@ -38,3 +58,10 @@ export async function switchBranchFromHeader( await expect(picker).not.toBeVisible({ timeout: 30_000 }); } + +// The workspace header title is a plain static title in Model B; the branch +// switcher must never appear there. Asserting on the header testID keeps this +// honest even as the switcher continues to exist inside the Changes panel. +export async function expectNoBranchSwitcherInWorkspaceHeader(page: Page): Promise { + await expect(page.getByTestId("workspace-header-branch-switcher")).toHaveCount(0); +} diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 343761575..7659cc85b 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -13,6 +13,7 @@ type NewWorkspaceDaemonClient = Pick< | "connect" | "createPaseoWorktree" | "fetchWorkspaces" + | "getPaseoWorktreeList" | "openProject" >; @@ -152,7 +153,7 @@ export async function openNewWorkspaceComposer( } export async function openGlobalNewWorkspaceComposer(page: Page): Promise { - await page.getByTestId("sidebar-new-workspace").click(); + await page.getByTestId("sidebar-global-new-workspace").click(); await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, { timeout: 30_000, @@ -190,6 +191,45 @@ export async function clickNewWorkspaceButton( await submitNewWorkspacePrompt(page, input.prompt); } +export async function selectNewWorkspaceProject( + page: Page, + input: { projectKey: string; projectDisplayName: string }, +): Promise { + const trigger = page.getByTestId("new-workspace-project-picker-trigger"); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await trigger.click(); + + const option = page.getByTestId(`new-workspace-project-picker-option-${input.projectKey}`); + await expect(option).toBeVisible({ timeout: 30_000 }); + await option.click(); + + await expectNewWorkspaceProjectSelected(page, input.projectDisplayName); +} + +export async function selectWorkspaceBacking( + page: Page, + backing: "local" | "worktree", +): Promise { + const trigger = page.getByTestId("workspace-create-backing-trigger"); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await trigger.click(); + + // "New worktree" is only listed once the checkout status query confirms the + // selected project is a git repo, so wait for the option to appear before + // clicking it. + const option = page.getByTestId(`workspace-create-backing-${backing}`); + await expect(option).toBeVisible({ timeout: 30_000 }); + await option.click(); +} + +export async function submitNewWorkspaceEmpty(page: Page): Promise { + const createButton = page + .getByTestId("message-input-root") + .getByRole("button", { name: "Create" }); + await expect(createButton).toBeVisible({ timeout: 30_000 }); + await createButton.click(); +} + export async function openStartingRefPicker(page: Page): Promise { const trigger = page.getByTestId("new-workspace-ref-picker-trigger"); await expect(trigger).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 88bae550e..be77997fe 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -24,6 +24,24 @@ export interface SeedDaemonClient { } | null; error: string | null; }>; + createWorkspace(input: { + backing: "local" | "worktree"; + cwd?: string; + projectId?: string; + branch?: string; + baseBranch?: string; + title?: string; + }): Promise<{ + workspace: { id: string; name: string } | null; + error: string | null; + }>; + /** + * Force the daemon to recompute its git snapshot and diff for a checkout, + * mirroring the UI's manual refresh. Tests use this to make an out-of-band + * working-tree write authoritative before asserting on it in the UI, instead + * of racing the filesystem watcher's debounce. + */ + checkoutRefresh(cwd: string): Promise<{ success: boolean; error: unknown }>; createTerminal( cwd: string, name?: string, diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index ebc3c9aa5..de9e9cdc2 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -34,6 +34,36 @@ export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`); await expect(archiveItem).toBeVisible({ timeout: 10_000 }); await archiveItem.click(); + + // Archiving the last reference to a worktree opens the keep/delete prompt. + // This helper deletes the worktree from disk; callers that want to keep it use + // openWorktreeDeletePrompt directly. + const deleteButton = page.getByTestId("worktree-delete-confirm-delete"); + await expect(deleteButton).toBeVisible({ timeout: 10_000 }); + await deleteButton.click(); +} + +// Opens the archive flow for a last-reference worktree and stops at the inline +// keep/delete prompt, which the caller resolves by clicking keep or delete. +export async function openWorktreeDeletePrompt(page: Page, workspaceId: string): Promise { + const serverId = getServerId(); + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + // A dirty/unsynced worktree raises a browser confirm before the prompt; accept + // it so the prompt opens deterministically either way. + page.once("dialog", (dialog) => void dialog.accept()); + + const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`); + await expect(archiveItem).toBeVisible({ timeout: 10_000 }); + await archiveItem.click(); + + await expect(page.getByTestId("worktree-delete-confirm-keep")).toBeVisible({ timeout: 10_000 }); } export async function expectWorkspaceAbsentFromSidebar( diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts new file mode 100644 index 000000000..33049a632 --- /dev/null +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -0,0 +1,155 @@ +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + connectNewWorkspaceDaemonClient, + expectNewWorkspaceProjectSelected, + openGlobalNewWorkspaceComposer, + openNewWorkspaceComposer, +} from "./helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; + +// Model B entry points into the New Workspace screen. The per-project +// "+ New workspace" sidebar row is gone; the surviving entries are the global +// button (universal) and each git project's own new-worktree icon (preselects +// that project). These specs prove the global entry opens the screen, the +// project icon preselects the right project across the reused 'new' screen, and +// non-git projects never offer the worktree Isolation control. + +function projectRow(page: import("@playwright/test").Page, projectKey: string) { + return page.getByTestId(`sidebar-project-row-${projectKey}`); +} + +test.describe("New workspace entry points", () => { + let client: Awaited>; + + test.describe.configure({ timeout: 240_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + }); + + test.afterEach(async () => { + await client?.close().catch(() => undefined); + }); + + test("the global new-workspace button opens the New Workspace screen", async ({ page }) => { + const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-global-button-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`), + ).toBeVisible({ timeout: 30_000 }); + + const globalButton = page.getByTestId("sidebar-global-new-workspace"); + await expect(globalButton).toBeVisible({ timeout: 30_000 }); + + await openGlobalNewWorkspaceComposer(page); + + // The screen is up: its project picker trigger is the canonical landmark. + await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({ + timeout: 30_000, + }); + } finally { + await seeded.cleanup(); + } + }); + + test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({ + page, + }) => { + const projectA: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-a-" }); + const projectB: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-b-" }); + const projectC: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-c-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 }); + + // Project A's row icon opens New Workspace with A preselected. + await openNewWorkspaceComposer(page, { + projectKey: projectA.projectId, + projectDisplayName: projectA.projectDisplayName, + }); + await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); + + // Manually override the selection to C from inside A's screen. This stale + // manualProjectKey is what the reused 'new' screen must reset when the next + // route-driven navigation targets a different project. + await page.getByTestId("new-workspace-project-picker-trigger").click(); + const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`); + await expect(optionC).toBeVisible({ timeout: 30_000 }); + await optionC.click(); + await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName); + + // Navigate via B's row icon. B must be preselected — the route project wins + // because the stale manual choice (C) was reset on the route change. If the + // reset were missing, the trigger would still read C. + await openNewWorkspaceComposer(page, { + projectKey: projectB.projectId, + projectDisplayName: projectB.projectDisplayName, + }); + await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName); + } finally { + await projectA.cleanup(); + await projectB.cleanup(); + await projectC.cleanup(); + } + }); + + test("the Isolation control is hidden for a non-git project and shown for a git project", async ({ + page, + }) => { + const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" }); + const nonGitProject: SeededWorkspace = await seedWorkspace({ + repoPrefix: "entry-iso-nongit-", + git: false, + }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 }); + + // Open New Workspace for the non-git project via the global button, then + // select it in the picker (its row has no new-worktree icon). + await openGlobalNewWorkspaceComposer(page); + const trigger = page.getByTestId("new-workspace-project-picker-trigger"); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await trigger.click(); + const nonGitOption = page.getByTestId( + `new-workspace-project-picker-option-${nonGitProject.projectId}`, + ); + await expect(nonGitOption).toBeVisible({ timeout: 30_000 }); + await nonGitOption.click(); + await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName); + + // No git checkout means no worktree backing choice: the Isolation row is + // absent entirely. + await expect(page.getByTestId("workspace-create-backing-trigger")).toHaveCount(0); + + // Switching to the git project on the same screen reveals the Isolation row. + await trigger.click(); + const gitOption = page.getByTestId( + `new-workspace-project-picker-option-${gitProject.projectId}`, + ); + await expect(gitOption).toBeVisible({ timeout: 30_000 }); + await gitOption.click(); + await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName); + + await expect(page.getByTestId("workspace-create-backing-trigger")).toBeVisible({ + timeout: 30_000, + }); + } finally { + await gitProject.cleanup(); + await nonGitProject.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index a8ce137aa..0fc3eb56b 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -25,6 +25,7 @@ import { selectBranchInPicker, selectGitHubPrInPicker, selectPickerOptionByKeyboard, + selectWorkspaceBacking, submitNewWorkspacePrompt, } from "./helpers/new-workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; @@ -632,6 +633,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); + await selectWorkspaceBacking(page, "worktree"); await openStartingRefPicker(page); await selectBranchInPicker(page, "dev"); @@ -677,6 +679,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); + await selectWorkspaceBacking(page, "worktree"); await openBranchPicker(page); await expectPickerOpen(page); @@ -701,6 +704,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); + await selectWorkspaceBacking(page, "worktree"); await openBranchPicker(page); await expectPickerOpen(page); @@ -730,6 +734,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); + await selectWorkspaceBacking(page, "worktree"); await openStartingRefPicker(page); await selectGitHubPrInPicker(page, pr.number); diff --git a/packages/app/e2e/same-directory-workspaces.spec.ts b/packages/app/e2e/same-directory-workspaces.spec.ts new file mode 100644 index 000000000..3946d2f90 --- /dev/null +++ b/packages/app/e2e/same-directory-workspaces.spec.ts @@ -0,0 +1,122 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { test, expect, type Page } from "./fixtures"; +import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { expectExplorerEntryVisible } from "./helpers/file-explorer"; +import { expectNoTerminalTabs, clickFirstTerminalTab } from "./helpers/workspace-tabs"; + +// Model B: two workspaces can back the SAME directory. What follows from that +// split is the contract these specs pin: +// - The right sidebar (file browser / git changes) reads the directory, so it +// is IDENTICAL across same-directory workspaces. +// - Tabs (agents, terminals) are owned by the workspace, so they are +// INDEPENDENT across same-directory workspaces. + +// On desktop the explorer is pinned open; on narrow layouts it must be toggled. +// Open it either way, then select the requested tab. +async function openExplorerTab(page: Page, tab: "files" | "changes"): Promise { + const openButton = page.getByRole("button", { name: "Open explorer" }).first(); + if (await openButton.isVisible().catch(() => false)) { + await openButton.click(); + } + await page.getByTestId(`explorer-tab-${tab}`).click(); +} + +async function createSecondWorkspaceOnSameDir( + seeded: SeededWorkspace, + title: string, +): Promise { + const created = await seeded.client.createWorkspace({ + backing: "local", + cwd: seeded.repoPath, + projectId: seeded.projectId, + title, + }); + if (!created.workspace) { + throw new Error(created.error ?? `Failed to create second workspace for ${seeded.projectId}`); + } + // Both workspaces back the same on-disk checkout. + return created.workspace.id; +} + +test.describe("Same-directory workspaces", () => { + test.describe.configure({ timeout: 180_000 }); + + test("the right sidebar is shared: a directory change shows in both same-dir workspaces", async ({ + page, + }) => { + const seeded = await seedWorkspace({ repoPrefix: "same-dir-shared-" }); + + try { + const secondWorkspaceId = await createSecondWorkspaceOnSameDir(seeded, "Second view"); + + // Seed an uncommitted change directly in the shared checkout. Because the + // file browser and git diff read the directory, both workspaces must see + // it — neither owns the directory state. + await writeFile( + path.join(seeded.workspaceDirectory, "SHARED_CHANGE.md"), + "# shared change\n", + ); + + // Make the write authoritative on the daemon before the UI reads it. The + // git status/diff is otherwise refreshed by a debounced filesystem watcher, + // and a loaded CI host can lag that debounce past the assertion window — + // the source of this spec's flakiness. Forcing a refresh (the same path as + // the UI's manual refresh) recomputes the snapshot and diff now, so the + // first subscribe on mount already includes SHARED_CHANGE.md. + const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath); + if (!refreshed.success) { + throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`); + } + + // Workspace A: the new file shows in both the file browser and the git + // changes view. + await gotoWorkspace(page, seeded.workspaceId); + await openExplorerTab(page, "files"); + await expectExplorerEntryVisible(page, "SHARED_CHANGE.md"); + await openExplorerTab(page, "changes"); + await expect( + page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + + // Workspace B (same directory): the SAME change is visible. The right + // sidebar content does not differ between the two views. + await gotoWorkspace(page, secondWorkspaceId); + await openExplorerTab(page, "files"); + await expectExplorerEntryVisible(page, "SHARED_CHANGE.md"); + await openExplorerTab(page, "changes"); + await expect( + page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + } finally { + await seeded.cleanup(); + } + }); + + test("workspace state is independent: a terminal opened in A does not appear in B", async ({ + page, + }) => { + const seeded = await seedWorkspace({ repoPrefix: "same-dir-independent-" }); + + try { + const secondWorkspaceId = await createSecondWorkspaceOnSameDir(seeded, "Independent view"); + + // Open workspace A and materialize a terminal tab. + await gotoWorkspace(page, seeded.workspaceId); + await clickNewTerminal(page); + await clickFirstTerminalTab(page); + + // Workspace B shares the directory but owns its own tabs: it has no + // terminal tab, because the terminal belongs to A. + await gotoWorkspace(page, secondWorkspaceId); + await expectNoTerminalTabs(page); + + // Back in A, the terminal is still there — B never absorbed it. + await gotoWorkspace(page, seeded.workspaceId); + await clickFirstTerminalTab(page); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts new file mode 100644 index 000000000..327952c7e --- /dev/null +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -0,0 +1,174 @@ +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { getServerId } from "./helpers/server-id"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; + +// Model B sidebar shape: every project — git or non-git, single- or +// multi-workspace — renders as the same expandable parent, the deepest sidebar +// level is the workspace row, and tabs/agents/terminals NEVER appear in the +// sidebar. These specs prove all three invariants end to end. + +function workspaceRow(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); +} + +function projectRow(page: Page, projectKey: string) { + return page.getByTestId(`sidebar-project-row-${projectKey}`); +} + +function projectNewWorktreeIcon(page: Page, projectKey: string) { + return page.getByTestId(`sidebar-project-new-worktree-${projectKey}`); +} + +async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise { + const created = await seeded.client.createWorkspace({ + backing: "local", + cwd: seeded.repoPath, + projectId: seeded.projectId, + title, + }); + if (!created.workspace) { + throw new Error(created.error ?? `Failed to create second workspace for ${seeded.projectId}`); + } + return created.workspace.id; +} + +test.describe("Model B sidebar shape", () => { + test.describe.configure({ timeout: 180_000 }); + + test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({ + page, + }) => { + const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" }); + const nonGitProject = await seedWorkspace({ repoPrefix: "model-b-nongit-", git: false }); + + try { + const gitSecondId = await seedSecondWorkspace(gitProject, "Git second"); + const nonGitSecondId = await seedSecondWorkspace(nonGitProject, "Non-git second"); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + + // Both projects are expandable parents — the non-git one is NOT flattened + // into a bare workspace link. + await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 }); + + // Each parent shows both of its workspace rows underneath. + await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 }); + await expect(workspaceRow(page, gitSecondId)).toBeVisible({ timeout: 30_000 }); + await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 }); + await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 }); + + // The per-project "+ New workspace" row is gone. The git project keeps a + // per-row new-worktree icon (revealed on hover); the non-git project has + // none, since worktree creation needs a git checkout. + await projectRow(page, gitProject.projectId).hover(); + await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({ + timeout: 30_000, + }); + await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0); + + // The global new-workspace button is the universal entry — present for both + // kinds regardless of their per-row affordance. + await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible({ + timeout: 30_000, + }); + } finally { + await gitProject.cleanup(); + await nonGitProject.cleanup(); + } + }); + + test("no tab, agent, or terminal ever renders as a sidebar row", async ({ page }) => { + const mock = await seedMockAgentWorkspace({ + repoPrefix: "model-b-leaf-", + title: "Leaf workspace", + }); + + try { + // Open the workspace and materialize both an agent tab and a terminal tab. + await gotoWorkspace(page, mock.workspaceId); + const agentTabs = await getVisibleWorkspaceAgentTabIds(page); + expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`); + + await clickNewTerminal(page); + await expect( + page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + + // The deepest level inside the sidebar is the workspace row: no tab, + // agent, or terminal element appears as a sidebar descendant. + const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first(); + await expect(workspaceRow(page, mock.workspaceId).first()).toBeVisible({ timeout: 30_000 }); + await expect(sidebar.locator('[data-testid^="workspace-tab-"]')).toHaveCount(0); + await expect(sidebar.locator('[data-testid^="sidebar-agent-row-"]')).toHaveCount(0); + await expect(sidebar.locator('[data-testid^="sidebar-terminal-row-"]')).toHaveCount(0); + } finally { + await mock.cleanup(); + } + }); + + test("status grouping shows only workspace rows and moves a single row when its status changes", async ({ + page, + }) => { + const idleProject = await seedWorkspace({ repoPrefix: "model-b-status-idle-" }); + const activeMock = await seedMockAgentWorkspace({ + repoPrefix: "model-b-status-active-", + title: "Working workspace", + initialPrompt: "stay busy", + }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 }); + + // Switch to status grouping. + await page.getByTestId("sidebar-grouping-selector").click(); + await page.getByTestId("sidebar-grouping-status").click(); + + const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first(); + + // The idle workspace lands in the Done bucket; the busy mock-agent workspace + // lands in the Working bucket. Each workspace is bucketed independently. + await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("sidebar-status-group-running")).toBeVisible({ + timeout: 60_000, + }); + await expect(workspaceRow(page, idleProject.workspaceId).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(workspaceRow(page, activeMock.workspaceId).first()).toBeVisible({ + timeout: 60_000, + }); + + // Only workspace rows are shown — no tab/agent/terminal leaves leak into + // the status view. + await expect(sidebar.locator('[data-testid^="workspace-tab-"]')).toHaveCount(0); + + // The busy workspace is grouped under Working, the idle one under Done: + // changing one workspace's status moved only that row. + const workingRows = page.getByTestId("sidebar-status-group-rows-running"); + const doneRows = page.getByTestId("sidebar-status-group-rows-done"); + await expect( + workingRows.getByTestId(`sidebar-workspace-row-${getServerId()}:${activeMock.workspaceId}`), + ).toBeVisible({ timeout: 60_000 }); + await expect( + doneRows.getByTestId(`sidebar-workspace-row-${getServerId()}:${idleProject.workspaceId}`), + ).toBeVisible({ timeout: 30_000 }); + // The busy workspace is NOT also sitting in the Done bucket — only its own + // row moved. + await expect( + doneRows.getByTestId(`sidebar-workspace-row-${getServerId()}:${activeMock.workspaceId}`), + ).toHaveCount(0); + } finally { + await idleProject.cleanup(); + await activeMock.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/sidebar-workspace-rename.spec.ts b/packages/app/e2e/sidebar-workspace-rename.spec.ts index fe8de73df..8e3dadce0 100644 --- a/packages/app/e2e/sidebar-workspace-rename.spec.ts +++ b/packages/app/e2e/sidebar-workspace-rename.spec.ts @@ -1,8 +1,6 @@ -import { execSync } from "node:child_process"; import { test, expect, type Page } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { seedWorkspace } from "./helpers/seed-client"; -import { captureWsSessionFrames } from "./helpers/rename"; import { getServerId } from "./helpers/server-id"; function workspaceRowTestId(workspaceId: string): string { @@ -32,24 +30,16 @@ async function openRenameModal(page: Page, workspaceId: string) { return input; } +// In Model B the workspace title is its identity: renaming sets a custom title +// layered over the derived branch/directory name, and reconciliation never +// touches it. The sidebar row shows the title verbatim — no branch mutation. test.describe("Sidebar workspace rename", () => { - test("renaming via kebab updates the branch name on disk and in the sidebar", async ({ - page, - }) => { + test("renaming via kebab sets a custom title that survives reload", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" }); try { expect(workspace.workspaceName).toBe("main"); - const renameRequests = captureWsSessionFrames( - page, - "checkout.rename_branch.request", - (inner) => ({ - branch: String(inner.branch ?? ""), - cwd: String(inner.cwd ?? ""), - }), - ); - await gotoAppShell(page); await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({ timeout: 30_000, @@ -57,56 +47,26 @@ test.describe("Sidebar workspace rename", () => { const input = await openRenameModal(page, workspace.workspaceId); await expect(input).toHaveValue("main"); - await input.fill("Feature Rename 2"); + const customTitle = "Payments Refactor"; + await input.fill(customTitle); await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click(); await expect(input).toHaveCount(0, { timeout: 15_000 }); + // The title is shown exactly as typed — not slugified into a branch name. await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText( - "feature-rename-2", + customTitle, { timeout: 15_000 }, ); - expect(renameRequests.length).toBeGreaterThan(0); - expect(renameRequests.at(-1)).toEqual({ - branch: "feature-rename-2", - cwd: workspace.workspaceDirectory, - }); - - const currentBranchOnDisk = execSync("git branch --show-current", { - cwd: workspace.repoPath, - stdio: "pipe", - }) - .toString() - .trim(); - expect(currentBranchOnDisk).toBe("feature-rename-2"); - } finally { - await workspace.cleanup(); - } - }); - - test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => { - const workspace = await seedWorkspace({ - repoPrefix: "sidebar-rename-error-", - repo: { branches: ["taken"] }, - }); - - try { - await gotoAppShell(page); - const input = await openRenameModal(page, workspace.workspaceId); - await expect(input).toHaveValue("main"); - - await input.fill("taken"); - await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click(); - - const errorNode = page.getByTestId( - workspaceRenameModalTestId(workspace.workspaceId, "error"), - ); - await expect(errorNode).toBeVisible({ timeout: 15_000 }); - await expect(errorNode).toContainText(/already exists|branch/i); - await expect(input).toBeVisible(); + // The custom title is backing metadata on the workspace: a full reload + // re-resolves the descriptor from persistence and must not lose it. This + // exercises the same descriptor resolution reconciliation re-runs against, + // so a reconcile pass cannot overwrite the user's title either. + await page.reload(); await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText( - "main", + customTitle, + { timeout: 30_000 }, ); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/workspace-multiplicity.spec.ts b/packages/app/e2e/workspace-multiplicity.spec.ts new file mode 100644 index 000000000..53604a599 --- /dev/null +++ b/packages/app/e2e/workspace-multiplicity.spec.ts @@ -0,0 +1,226 @@ +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { gotoWorkspace } from "./helpers/launcher"; +import { + assertNewWorkspaceSidebarAndHeader, + connectNewWorkspaceDaemonClient, + openGlobalNewWorkspaceComposer, + selectNewWorkspaceProject, + selectWorkspaceBacking, + submitNewWorkspaceEmpty, +} from "./helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { expectExplorerEntryVisible } from "./helpers/file-explorer"; +import { getServerId } from "./helpers/server-id"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; + +// Model B reshape: a workspace is the unit, its backing directory (local +// checkout or worktree) is a CHOICE at creation, and creation NEVER dedupes by +// directory. These specs drive the real creation UI (workspace-create-* test +// IDs) to prove a single directory can back any number of workspaces. + +function workspaceRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +// On desktop the file explorer is pinned open; on narrower layouts it must be +// toggled first. Open it either way, then select the Files tab. +async function openFilesTab(page: Page): Promise { + const openButton = page.getByRole("button", { name: "Open explorer" }).first(); + if (await openButton.isVisible().catch(() => false)) { + await openButton.click(); + } + await page.getByTestId("explorer-tab-files").click(); + await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 }); +} + +async function createWorkspaceViaUi( + page: Page, + input: { + project: { projectKey: string; projectDisplayName: string }; + // null when the project has no git checkout: there is no Isolation control to + // touch, the backing is implicitly local. + backing: "local" | "worktree" | null; + previousWorkspaceId: string; + client: Awaited>; + }, +): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> { + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, input.project); + if (input.backing !== null) { + await selectWorkspaceBacking(page, input.backing); + } + await submitNewWorkspaceEmpty(page); + + return assertNewWorkspaceSidebarAndHeader(page, { + serverId: getServerId(), + client: input.client, + previousWorkspaceId: input.previousWorkspaceId, + projectDisplayName: input.project.projectDisplayName, + assertSidebarRow: false, + assertHeader: false, + }); +} + +test.describe("Workspace multiplicity creation flow", () => { + let client: Awaited>; + + test.describe.configure({ timeout: 240_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + }); + + test.afterEach(async () => { + await client?.close().catch(() => undefined); + }); + + test("two Local workspaces share one git checkout and both are independently selectable", async ({ + page, + }) => { + const seeded: SeededWorkspace = await seedWorkspace({ + repoPrefix: "multiplicity-local-git-", + }); + + try { + const project = { + projectKey: seeded.projectId, + projectDisplayName: seeded.projectDisplayName, + }; + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ + timeout: 30_000, + }); + + const second = await createWorkspaceViaUi(page, { + project, + backing: "local", + previousWorkspaceId: seeded.workspaceId, + client, + }); + + // A second workspace was minted on the SAME checkout — creation did not + // dedupe the directory away. + expect(second.workspaceId).not.toBe(seeded.workspaceId); + expect(second.workspaceDirectory).toBe(seeded.workspaceDirectory); + + // Both rows live under the same project and are distinct. + const firstRow = page.getByTestId(workspaceRowTestId(seeded.workspaceId)); + const secondRow = page.getByTestId(workspaceRowTestId(second.workspaceId)); + await expect(firstRow).toBeVisible({ timeout: 30_000 }); + await expect(secondRow).toBeVisible({ timeout: 30_000 }); + await expect(secondRow).toContainText(second.workspaceName); + + // Selecting the second workspace shows the shared checkout's files. + await gotoWorkspace(page, second.workspaceId); + await openFilesTab(page); + await expectExplorerEntryVisible(page, "README.md"); + + // Selecting the first workspace shows the SAME shared directory data. + await gotoWorkspace(page, seeded.workspaceId); + await openFilesTab(page); + await expectExplorerEntryVisible(page, "README.md"); + } finally { + await seeded.cleanup(); + } + }); + + test("New worktree backing creates a worktree-backed workspace in a distinct directory", async ({ + page, + }) => { + const seeded: SeededWorkspace = await seedWorkspace({ + repoPrefix: "multiplicity-worktree-", + }); + + try { + const project = { + projectKey: seeded.projectId, + projectDisplayName: seeded.projectDisplayName, + }; + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ + timeout: 30_000, + }); + + const worktree = await createWorkspaceViaUi(page, { + project, + backing: "worktree", + previousWorkspaceId: seeded.workspaceId, + client, + }); + + // The worktree row appears, pointing at a directory distinct from the + // backing checkout. + const worktreeRow = page.getByTestId(workspaceRowTestId(worktree.workspaceId)); + await expect(worktreeRow).toBeVisible({ timeout: 30_000 }); + expect(worktree.workspaceId).not.toBe(seeded.workspaceId); + expect(worktree.workspaceDirectory).not.toBe(seeded.workspaceDirectory); + + // The daemon descriptor confirms the worktree kind (○ row). + const descriptor = (await client.fetchWorkspaces()).entries.find( + (entry) => entry.id === worktree.workspaceId, + ); + expect(descriptor?.workspaceKind).toBe("worktree"); + + await client + .archivePaseoWorktree({ worktreePath: worktree.workspaceDirectory }) + .catch(() => undefined); + } finally { + await seeded.cleanup(); + } + }); + + test("two Local workspaces appear under the same non-git project", async ({ page }) => { + const seeded: SeededWorkspace = await seedWorkspace({ + repoPrefix: "multiplicity-local-nongit-", + git: false, + }); + + try { + const project = { + projectKey: seeded.projectId, + projectDisplayName: seeded.projectDisplayName, + }; + + await gotoAppShell(page); + await waitForSidebarHydration(page); + // Model B: a non-git project is an expandable parent like any other, with + // its single workspace already rendered as its own row underneath. + await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ + timeout: 30_000, + }); + + const second = await createWorkspaceViaUi(page, { + project, + // Non-git project: no Isolation control, backing is implicitly local. + backing: null, + previousWorkspaceId: seeded.workspaceId, + client, + }); + + expect(second.workspaceId).not.toBe(seeded.workspaceId); + expect(second.workspaceDirectory).toBe(seeded.workspaceDirectory); + + // Both the original and the new workspace render as distinct rows under + // the same expandable parent. + await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ + timeout: 30_000, + }); + const secondRow = page.getByTestId(workspaceRowTestId(second.workspaceId)); + await expect(secondRow).toBeVisible({ timeout: 30_000 }); + await expect(secondRow).toContainText(second.workspaceName); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/worktree-archive-keep-prompt.spec.ts b/packages/app/e2e/worktree-archive-keep-prompt.spec.ts new file mode 100644 index 000000000..4ad13f4a5 --- /dev/null +++ b/packages/app/e2e/worktree-archive-keep-prompt.spec.ts @@ -0,0 +1,69 @@ +import { existsSync } from "node:fs"; +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { getServerId } from "./helpers/server-id"; +import { expectWorkspaceAbsentFromSidebar, openWorktreeDeletePrompt } from "./helpers/sidebar"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; + +// Model B: archiving the LAST reference to a Paseo-owned worktree opens the +// keep/delete prompt. Choosing "Keep on disk" archives the workspace record (the +// row disappears) but leaves the worktree directory on disk, because a directory +// can back multiple workspaces and archive removes the task, not the directory. +test.describe("Worktree archive keep prompt", () => { + let client: Awaited>; + let tempRepo: { path: string; cleanup: () => Promise }; + const createdWorktreeDirectories = new Set(); + + test.describe.configure({ retries: 1, timeout: 120_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + tempRepo = await createTempGitRepo("wt-archive-keep-"); + }); + + test.afterEach(async () => { + for (const directory of createdWorktreeDirectories) { + await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined); + } + createdWorktreeDirectories.clear(); + await client?.close().catch(() => undefined); + await tempRepo?.cleanup().catch(() => undefined); + }); + + test("keeping a last-reference worktree on disk removes the row but preserves the directory", async ({ + page, + }) => { + const serverId = getServerId(); + await openProjectViaDaemon(client, tempRepo.path); + const worktree = await createWorktreeViaDaemon(client, { + cwd: tempRepo.path, + slug: `archive-keep-${Date.now()}`, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId }); + + await openWorktreeDeletePrompt(page, worktree.workspaceId); + await page.getByTestId("worktree-delete-confirm-keep").click(); + + await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId); + + // The row is gone, but keeping on disk leaves the worktree directory in place + // and the git worktree still registered with the repo. + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + const listed = await client.getPaseoWorktreeList({ cwd: tempRepo.path }); + expect( + listed.worktrees.some((entry) => entry.worktreePath === worktree.workspaceDirectory), + ).toBe(true); + }); +}); diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index 0a460978a..13a17b999 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -1,42 +1,45 @@ import { useCallback, useMemo, useRef } from "react"; -import { Pressable, View, type PressableStateCallbackType } from "react-native"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, GitBranch } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { Combobox, ComboboxItem } from "@/components/ui/combobox"; -import type { ComboboxProps } from "@/components/ui/combobox"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import type { Theme } from "@/styles/theme"; +import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; -import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; -import { ScreenTitle } from "@/components/headers/screen-title"; interface BranchSwitcherProps { currentBranchName: string | null; - title: string; serverId: string; workspaceId: string; + workspaceDirectory: string | null; isGitCheckout: boolean; + testID?: string; } +const foregroundMutedIconColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const ThemedGitBranch = withUnistyles(GitBranch); +const ThemedChevronDown = withUnistyles(ChevronDown); + export function BranchSwitcher({ currentBranchName, - title, serverId, workspaceId, + workspaceDirectory, isGitCheckout, + testID = "workspace-header-branch-switcher", }: BranchSwitcherProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); - const isCompact = useIsCompactFormFactor(); const anchorRef = useRef(null); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const toast = useToast(); const queryClient = useQueryClient(); - const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId); const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({ client, @@ -50,26 +53,19 @@ export function BranchSwitcher({ queryClient, }); - const titleContent = ( - - {isGitCheckout ? : null} - {title} - - ); - const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]); const triggerStyle = useCallback( ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.branchSwitcherTrigger, - (Boolean(hovered) || pressed) && styles.branchSwitcherTriggerHovered, + styles.trigger, + (Boolean(hovered) || pressed) && styles.triggerHovered, ], [], ); const branchLeadingSlot = useMemo( - () => , - [theme.colors.foregroundMuted], + () => , + [], ); const renderBranchOption = useCallback>( @@ -86,20 +82,23 @@ export function BranchSwitcher({ ); if (!currentBranchName) { - return {titleContent}; + return null; } return ( - + - {titleContent} - {!isCompact ? : null} + + + {currentBranchName} + + ({ - branchSwitcherTrigger: { + anchor: { + flexShrink: 1, + minWidth: 0, + }, + trigger: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], minWidth: 0, - marginLeft: { - xs: -theme.spacing[2], - md: 0, - }, - paddingVertical: { - xs: 0, - md: theme.spacing[1], - }, + paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], + marginLeft: -theme.spacing[2], borderRadius: theme.borderRadius.md, flexShrink: 1, }, - branchSwitcherTriggerHovered: { + triggerHovered: { backgroundColor: theme.colors.surface1, }, - titleRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - minWidth: 0, - overflow: "hidden", + branchLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + fontWeight: theme.fontWeight.medium, + flexShrink: 1, }, })); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index a03708e44..a8f4c2ada 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -520,7 +520,6 @@ function SidebarContent({ serverId={serverId} workspaceId={workspaceId} cwd={workspaceRoot} - hideHeaderRow={!isMobile} enabled={isOpen} /> )} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 4ab82ae9b..cc88a88ac 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router, usePathname } from "expo-router"; -import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native"; +import { Clock, FolderPlus, Home, Plus, Search, Settings, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { type Dispatch, @@ -116,6 +116,7 @@ interface SidebarSharedProps { interface SidebarLabels { addProject: string; + newWorkspace: string; home: string; settings: string; switchHost: string; @@ -242,7 +243,7 @@ export const LeftSidebar = memo(function LeftSidebar({ const handleNewWorkspaceNavigate = useCallback(() => { if (!activeServerId) return; - router.push(buildHostNewWorkspaceRoute(activeServerId)); + router.navigate(buildHostNewWorkspaceRoute(activeServerId)); }, [activeServerId]); const handleSettingsMobile = useCallback(() => { @@ -287,6 +288,7 @@ export const LeftSidebar = memo(function LeftSidebar({ const labels = useMemo( (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), + newWorkspace: t("sidebar.actions.newWorkspace"), home: t("sidebar.actions.home"), settings: t("sidebar.actions.settings"), switchHost: t("sidebar.host.switchTitle"), @@ -800,13 +802,21 @@ function MobileSidebar({ - + + {padding.top > 0 ? : null} - + + @@ -1137,8 +1155,13 @@ const staticStyles = RNStyleSheet.create({ }); const styles = StyleSheet.create((theme) => ({ - sidebarHeaderRow: { - position: "relative", + sidebarHeaderGroup: { + paddingTop: theme.spacing[2], + // Match WorkspacesSectionHeader's paddingTop below the divider so the divider + // sits visually centered between the Sessions row and the Workspaces header. + paddingBottom: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, }, workspacesSectionHeader: { flexDirection: "row", diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 83f4ac321..bbe1828e3 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -11,11 +11,9 @@ import { type ViewStyle, } from "react-native"; import * as Haptics from "expo-haptics"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; +import { useMutation } from "@tanstack/react-query"; import { ProjectIconView } from "@/components/project-icon-view"; import { AdaptiveRenameModal } from "@/components/rename-modal"; -import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { memo, useCallback, @@ -73,13 +71,7 @@ import { } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, - useContextMenu, -} from "@/components/ui/context-menu"; +import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuTrigger, @@ -121,14 +113,9 @@ import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; -import { - confirmRiskyWorktreeArchive, - type WorktreeArchiveWarningLabels, -} from "@/git/worktree-archive-warning"; -import { - archiveWorkspaceOptimistically, - archiveWorkspacesOptimistically, -} from "@/workspace/workspace-archive"; +import { archiveWorkspacesOptimistically } from "@/workspace/workspace-archive"; +import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; +import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt"; import { isWeb as platformIsWeb, isNative as platformIsNative, @@ -184,40 +171,6 @@ const syncedLoaderColorMapping = (theme: Theme) => ({ : theme.colors.palette.amber[500], }); -function getWorktreeArchiveWarningLabels( - t: (key: string, options?: Record) => string, -): WorktreeArchiveWarningLabels { - return { - title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }), - confirm: t("workspace.git.actions.archiveWarning.confirm"), - cancel: t("workspace.git.actions.archiveWarning.cancel"), - uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"), - uncommittedChangesWithDiff: (diffStat) => - t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }), - addedLine: (count) => - t( - count === 1 - ? "workspace.git.actions.archiveWarning.addedLine" - : "workspace.git.actions.archiveWarning.addedLines", - { count }, - ), - deletedLine: (count) => - t( - count === 1 - ? "workspace.git.actions.archiveWarning.deletedLine" - : "workspace.git.actions.archiveWarning.deletedLines", - { count }, - ), - unpushedCommit: (count) => - t( - count === 1 - ? "workspace.git.actions.archiveWarning.unpushedCommit" - : "workspace.git.actions.archiveWarning.unpushedCommits", - { count }, - ), - }; -} - function getPrIconUniMapping(state: PrHint["state"]) { switch (state) { case "merged": @@ -1531,14 +1484,12 @@ function WorkspaceRowWithMenu({ }) { const { t } = useTranslation(); const toast = useToast(); - const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); - const queryClient = useQueryClient(); - const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); + const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); const workspaceDirectory = resolveWorkspaceDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); - const archiveStatus = useCheckoutGitActionsStore((state) => + const worktreeArchiveStatus = useCheckoutGitActionsStore((state) => workspaceDirectory ? state.getStatus({ serverId: workspace.serverId, @@ -1548,7 +1499,7 @@ function WorkspaceRowWithMenu({ : "idle", ); const isWorktree = workspace.workspaceKind === "worktree"; - const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace; + const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace; const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, @@ -1557,102 +1508,18 @@ function WorkspaceRowWithMenu({ }); }, [selected, workspace]); - const archiveWorktreeAfterConfirmation = useCallback(async () => { + const archiveController = useWorkspaceArchive({ + workspace, + onArchiveStarted: redirectAfterArchive, + onSetHiding: setIsHidingWorkspace, + }); + + const handleArchive = useCallback(() => { if (isArchiving) { return; } - - const confirmed = await confirmRiskyWorktreeArchive( - { - worktreeName: workspace.name, - isDirty: workspace.archiveHasUncommittedChanges, - aheadOfOrigin: workspace.archiveUnpushedCommitCount, - diffStat: workspace.diffStat, - }, - getWorktreeArchiveWarningLabels(t), - ); - - if (!confirmed) { - return; - } - let archiveDirectory: string; - try { - archiveDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("sidebar.workspace.toasts.workspacePathUnavailable"), - ); - return; - } - - if (!archiveDirectory) { - toast.error(t("sidebar.workspace.toasts.workspacePathUnavailable")); - return; - } - - redirectAfterArchive(); - - void archiveWorktree({ - serverId: workspace.serverId, - cwd: archiveDirectory, - worktreePath: archiveDirectory, - }).catch((error) => { - const message = - error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"); - toast.error(message); - }); - }, [archiveWorktree, isArchiving, redirectAfterArchive, t, toast, workspace]); - - const handleArchiveWorktree = useCallback(() => { - void archiveWorktreeAfterConfirmation(); - }, [archiveWorktreeAfterConfirmation]); - - const hideWorkspaceAfterConfirmation = useCallback(async () => { - if (isArchivingWorkspace) { - return; - } - - const confirmed = await confirmDialog({ - title: t("sidebar.workspace.confirmations.hideTitle"), - message: t("sidebar.workspace.confirmations.hideMessage", { workspaceName: workspace.name }), - confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"), - cancelLabel: t("sidebar.workspace.confirmations.cancel"), - destructive: true, - }); - if (!confirmed) { - return; - } - - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - toast.error(t("sidebar.workspace.toasts.hostDisconnected")); - return; - } - - setIsArchivingWorkspace(true); - try { - await archiveWorkspaceOptimistically({ - client, - workspace, - afterHide: redirectAfterArchive, - }); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"), - ); - } finally { - setIsArchivingWorkspace(false); - } - }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]); - - const handleArchiveWorkspace = useCallback(() => { - void hideWorkspaceAfterConfirmation(); - }, [hideWorkspaceAfterConfirmation]); + archiveController.beginArchive(); + }, [archiveController, isArchiving]); const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; @@ -1674,31 +1541,20 @@ function WorkspaceRowWithMenu({ }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { - void Clipboard.setStringAsync(workspace.name); + if (!workspace.currentBranch) { + return; + } + void Clipboard.setStringAsync(workspace.currentBranch); toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); - }, [t, toast, workspace.name]); + }, [t, toast, workspace.currentBranch]); const renameMutation = useMutation({ - mutationFn: async (branch: string) => { + mutationFn: async (title: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); } - const targetCwd = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - const payload = await client.renameBranch({ cwd: targetCwd, branch }); - if (!payload.success || payload.error) { - throw new Error(payload.error?.message ?? t("sidebar.workspace.rename.invalidBranchName")); - } - return { targetCwd }; - }, - onSuccess: async ({ targetCwd }) => { - await invalidateCheckoutGitQueriesForClient(queryClient, { - serverId: workspace.serverId, - cwd: targetCwd, - }); + await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); }, }); @@ -1712,20 +1568,11 @@ function WorkspaceRowWithMenu({ const handleSubmitRename = useCallback( async (value: string) => { - await renameMutation.mutateAsync(slugify(value)); + await renameMutation.mutateAsync(value.trim()); }, [renameMutation], ); - const validateRenameSlug = useCallback( - (value: string): string | null => { - const result = validateBranchSlug(slugify(value)); - if (result.valid) return null; - return result.error ?? t("sidebar.workspace.rename.invalidBranchName"); - }, - [t], - ); - const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ serverId: workspace.serverId, @@ -1743,11 +1590,7 @@ function WorkspaceRowWithMenu({ enabled: selected && !isArchiving, priority: 0, handle: () => { - if (isWorktree) { - void archiveWorktreeAfterConfirmation(); - } else { - handleArchiveWorkspace(); - } + handleArchive(); return true; }, }); @@ -1766,32 +1609,33 @@ function WorkspaceRowWithMenu({ isCreating={isCreating} dragHandleProps={dragHandleProps} menuController={null} - archiveLabel={ - isWorktree - ? t("sidebar.workspace.actions.archiveWorktree") - : t("sidebar.workspace.actions.hideFromSidebar") - } - archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)} - archivePendingLabel={ - isWorktree - ? t("sidebar.workspace.actions.archiving") - : t("sidebar.workspace.actions.hiding") - } - onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace} + archiveLabel={t("sidebar.workspace.actions.archive")} + archiveStatus={getWorkspaceArchiveStatus( + isWorktree, + worktreeArchiveStatus, + isHidingWorkspace, + )} + archivePendingLabel={t("sidebar.workspace.actions.archiving")} + onArchive={handleArchive} onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined} onCopyPath={handleCopyPath} - onRename={canCopyBranchName ? handleOpenRename : undefined} + onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> + void; - shortcutNumber: number | null; - showShortcutBadge: boolean; - drag: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; -}) { - const { t } = useTranslation(); - const toast = useToast(); - const contextMenu = useContextMenu(); - const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); - const redirectAfterArchive = useCallback(() => { - redirectIfArchivingActiveWorkspace({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace), - }); - }, [selected, workspace]); - - const handleArchiveWorkspace = useCallback(() => { - if (isArchivingWorkspace) { - return; - } - - void (async () => { - const confirmed = await confirmDialog({ - title: t("sidebar.workspace.confirmations.hideTitle"), - message: t("sidebar.workspace.confirmations.hideMessage", { - workspaceName: workspace.name, - }), - confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"), - cancelLabel: t("sidebar.workspace.confirmations.cancel"), - destructive: true, - }); - if (!confirmed) { - return; - } - - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - toast.error(t("sidebar.workspace.toasts.hostDisconnected")); - return; - } - - setIsArchivingWorkspace(true); - void (async () => { - try { - await archiveWorkspaceOptimistically({ - client, - workspace, - afterHide: redirectAfterArchive, - }); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"), - ); - } finally { - setIsArchivingWorkspace(false); - } - })(); - })(); - }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]); - - return ( - <> - - - - {t("sidebar.workspace.actions.hideFromSidebar")} - - - - ); -} - -function NonGitProjectRowWithMenu(props: { - project: SidebarProjectEntry; - displayName: string; - iconDataUri: string | null; - workspace: SidebarWorkspaceEntry; - selected: boolean; - onPress: () => void; - shortcutNumber: number | null; - showShortcutBadge: boolean; - drag: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; -}) { - return ( - - - - ); -} - -function FlattenedProjectRow({ - project, - displayName, - iconDataUri, - rowModel, - onPress, - serverId, - onWorkspacePress, - onWorktreeCreated, - shortcutNumber, - showShortcutBadge, - drag, - isDragging, - dragHandleProps, - isProjectActive = false, - onRemoveProject, - removeProjectStatus, - selectionEnabled, - activeWorkspaceSelection, -}: { - project: SidebarProjectEntry; - displayName: string; - iconDataUri: string | null; - rowModel: Extract, { kind: "workspace_link" }>; - onPress: () => void; - serverId: string | null; - onWorkspacePress?: () => void; - onWorktreeCreated?: (workspaceId: string) => void; - shortcutNumber: number | null; - showShortcutBadge: boolean; - drag: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; - isProjectActive?: boolean; - onRemoveProject?: () => void; - removeProjectStatus?: "idle" | "pending"; - selectionEnabled: boolean; - activeWorkspaceSelection: ActiveWorkspaceSelection | null; -}) { - const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId); - const selected = isWorkspaceSelected({ - selection: activeWorkspaceSelection, - serverId, - workspaceId: rowModel.workspace.workspaceId, - enabled: selectionEnabled, - }); - - if (!workspace) { - return null; - } - - if (project.projectKind === "directory") { - return ( - - ); - } - - return ( - - ); -} - interface WorkspaceRowItemProps { workspace: SidebarWorkspaceEntry; shortcutNumber: number | null; @@ -2331,84 +1939,49 @@ function ProjectBlock({ })(); }, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]); - const flattenedRowWorkspaceId = - rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null; - const handleFlattenedRowPress = useCallback(() => { - if (!serverId || !flattenedRowWorkspaceId) { - return; - } - onWorkspacePress?.(); - navigateToWorkspace(serverId, flattenedRowWorkspaceId); - }, [serverId, flattenedRowWorkspaceId, onWorkspacePress]); - const handleToggleCollapsed = useCallback(() => { onToggleCollapsed(project.projectKey); }, [onToggleCollapsed, project.projectKey]); return ( - {rowModel.kind === "workspace_link" ? ( - - ) : ( - <> - + - {!collapsed ? ( - - ) : null} - - )} + {!collapsed && project.workspaces.length > 0 ? ( + + ) : null} ); } diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index d1d9c9b13..6d4277329 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -1,8 +1,15 @@ import { useCallback, useMemo } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { LucideIcon } from "lucide-react-native"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; +import { ICON_SIZE } from "@/styles/theme"; +import type { Theme } from "@/styles/theme"; + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +type SidebarHeaderRowVariant = "header" | "compact"; interface SidebarHeaderRowProps { icon: LucideIcon; @@ -12,14 +19,15 @@ interface SidebarHeaderRowProps { testID?: string; nativeID?: string; accessibilityLabel?: string; + /** + * "header" (default): a sidebar-height row with its own bottom separator — + * the lone header at the top of a sidebar (settings "Back to workspace"). + * "compact": a workspace-row-height row with no separator, for entries that + * sit in a header group whose wrapper owns the single divider. + */ + variant?: SidebarHeaderRowVariant; } -/** - * Top-of-sidebar header row: a sidebar-height pressable with an icon + label - * and a full-width border separator beneath. Used as the first element of a - * sidebar (workspace "Sessions", settings "Back to workspace"). Owns its own - * separator line so both sidebars converge on the same edge and padding. - */ export function SidebarHeaderRow({ icon: Icon, label, @@ -28,8 +36,14 @@ export function SidebarHeaderRow({ testID, nativeID, accessibilityLabel, + variant = "header", }: SidebarHeaderRowProps) { - const { theme } = useUnistyles(); + const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); + + const containerStyle = useMemo( + () => (variant === "compact" ? styles.containerCompact : styles.container), + [variant], + ); const buttonStyle = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -42,26 +56,21 @@ export function SidebarHeaderRow({ const renderChildren = useCallback( (state: PressableStateCallbackType & { hovered?: boolean }) => { const isHighlighted = Boolean(state.hovered) || isActive; - const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted; return ( <> - + ); }, - [ - Icon, - isActive, - label, - theme.colors.foreground, - theme.colors.foregroundMuted, - theme.iconSize.md, - ], + [ThemedIcon, isActive, label], ); return ( - + ({ borderBottomColor: theme.colors.border, userSelect: "none", }, + containerCompact: { + paddingHorizontal: theme.spacing[2], + justifyContent: "center", + userSelect: "none", + }, button: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], + // Match the sidebar workspace-row shape (height, padding, radius) so the + // compact header entries sit tight against the workspace list below. + minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, @@ -115,7 +132,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surfaceSidebarHover, }, label: { - fontSize: theme.fontSize.base, + fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 1f9122346..13d1de456 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -35,17 +35,14 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/contexts/toast-context"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; -import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; -import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; +import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; +import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; -import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; -import { confirmDialog } from "@/utils/confirm-dialog"; import * as Clipboard from "expo-clipboard"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -188,7 +185,10 @@ function StatusGroupList({ {!collapsedStatusGroupKeys.has(group.bucket) ? ( - + {group.rows.map((workspace) => ( state.archiveWorktree); - const queryClient = useQueryClient(); - const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); + const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); const workspaceDirectory = resolveWorkspaceDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); - const archiveStatus = useCheckoutGitActionsStore((state) => + const worktreeArchiveStatus = useCheckoutGitActionsStore((state) => workspaceDirectory ? state.getStatus({ serverId: workspace.serverId, @@ -365,7 +363,7 @@ function StatusWorkspaceRowWithMenu({ : "idle", ); const isWorktree = workspace.workspaceKind === "worktree"; - const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace; + const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace; const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ @@ -377,63 +375,16 @@ function StatusWorkspaceRowWithMenu({ }); }, [selected, workspace]); - const archiveWorktreeAfterConfirmation = useCallback(async () => { - if (isArchiving) return; - const confirmed = await confirmRiskyWorktreeArchive({ - worktreeName: workspace.name, - isDirty: workspace.archiveHasUncommittedChanges, - aheadOfOrigin: workspace.archiveUnpushedCommitCount, - diffStat: workspace.diffStat, - }); - if (!confirmed) return; - let archiveDirectory: string; - try { - archiveDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Workspace path not available"); - return; - } - redirectAfterArchive(); - void archiveWorktree({ - serverId: workspace.serverId, - cwd: archiveDirectory, - worktreePath: archiveDirectory, - }).catch((error) => { - toast.error(error instanceof Error ? error.message : "Failed to archive worktree"); - }); - }, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]); + const archiveController = useWorkspaceArchive({ + workspace, + onArchiveStarted: redirectAfterArchive, + onSetHiding: setIsHidingWorkspace, + }); - const hideWorkspaceAfterConfirmation = useCallback(async () => { - if (isArchivingWorkspace) return; - const confirmed = await confirmDialog({ - title: "Hide workspace?", - message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, - confirmLabel: "Hide", - cancelLabel: "Cancel", - destructive: true, - }); - if (!confirmed) return; - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - toast.error(t("workspace.terminal.hostDisconnected")); - return; - } - setIsArchivingWorkspace(true); - try { - await archiveWorkspaceOptimistically({ - client, - workspace, - afterHide: redirectAfterArchive, - }); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); - } finally { - setIsArchivingWorkspace(false); - } - }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]); + const handleArchive = useCallback(() => { + if (isArchiving) return; + archiveController.beginArchive(); + }, [archiveController, isArchiving]); const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; @@ -456,24 +407,10 @@ function StatusWorkspaceRowWithMenu({ }, [toast, workspace.name]); const renameMutation = useMutation({ - mutationFn: async (branch: string) => { + mutationFn: async (title: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); - const targetCwd = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - const payload = await client.renameBranch({ cwd: targetCwd, branch }); - if (!payload.success || payload.error) { - throw new Error(payload.error?.message ?? "Failed to rename branch"); - } - return { targetCwd }; - }, - onSuccess: async ({ targetCwd }) => { - await invalidateCheckoutGitQueriesForClient(queryClient, { - serverId: workspace.serverId, - cwd: targetCwd, - }); + await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); }, }); @@ -481,15 +418,10 @@ function StatusWorkspaceRowWithMenu({ const handleCloseRename = useCallback(() => setIsRenameOpen(false), []); const handleSubmitRename = useCallback( async (value: string) => { - await renameMutation.mutateAsync(slugify(value)); + await renameMutation.mutateAsync(value.trim()); }, [renameMutation], ); - const validateRenameSlug = useCallback((value: string): string | null => { - const result = validateBranchSlug(slugify(value)); - if (result.valid) return null; - return result.error ?? "Invalid branch name"; - }, []); const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ @@ -508,19 +440,15 @@ function StatusWorkspaceRowWithMenu({ enabled: selected && !isArchiving, priority: 0, handle: () => { - if (isWorktree) { - void archiveWorktreeAfterConfirmation(); - } else { - void hideWorkspaceAfterConfirmation(); - } + handleArchive(); return true; }, }); let computedArchiveStatus: "idle" | "pending" | "success" = "idle"; if (isWorktree) { - computedArchiveStatus = archiveStatus; - } else if (isArchivingWorkspace) { + computedArchiveStatus = worktreeArchiveStatus; + } else if (isHidingWorkspace) { computedArchiveStatus = "pending"; } @@ -534,24 +462,29 @@ function StatusWorkspaceRowWithMenu({ showShortcutBadge={showShortcutBadge} onPress={onPress} isArchiving={isArchiving} - archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"} + archiveLabel={t("sidebar.workspace.actions.archive")} archiveStatus={computedArchiveStatus} - archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."} - onArchive={isWorktree ? archiveWorktreeAfterConfirmation : hideWorkspaceAfterConfirmation} + archivePendingLabel={t("sidebar.workspace.actions.archiving")} + onArchive={handleArchive} onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined} onCopyPath={handleCopyPath} - onRename={workspace.projectKind === "git" ? handleOpenRename : undefined} + onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> + + {workspace.currentBranch && workspace.currentBranch !== workspace.name ? ( + + + + {workspace.currentBranch} + + + ) : null} {prHint || workspace.diffStat ? ( {workspace.diffStat ? ( @@ -309,6 +321,7 @@ function WorkspaceHoverCardContent({ ); } +const ThemedGitBranch = withUnistyles(GitBranch); const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedCircleCheck = withUnistyles(CircleCheck); @@ -471,6 +484,20 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[2], }, + cardBranchRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + cardBranchText: { + flex: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, separator: { height: 1, backgroundColor: theme.colors.border, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 43df97310..2f18f2a94 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -734,6 +734,8 @@ interface ComposerProps { allowEmptySubmit?: boolean; /** Optional accessibility label for the primary submit button. */ submitButtonAccessibilityLabel?: string; + /** Optional testID for the primary submit button. */ + submitButtonTestID?: string; submitIcon?: "arrow" | "return"; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; @@ -951,6 +953,7 @@ export function Composer({ hasExternalContent = false, allowEmptySubmit = false, submitButtonAccessibilityLabel, + submitButtonTestID, submitIcon = "arrow", isSubmitLoading = false, submitBehavior = "clear", @@ -1864,6 +1867,7 @@ export function Composer({ hasExternalContent={hasExternalContent} allowEmptySubmit={allowEmptySubmit} submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} + submitButtonTestID={submitButtonTestID} submitIcon={submitIcon} isSubmitDisabled={isSubmitBusy} isSubmitLoading={isSubmitBusy} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 5e74c4043..0e2e40603 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -89,6 +89,8 @@ export interface MessageInputProps { allowEmptySubmit?: boolean; /** Optional accessibility label for the primary submit button. */ submitButtonAccessibilityLabel?: string; + /** Optional testID for the primary submit button. */ + submitButtonTestID?: string; submitIcon?: "arrow" | "return"; isSubmitDisabled?: boolean; isSubmitLoading?: boolean; @@ -784,6 +786,7 @@ function SendButtonTooltip({ sendButtonCombinedStyle, isSubmitLoading, submitIcon, + submitButtonTestID, buttonIconSize, sendKeys, sendTooltipLabel, @@ -797,6 +800,7 @@ function SendButtonTooltip({ sendButtonCombinedStyle: React.ComponentProps["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; + submitButtonTestID: string | undefined; buttonIconSize: number; sendKeys: ShortcutChord | null | undefined; sendTooltipLabel: string; @@ -809,6 +813,7 @@ function SendButtonTooltip({ disabled={isSendButtonDisabled} accessibilityLabel={submitAccessibilityLabel} accessibilityRole="button" + testID={submitButtonTestID} style={sendButtonCombinedStyle} > ( hasExternalContent, allowEmptySubmit, submitButtonAccessibilityLabel, + submitButtonTestID, submitIcon, isSubmitDisabled, isSubmitLoading, @@ -1872,6 +1880,7 @@ export const MessageInput = forwardRef( sendButtonCombinedStyle={sendButtonCombinedStyle} isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} + submitButtonTestID={submitButtonTestID} buttonIconSize={buttonIconSize} sendKeys={DEFAULT_SEND_KEYS} sendTooltipLabel={sendTooltipLabel} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index f2f362762..5ebe03f85 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -42,7 +42,9 @@ import { type MessageEntry, type SessionState, type WorkspaceDescriptor, + type EmptyProjectDescriptor, normalizeWorkspaceDescriptor, + normalizeEmptyProjectDescriptor, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; @@ -473,6 +475,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); const setAgents = useSessionStore((state) => state.setAgents); const setWorkspaces = useSessionStore((state) => state.setWorkspaces); + const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects); + const addEmptyProject = useSessionStore((state) => state.addEmptyProject); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const removeWorkspace = useSessionStore((state) => state.removeWorkspace); const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity); @@ -540,6 +544,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } const workspaces = new Map(); + const emptyProjects = new Map(); let cursor: string | null = null; let includeSubscribe = options?.subscribe ?? false; @@ -561,6 +566,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider workspaces.set(workspace.id, workspace); } + // Empty project parents only ride on the first page. + for (const project of payload.emptyProjects ?? []) { + const descriptor = normalizeEmptyProjectDescriptor(project); + emptyProjects.set(descriptor.projectId, descriptor); + } + if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { break; } @@ -573,9 +584,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } setWorkspaces(serverId, workspaces); + setEmptyProjects(serverId, emptyProjects.values()); setHasHydratedWorkspaces(serverId, true); }, - [client, isConnected, serverId, setHasHydratedWorkspaces, setWorkspaces], + [client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces], ); const applyAuthoritativeAgentSnapshot = useCallback( @@ -1290,6 +1302,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); removeWorkspaceSetup({ serverId, workspaceId: message.payload.id }); removeWorkspace(serverId, message.payload.id); + if (message.payload.emptyProject) { + addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject)); + } return; } const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); @@ -1712,6 +1727,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider mergeWorkspaces, removeWorkspace, removeWorkspaceSetup, + addEmptyProject, setAgentLastActivity, setPendingPermissions, setHasHydratedAgents, diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index a3f5d513a..41a99510d 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -57,7 +57,11 @@ function GitActionMenuItem({ {needsSeparator && showSeparator ? : null} Promise; } @@ -497,7 +499,7 @@ export const useCheckoutGitActionsStore = create() }); }, - archiveWorktree: async ({ serverId, cwd, worktreePath }) => { + archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId, deleteWorktreeFromDisk }) => { await runCheckoutAction({ serverId, cwd, @@ -519,7 +521,11 @@ export const useCheckoutGitActionsStore = create() } removeWorktreeFromCachedLists({ serverId, worktreePath }); try { - const payload = await client.archivePaseoWorktree({ worktreePath }); + const payload = await client.archivePaseoWorktree({ + worktreePath, + ...(workspaceId !== undefined ? { workspaceId } : {}), + deleteWorktreeFromDisk, + }); if (payload.error) { throw new Error(payload.error.message); } diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 17ff0cc5a..a6ffaa08d 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -36,7 +36,6 @@ import { ChevronDown, Columns2, Download, - GitBranch, GitCommitHorizontal, GitMerge, ListChevronsDownUp, @@ -80,6 +79,7 @@ import { GitHubIcon } from "@/components/icons/github-icon"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { GitActionsSplitButton } from "@/git/actions-split-button"; +import { BranchSwitcher } from "@/components/branch-switcher"; import { useGitActions } from "@/git/use-actions"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useToast } from "@/contexts/toast-context"; @@ -1086,7 +1086,6 @@ interface GitDiffPaneProps { serverId: string; workspaceId?: string | null; cwd: string; - hideHeaderRow?: boolean; enabled?: boolean; } @@ -1112,7 +1111,6 @@ const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedGitMerge = withUnistyles(GitMerge); const ThemedRefreshCcw = withUnistyles(RefreshCcw); const ThemedArchive = withUnistyles(Archive); -const ThemedGitBranch = withUnistyles(GitBranch); const ThemedChevronDown = withUnistyles(ChevronDown); interface DiffLayoutToggleGroupProps { @@ -1500,6 +1498,7 @@ interface DerivedStatusState { baseRef: string | undefined; hasUncommittedChanges: boolean; actionsDisabled: boolean; + currentBranchName: string | null; } function deriveStatusState({ @@ -1517,6 +1516,8 @@ function deriveStatusState({ const baseRef = gitStatus?.baseRef ?? undefined; const hasUncommittedChanges = Boolean(gitStatus?.isDirty); const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; + const currentBranchName = + gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null; return { gitStatus, isGit, @@ -1525,6 +1526,7 @@ function deriveStatusState({ baseRef, hasUncommittedChanges, actionsDisabled, + currentBranchName, }; } @@ -1580,13 +1582,7 @@ function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean return input.paneEnabled && input.isGit; } -export function GitDiffPane({ - serverId, - workspaceId, - cwd, - hideHeaderRow, - enabled, -}: GitDiffPaneProps) { +export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPaneProps) { const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); @@ -1684,7 +1680,8 @@ export function GitDiffPane({ error: statusError, } = useCheckoutStatusQuery({ serverId, cwd }); const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError }); - const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState; + const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges, currentBranchName } = + statusState; const reviewDraftScopeKey = useMemo( () => @@ -2106,7 +2103,11 @@ export function GitDiffPane({ }), [], ); - const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons }); + const { gitActions, branchLabel, worktreeDeletePrompt } = useGitActions({ + serverId, + cwd, + icons: gitActionsIcons, + }); const committedDiffDescription = useMemo( () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], @@ -2152,15 +2153,18 @@ export function GitDiffPane({ return ( - {!hideHeaderRow ? ( + {isGit && (currentBranchName || isMobile) ? ( - - - - {branchLabel} - - - {isGit ? : null} + + {isMobile ? : null} + {worktreeDeletePrompt} ) : null} @@ -2262,19 +2266,6 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, - headerLeft: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flex: 1, - minWidth: 0, - }, - branchLabel: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - fontWeight: theme.fontWeight.medium, - flexShrink: 1, - }, diffStatusContainer: { height: WORKSPACE_SECONDARY_HEADER_HEIGHT, borderBottomWidth: 1, diff --git a/packages/app/src/git/use-actions.ts b/packages/app/src/git/use-actions.tsx similarity index 90% rename from packages/app/src/git/use-actions.ts rename to packages/app/src/git/use-actions.tsx index a182661b3..6354c93fd 100644 --- a/packages/app/src/git/use-actions.ts +++ b/packages/app/src/git/use-actions.tsx @@ -14,7 +14,7 @@ import { import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { buildHostRootRoute } from "@/utils/host-routes"; @@ -22,6 +22,7 @@ import { confirmRiskyWorktreeArchive, type WorktreeArchiveWarningLabels, } from "@/git/worktree-archive-warning"; +import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -54,6 +55,62 @@ function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; } +// The header archive only appears for Paseo-owned worktrees. When this is the +// last active workspace referencing the worktree directory, offer to remove it +// from disk; otherwise a sibling workspace still needs the directory. +function isLastWorktreeReference(workspaces: WorkspaceDescriptor[], worktreePath: string): boolean { + let references = 0; + for (const candidate of workspaces) { + if (candidate.workspaceDirectory === worktreePath) { + references += 1; + } + } + return references <= 1; +} + +// Owns the inline keep/delete prompt for the last-reference worktree case so the +// archive flow stays a single decision point and `useGitActions` keeps a flat +// shape. +function useWorktreeDeletePrompt( + runArchive: (worktreePath: string, deleteWorktreeFromDisk: boolean) => void, +): { + open: (input: { worktreePath: string; workspaceName: string }) => void; + element: ReactElement; +} { + const [state, setState] = useState<{ worktreePath: string; workspaceName: string } | null>(null); + + const resolve = useCallback( + (deleteWorktreeFromDisk: boolean) => { + const prompt = state; + setState(null); + if (prompt) { + runArchive(prompt.worktreePath, deleteWorktreeFromDisk); + } + }, + [runArchive, state], + ); + + const onKeep = useCallback(() => resolve(false), [resolve]); + const onDelete = useCallback(() => resolve(true), [resolve]); + const onCancel = useCallback(() => setState(null), []); + const open = useCallback( + (input: { worktreePath: string; workspaceName: string }) => setState(input), + [], + ); + + const element = ( + + ); + + return { open, element }; +} + type PrStatusValue = NonNullable | null; interface DeriveGitActionsStateArgs { @@ -162,6 +219,9 @@ interface UseGitActionsResult { gitActions: GitActions; branchLabel: string; isGit: boolean; + // Inline keep/delete confirmation for archiving the last reference to a + // Paseo-owned worktree. Consumers must render this so the prompt is visible. + worktreeDeletePrompt: ReactElement | null; } export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { @@ -471,6 +531,33 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]); + const runArchiveWorktreeRecord = useCallback( + (worktreePath: string, deleteWorktreeFromDisk: boolean) => { + const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces; + const workspaceList = Array.from(workspaces?.values() ?? []); + const archivedWorkspaceId = resolveWorkspaceIdByDirectory({ + workspaces: workspaceList, + workspaceDirectory: worktreePath, + }); + const redirectRoute = archivedWorkspaceId + ? buildWorkspaceArchiveRedirectRoute({ + serverId, + archivedWorkspaceId, + workspaces: workspaceList, + }) + : buildHostRootRoute(serverId); + router.replace(redirectRoute as Href); + void runArchiveWorktree({ serverId, cwd, worktreePath, deleteWorktreeFromDisk }).catch( + (err) => { + toastActionError(err, t("workspace.git.actions.toasts.failedArchive")); + }, + ); + }, + [cwd, runArchiveWorktree, serverId, t, toastActionError], + ); + + const worktreeDeletePrompt = useWorktreeDeletePrompt(runArchiveWorktreeRecord); + const archiveWorktreeAfterConfirmation = useCallback(async () => { const worktreePath = status?.cwd; if (!worktreePath) { @@ -496,32 +583,21 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use return; } - const archivedWorkspaceId = resolveWorkspaceIdByDirectory({ - workspaces: workspaceList, - workspaceDirectory: worktreePath, - }); - const redirectRoute = archivedWorkspaceId - ? buildWorkspaceArchiveRedirectRoute({ - serverId, - archivedWorkspaceId, - workspaces: workspaceList, - }) - : buildHostRootRoute(serverId); - router.replace(redirectRoute as Href); - void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => { - toastActionError(err, t("workspace.git.actions.toasts.failedArchive")); - }); + if (isLastWorktreeReference(workspaceList, worktreePath)) { + worktreeDeletePrompt.open({ worktreePath, workspaceName: workspace?.name ?? branchLabel }); + return; + } + runArchiveWorktreeRecord(worktreePath, false); }, [ branchLabel, - cwd, gitStatus?.aheadOfOrigin, gitStatus?.isDirty, - runArchiveWorktree, + runArchiveWorktreeRecord, serverId, status?.cwd, t, toast, - toastActionError, + worktreeDeletePrompt, ]); const handleArchiveWorktree = useCallback(() => { @@ -732,7 +808,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use baseRef, ]); - return { gitActions, branchLabel, isGit }; + return { gitActions, branchLabel, isGit, worktreeDeletePrompt: worktreeDeletePrompt.element }; } function translateGitActions( diff --git a/packages/app/src/git/workspace-actions.tsx b/packages/app/src/git/workspace-actions.tsx index f6b62d3cb..7c142552f 100644 --- a/packages/app/src/git/workspace-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -48,11 +48,20 @@ const ICONS = { }; export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) { - const { gitActions, isGit } = useGitActions({ serverId, cwd, icons: ICONS }); + const { gitActions, isGit, worktreeDeletePrompt } = useGitActions({ + serverId, + cwd, + icons: ICONS, + }); if (!isGit) { return null; } - return ; + return ( + <> + + {worktreeDeletePrompt} + + ); } diff --git a/packages/app/src/hooks/sidebar-status-view-model.test.ts b/packages/app/src/hooks/sidebar-status-view-model.test.ts index d3400e260..223adce76 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.test.ts @@ -20,6 +20,8 @@ function ws( projectKind: input.projectKind ?? "git", workspaceKind: input.workspaceKind ?? "worktree", name: input.name ?? "main", + title: input.title ?? null, + currentBranch: input.currentBranch ?? null, statusBucket: input.statusBucket ?? "done", statusEnteredAt: input.statusEnteredAt ?? null, archivingAt: null, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 320eb2729..980bd5d55 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -26,6 +26,11 @@ export interface SidebarWorkspaceEntry { projectKind: WorkspaceDescriptor["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; + // Raw user-set title (null when the name is derived from branch/directory). + // Prefills the rename input and signals whether a reset is available. + title: string | null; + // Checkout branch (null when not a git checkout or detached HEAD). + currentBranch: string | null; statusBucket: SidebarStateBucket; statusEnteredAt: Date | null; archivingAt: string | null; @@ -61,6 +66,8 @@ function createStructuralWorkspaceEntry(input: { projectKind: input.project.projectKind, workspaceKind: "checkout", name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId, + title: null, + currentBranch: null, statusBucket: "done", statusEnteredAt: null, archivingAt: null, diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index e6bc3de44..1aebdfb52 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -48,6 +48,7 @@ function createRuntime(hosts: HostFixture[]): RuntimeAdapter { return { requestId: `req-${host.serverId}`, entries: workspaces, + emptyProjects: [], pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, } satisfies FetchWorkspacesResult; }; diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.ts b/packages/app/src/hooks/use-sidebar-shortcut-model.ts index 80cfe7a4b..2d2bc617d 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.ts +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.ts @@ -1,7 +1,6 @@ import { useEffect, useMemo } from "react"; import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; -import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; export function useSidebarShortcutModel(input: { @@ -33,13 +32,9 @@ export function useSidebarShortcutModel(input: { return; } - const collapsibleProjectKeys = new Set( - projects - .filter((project) => !isSidebarProjectFlattened(project)) - .map((project) => project.projectKey), - ); + const projectKeys = new Set(projects.map((project) => project.projectKey)); for (const key of collapsedProjectKeys) { - if (!collapsibleProjectKeys.has(key)) { + if (!projectKeys.has(key)) { setProjectCollapsed(key, false); } } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index ae04c7fa9..e7f28ac87 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -34,6 +34,14 @@ export { type SidebarWorkspaceEntry, } from "./sidebar-workspaces-view-model"; +function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null { + if (!currentBranch) { + return null; + } + const trimmed = currentBranch.trim(); + return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed; +} + export function createSidebarWorkspaceEntry(input: { serverId: string; workspace: WorkspaceDescriptor; @@ -51,6 +59,8 @@ export function createSidebarWorkspaceEntry(input: { projectKind: input.workspace.projectKind, workspaceKind: input.workspace.workspaceKind, name: input.workspace.name, + title: input.workspace.title ?? null, + currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch), statusBucket: effectiveStatus.status, statusEnteredAt: effectiveStatus.enteredAt, archivingAt: input.workspace.archivingAt, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 1fc037945..ac845a889 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -290,7 +290,7 @@ describe("translation resources", () => { }); it("includes sessions and agent list keys for the Batch 4H migration", () => { - expect(en.sessions.title).toBe("Sessions"); + expect(en.sessions.title).toBe("Agent history"); expect(en.sessions.empty).toBe("No sessions yet"); expect(en.sessions.actions.loadMore).toBe("Load more"); expect(en.agentList.fallbackTitle).toBe("New session"); @@ -353,7 +353,7 @@ describe("translation resources", () => { expect(en.sidebar.actions.home).toBe("Home"); expect(en.sidebar.actions.settings).toBe("Settings"); expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar"); - expect(en.sidebar.sections.sessions).toBe("Sessions"); + expect(en.sidebar.sections.sessions).toBe("History"); expect(en.sidebar.workspace.actions.newWorkspace).toBe("New workspace"); expect(en.sidebar.workspace.actions.createWorkspaceFor).toBe( "Create a new workspace for {{projectName}}", diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 28ff92513..00cdf4c06 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -206,7 +206,7 @@ export const ar: TranslationResources = { }, }, sessions: { - title: "الجلسات", + title: "سجل الوكلاء", empty: "لا توجد جلسات بعد", actions: { loadMore: "تحميل المزيد", @@ -746,12 +746,13 @@ export const ar: TranslationResources = { }, actions: { addProject: "إضافة مشروع", + newWorkspace: "مساحة عمل جديدة", home: "بيت", settings: "إعدادات", closeSidebar: "إغلاق الشريط الجانبي", }, sections: { - sessions: "الجلسات", + sessions: "السجل", }, worktreeSetup: { title: "إعداد البرامج النصية لشجرة العمل", @@ -809,6 +810,12 @@ export const ar: TranslationResources = { hideConfirm: "يخفي", cancel: "يلغي", }, + deleteWorktreePrompt: { + title: "أرشفة مساحة العمل", + message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟", + keep: "الاحتفاظ على القرص", + delete: "حذف", + }, rename: { title: "إعادة تسمية مساحة العمل", submit: "إعادة تسمية", @@ -827,6 +834,17 @@ export const ar: TranslationResources = { newWorkspace: { title: "مساحة عمل جديدة", create: "يخلق", + backing: { + local: "محلي", + worktree: "شجرة عمل جديدة", + label: "العزل", + }, + fields: { + project: "المشروع", + base: "الأساس", + baseNotApplicable: "غير قابل للتطبيق", + }, + titlePlaceholder: "العنوان (اختياري)", errors: { hostDisconnected: "Host غير متصل", createWorktreeFailed: "فشل في إنشاء شجرة العمل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3962b4be5..a2fdd1aaf 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -205,7 +205,7 @@ export const en = { }, }, sessions: { - title: "Sessions", + title: "Agent history", empty: "No sessions yet", actions: { loadMore: "Load more", @@ -752,12 +752,13 @@ export const en = { }, actions: { addProject: "Add project", + newWorkspace: "New workspace", home: "Home", settings: "Settings", closeSidebar: "Close sidebar", }, sections: { - sessions: "Sessions", + sessions: "History", }, worktreeSetup: { title: "Set up worktree scripts", @@ -815,6 +816,12 @@ export const en = { hideConfirm: "Hide", cancel: "Cancel", }, + deleteWorktreePrompt: { + title: "Archive workspace", + message: "Also remove the worktree from disk?", + keep: "Keep on disk", + delete: "Delete", + }, rename: { title: "Rename workspace", submit: "Rename", @@ -833,6 +840,17 @@ export const en = { newWorkspace: { title: "New workspace", create: "Create", + backing: { + local: "Local", + worktree: "New worktree", + label: "Isolation", + }, + fields: { + project: "Project", + base: "Base", + baseNotApplicable: "Not applicable", + }, + titlePlaceholder: "Title (optional)", errors: { hostDisconnected: "Host is not connected", createWorktreeFailed: "Failed to create worktree", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b2e0d19cf..fd9f69447 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -209,7 +209,7 @@ export const es: TranslationResources = { }, }, sessions: { - title: "Sesiones", + title: "Historial de agentes", empty: "Aún no hay sesiones", actions: { loadMore: "Cargar más", @@ -772,12 +772,13 @@ export const es: TranslationResources = { }, actions: { addProject: "Agregar proyecto", + newWorkspace: "Nuevo espacio de trabajo", home: "Hogar", settings: "Ajustes", closeSidebar: "Cerrar barra lateral", }, sections: { - sessions: "Sesiones", + sessions: "Historial", }, worktreeSetup: { title: "Configurar secuencias de comandos del árbol de trabajo", @@ -835,6 +836,12 @@ export const es: TranslationResources = { hideConfirm: "Esconder", cancel: "Cancelar", }, + deleteWorktreePrompt: { + title: "Archivar espacio de trabajo", + message: "¿También eliminar el worktree del disco?", + keep: "Conservar en disco", + delete: "Eliminar", + }, rename: { title: "Cambiar nombre del espacio de trabajo", submit: "Rebautizar", @@ -853,6 +860,17 @@ export const es: TranslationResources = { newWorkspace: { title: "Nuevo espacio de trabajo", create: "Crear", + backing: { + local: "Local", + worktree: "Nuevo worktree", + label: "Aislamiento", + }, + fields: { + project: "Proyecto", + base: "Base", + baseNotApplicable: "No aplicable", + }, + titlePlaceholder: "Título (opcional)", errors: { hostDisconnected: "Hostno está conectado", createWorktreeFailed: "No se pudo crear el árbol de trabajo", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 955ec8b41..a885785aa 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -209,7 +209,7 @@ export const fr: TranslationResources = { }, }, sessions: { - title: "Séances", + title: "Historique des agents", empty: "Aucune séance pour l'instant", actions: { loadMore: "Charger plus", @@ -771,12 +771,13 @@ export const fr: TranslationResources = { }, actions: { addProject: "Ajouter un projet", + newWorkspace: "Nouvel espace de travail", home: "Maison", settings: "Paramètres", closeSidebar: "Fermer la barre latérale", }, sections: { - sessions: "Séances", + sessions: "Historique", }, worktreeSetup: { title: "Configurer les scripts d'arbre de travail", @@ -834,6 +835,12 @@ export const fr: TranslationResources = { hideConfirm: "Cacher", cancel: "Annuler", }, + deleteWorktreePrompt: { + title: "Archiver l'espace de travail", + message: "Supprimer aussi le worktree du disque?", + keep: "Conserver sur le disque", + delete: "Supprimer", + }, rename: { title: "Renommer l'espace de travail", submit: "Rebaptiser", @@ -852,6 +859,17 @@ export const fr: TranslationResources = { newWorkspace: { title: "Nouvel espace de travail", create: "Créer", + backing: { + local: "Local", + worktree: "Nouveau worktree", + label: "Isolation", + }, + fields: { + project: "Projet", + base: "Base", + baseNotApplicable: "Non applicable", + }, + titlePlaceholder: "Titre (facultatif)", errors: { hostDisconnected: "Hostn'est pas connecté", createWorktreeFailed: "Échec de la création de l'arbre de travail", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 443c57d80..d27301ba4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -208,7 +208,7 @@ export const ru: TranslationResources = { }, }, sessions: { - title: "Сессии", + title: "История агентов", empty: "Сеансов пока нет", actions: { loadMore: "Загрузить больше", @@ -764,12 +764,13 @@ export const ru: TranslationResources = { }, actions: { addProject: "Добавить проект", + newWorkspace: "Новое рабочее пространство", home: "Дом", settings: "Настройки", closeSidebar: "Закрыть боковую панель", }, sections: { - sessions: "Сессии", + sessions: "История", }, worktreeSetup: { title: "Настройка сценариев рабочего дерева", @@ -827,6 +828,12 @@ export const ru: TranslationResources = { hideConfirm: "Скрывать", cancel: "Отмена", }, + deleteWorktreePrompt: { + title: "Архивировать рабочее пространство", + message: "Также удалить рабочее дерево с диска?", + keep: "Оставить на диске", + delete: "Удалить", + }, rename: { title: "Переименовать рабочую область", submit: "Переименовать", @@ -845,6 +852,17 @@ export const ru: TranslationResources = { newWorkspace: { title: "Новое рабочее пространство", create: "Создавать", + backing: { + local: "Локально", + worktree: "Новый worktree", + label: "Изоляция", + }, + fields: { + project: "Проект", + base: "База", + baseNotApplicable: "Неприменимо", + }, + titlePlaceholder: "Название (необязательно)", errors: { hostDisconnected: "Host не подключен", createWorktreeFailed: "Не удалось создать рабочее дерево.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c655b915c..71846cb42 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -206,7 +206,7 @@ export const zhCN: TranslationResources = { }, }, sessions: { - title: "会话", + title: "Agent 历史", empty: "还没有会话", actions: { loadMore: "加载更多", @@ -740,12 +740,13 @@ export const zhCN: TranslationResources = { }, actions: { addProject: "添加 project", + newWorkspace: "新建工作区", home: "首页", settings: "设置", closeSidebar: "关闭侧边栏", }, sections: { - sessions: "会话", + sessions: "历史", }, worktreeSetup: { title: "设置 worktree scripts", @@ -800,6 +801,12 @@ export const zhCN: TranslationResources = { hideConfirm: "隐藏", cancel: "取消", }, + deleteWorktreePrompt: { + title: "归档 workspace", + message: "同时从磁盘删除 worktree?", + keep: "保留在磁盘上", + delete: "删除", + }, rename: { title: "重命名 workspace", submit: "重命名", @@ -818,6 +825,17 @@ export const zhCN: TranslationResources = { newWorkspace: { title: "新建 workspace", create: "创建", + backing: { + local: "本地", + worktree: "新建 worktree", + label: "隔离", + }, + fields: { + project: "项目", + base: "基线", + baseNotApplicable: "不适用", + }, + titlePlaceholder: "标题(可选)", errors: { hostDisconnected: "Host 未连接", createWorktreeFailed: "创建 worktree 失败", diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 2084e3afa..1fc430fd5 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -9,6 +9,7 @@ import { TerminalPane } from "@/components/terminal-pane"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { queryClient } from "@/query/query-client"; +import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks"; @@ -41,13 +42,19 @@ function useTerminalPanelDescriptor( const workspaceDirectory = useWorkspaceDirectory(context.serverId, context.workspaceId); const terminalsQuery = useQuery( { - queryKey: ["terminals", context.serverId, workspaceDirectory] as const, + queryKey: buildTerminalsQueryKey( + context.serverId, + workspaceDirectory, + context.workspaceId || null, + ), enabled: Boolean(client && workspaceDirectory), queryFn: async (): Promise => { if (!client || !workspaceDirectory) { throw new Error("Workspace directory not found"); } - return client.listTerminals(workspaceDirectory); + return client.listTerminals(workspaceDirectory, undefined, { + workspaceId: context.workspaceId || undefined, + }); }, staleTime: 5_000, }, diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index eb0232293..dc63b78b1 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -1,4 +1,4 @@ -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; export interface WorkspaceStructureProject { @@ -45,9 +45,11 @@ function compareWorkspaceStructureProjects( export function buildWorkspaceStructureProjects(input: { serverId: string; workspaces: Iterable; + emptyProjects?: Iterable; }): WorkspaceStructureProject[] { const workspaceList = Array.from(input.workspaces); - if (workspaceList.length === 0) { + const emptyProjectList = Array.from(input.emptyProjects ?? []); + if (workspaceList.length === 0 && emptyProjectList.length === 0) { return EMPTY_WORKSPACE_STRUCTURE.projects; } @@ -58,6 +60,18 @@ export function buildWorkspaceStructureProjects(input: { } >(); + for (const emptyProject of emptyProjectList) { + byProject.set(emptyProject.projectId, { + projectKey: emptyProject.projectId, + projectName: + emptyProject.projectDisplayName || projectDisplayNameFromProjectId(emptyProject.projectId), + projectKind: emptyProject.projectKind, + iconWorkingDir: emptyProject.projectRootPath, + workspaceKeys: [], + workspaces: [], + }); + } + for (const workspace of workspaceList) { const project = byProject.get(workspace.projectId) ?? diff --git a/packages/app/src/screens/new-workspace-empty.test.ts b/packages/app/src/screens/new-workspace-empty.test.ts index 8065464bb..28c5ddbe6 100644 --- a/packages/app/src/screens/new-workspace-empty.test.ts +++ b/packages/app/src/screens/new-workspace-empty.test.ts @@ -37,6 +37,7 @@ describe("runCreateEmptyWorkspace", () => { cwd: "/sample/repo", prompt: "", attachments: [], + withInitialAgent: false, }); expect(recorded).toEqual([{ serverId: "server-abc", workspaceId: "workspace-123" }]); }); diff --git a/packages/app/src/screens/new-workspace-empty.ts b/packages/app/src/screens/new-workspace-empty.ts index 40fdcdf27..1c1566b6d 100644 --- a/packages/app/src/screens/new-workspace-empty.ts +++ b/packages/app/src/screens/new-workspace-empty.ts @@ -12,6 +12,7 @@ export interface CreateEmptyWorkspaceInput { cwd: string; prompt: string; attachments: AgentAttachment[]; + withInitialAgent: boolean; }) => Promise>; serverId: string; navigate: (serverId: string, workspaceId: string) => void; @@ -23,6 +24,7 @@ export async function runCreateEmptyWorkspace(input: CreateEmptyWorkspaceInput): cwd: payload.cwd, prompt: "", attachments: [], + withInitialAgent: false, }); navigate(serverId, ensuredWorkspace.id); } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 0aa31073a..5fab3dede 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { Pressable, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; @@ -101,6 +102,10 @@ interface NewWorkspaceProjectPickerInput { sourceDirectory?: string; projectId?: string; displayName?: string; + // When true (workspaceMultiplicity), every project is selectable because a + // local-backed workspace works for any directory, git or not. When false the + // picker stays limited to worktree-capable projects (legacy behavior). + allowAllProjects: boolean; } interface NewWorkspaceProjectPickerState { @@ -118,6 +123,10 @@ interface NewWorkspaceProjectPickerState { const BRANCH_OPTION_PREFIX = "branch:"; const PR_OPTION_PREFIX = "github-pr:"; const PROJECT_OPTION_PREFIX = "project:"; +const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; +// Height of a single picker-trigger badge. The Base-row spacer reserves exactly +// this so toggling Isolation to Local hides the row without shifting the form. +const BADGE_HEIGHT = 28; function RefPickerBadgeContent({ selectedItem, @@ -238,9 +247,9 @@ function ProjectPickerTrigger({ iconDataUri={iconDataUri} initial={placeholderInitial} projectKey={projectKey} - imageStyle={styles.badgeProjectIcon} - fallbackStyle={styles.badgeProjectIconFallback} - textStyle={styles.badgeProjectIconFallbackText} + imageStyle={styles.projectIcon} + fallbackStyle={styles.projectIconFallback} + textStyle={styles.projectIconFallbackText} /> ) : ( @@ -352,6 +361,50 @@ function PickerOptionItem({ ); } +function BackingOptionItem({ + optionId, + label, + selected, + active, + disabled, + onPress, + iconColor, + iconSize, +}: { + optionId: string; + label: string; + selected: boolean; + active: boolean; + disabled: boolean; + onPress: () => void; + iconColor: string; + iconSize: number; +}) { + const leadingSlot = useMemo( + () => ( + + {optionId === "worktree" ? ( + + ) : ( + + )} + + ), + [optionId, iconSize, iconColor], + ); + return ( + + ); +} + function ProjectOptionItem({ testID, projectKey, @@ -382,9 +435,9 @@ function ProjectOptionItem({ iconDataUri={iconDataUri} initial={placeholderInitial} projectKey={projectKey} - imageStyle={styles.projectOptionIcon} - fallbackStyle={styles.projectOptionIconFallback} - textStyle={styles.projectOptionIconFallbackText} + imageStyle={styles.projectIcon} + fallbackStyle={styles.projectIconFallback} + textStyle={styles.projectIconFallbackText} /> ), @@ -477,6 +530,7 @@ function useNewWorkspaceProjectPicker({ sourceDirectory, projectId, displayName: displayNameProp, + allowAllProjects, }: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState { const [manualProjectKey, setManualProjectKey] = useState(null); const displayName = displayNameProp?.trim() ?? ""; @@ -509,11 +563,21 @@ function useNewWorkspaceProjectPicker({ }), [lastActiveProject, projects, routeProject], ); - const worktreeProjects = useMemo( - () => projects.filter((project) => project.canCreateWorktree), - [projects], + const selectableProjects = useMemo( + () => (allowAllProjects ? projects : projects.filter((project) => project.canCreateWorktree)), + [allowAllProjects, projects], ); + // expo-router reuses the 'new' screen across navigations without remounting, so + // a manual picker choice would otherwise stick when navigating to a different + // project's New Workspace. Resetting on route project identity lets each + // route-driven navigation preselect its own project; in-screen manual override + // still works within a single visit. + const routeProjectKey = routeProject?.projectKey ?? null; + useEffect(() => { + setManualProjectKey(null); + }, [routeProjectKey]); + const selectedProjectKey = manualProjectKey ?? initialProject?.projectKey ?? null; const selectedProject = useMemo( @@ -527,16 +591,17 @@ function useNewWorkspaceProjectPicker({ [lastActiveProject, projects, routeProject, selectedProjectKey], ); const { options: projectPickerOptions, projectByOptionId }: ProjectOptionData = useMemo( - () => computeProjectOptionData(worktreeProjects), - [worktreeProjects], + () => computeProjectOptionData(selectableProjects), + [selectableProjects], ); const handleSelectProjectOption = useCallback( (id: string) => { const project = projectByOptionId.get(id); - if (!project?.canCreateWorktree) return; + if (!project) return; + if (!allowAllProjects && !project.canCreateWorktree) return; setManualProjectKey(project.projectKey); }, - [projectByOptionId], + [allowAllProjects, projectByOptionId], ); return { @@ -552,6 +617,103 @@ function useNewWorkspaceProjectPicker({ }; } +function IsolationPickerTrigger({ + pickerAnchorRef, + onPress, + disabled, + badgePressableStyle, + backing, + label, + iconColor, + iconSize, +}: { + pickerAnchorRef: React.RefObject; + onPress: () => void; + disabled: boolean; + badgePressableStyle: React.ComponentProps["style"]; + backing: "local" | "worktree"; + label: string; + iconColor: string; + iconSize: number; +}) { + return ( + + + {backing === "worktree" ? ( + + ) : ( + + )} + + + {label} + + + + ); +} + +// Each row aligns its label glyph with the screen heading glyph and lets the +// label's natural width push the control. The label and control sit side by +// side with no held horizontal space. +function LabeledRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {label} + {children} + + ); +} + +interface WorkspaceBackingState { + backing: "local" | "worktree"; + setBacking: (value: "local" | "worktree") => void; + effectiveBacking: "local" | "worktree"; + canCreateWorktree: boolean; + showRefPicker: boolean; +} + +// Worktree backing only makes sense for a git checkout. The effective backing +// falls back to local whenever the selected directory isn't git so the flow +// never submits an impossible request. +function useWorkspaceBacking(input: { + supportsMultiplicity: boolean; + selectedIsGit: boolean; +}): WorkspaceBackingState { + const { supportsMultiplicity, selectedIsGit } = input; + const [backing, setBacking] = useState<"local" | "worktree">("local"); + const canCreateWorktree = supportsMultiplicity && selectedIsGit; + const isWorktree = backing === "worktree" && canCreateWorktree; + + useEffect(() => { + if (backing === "worktree" && !canCreateWorktree) { + setBacking("local"); + } + }, [backing, canCreateWorktree]); + + return { + backing, + setBacking, + effectiveBacking: isWorktree ? "worktree" : "local", + canCreateWorktree, + showRefPicker: !supportsMultiplicity || isWorktree, + }; +} + +function backingLabel(t: TFunction, backing: "local" | "worktree"): string { + return backing === "worktree" + ? t("newWorkspace.backing.worktree") + : t("newWorkspace.backing.local"); +} + function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { if (input.isCompact) { return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; @@ -610,6 +772,42 @@ async function createAndMergeWorkspace(input: { return normalizedWorkspace; } +async function createMultiplicityWorkspace(input: { + client: NonNullable>; + backing: "local" | "worktree"; + project: HostProjectListItem; + selectedItem: PickerItem | null; + currentBranch: string | null; + withInitialAgent: boolean; + mergeWorkspaces: ( + serverId: string, + workspaces: ReturnType[], + ) => void; + serverId: string; + createFailedMessage: string; +}): Promise> { + const isWorktree = input.backing === "worktree"; + const baseBranch = isWorktree + ? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined) + : undefined; + const payload = await input.client.createWorkspace({ + backing: input.backing, + cwd: input.project.iconWorkingDir, + projectId: input.project.projectKey, + ...(isWorktree ? { branch: createNameId() } : {}), + ...(baseBranch ? { baseBranch } : {}), + }); + if (payload.error || !payload.workspace) { + throw new Error(payload.error ?? input.createFailedMessage); + } + const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); + const workspaceForInitialMerge = input.withInitialAgent + ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } + : normalizedWorkspace; + input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); + return normalizedWorkspace; +} + interface CreateChatAgentInput { payload: MessagePayload; composerState: ReturnType["composerState"]; @@ -617,6 +815,7 @@ interface CreateChatAgentInput { cwd: string; prompt: string; attachments: AgentAttachment[]; + withInitialAgent: boolean; }) => Promise>; serverId: string; draftKey: string; @@ -641,6 +840,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { cwd, prompt: text, attachments: reviewAttachments, + withInitialAgent: true, }); submitWorkspaceDraft({ serverId, @@ -776,6 +976,10 @@ export function NewWorkspaceScreen({ const isCompact = useIsCompactFormFactor(); const toast = useToast(); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 + const supportsWorkspaceMultiplicity = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.workspaceMultiplicity === true, + ); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); + const [backingPickerOpen, setBackingPickerOpen] = useState(false); const [pickerSearchQuery, setPickerSearchQuery] = useState(""); const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); const pickerAnchorRef = useRef(null); const projectPickerAnchorRef = useRef(null); + const backingPickerAnchorRef = useRef(null); useEffect(() => { const trimmed = pickerSearchQuery.trim(); @@ -813,6 +1019,7 @@ export function NewWorkspaceScreen({ sourceDirectory: sourceDirectoryProp, projectId, displayName: displayNameProp, + allowAllProjects: supportsWorkspaceMultiplicity, }); const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ serverId, projects }); const draftKey = `new-workspace:${serverId}:${selectedSourceDirectory ?? "choose-project"}`; @@ -859,6 +1066,10 @@ export function NewWorkspaceScreen({ }); const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; + const { effectiveBacking, setBacking, canCreateWorktree, showRefPicker } = useWorkspaceBacking({ + supportsMultiplicity: supportsWorkspaceMultiplicity, + selectedIsGit: checkoutStatusQuery.data?.isGit === true, + }); const branchSuggestionsQuery = useQuery({ queryKey: ["branch-suggestions", serverId, selectedSourceDirectory, debouncedPickerSearchQuery], @@ -942,13 +1153,14 @@ export function NewWorkspaceScreen({ const handleSelectProjectOption = useCallback( (id: string) => { - const project = projectByOptionId.get(id); - if (!project?.canCreateWorktree) return; + // selectProjectOption enforces selectability (worktree-only when + // multiplicity is off, any project when it's on); don't re-gate here on + // canCreateWorktree or non-git projects become unselectable. selectProjectOption(id); setProjectPickerOpen(false); setManualPickerSelection(null); }, - [projectByOptionId, selectProjectOption], + [selectProjectOption], ); const checkoutHintPrAttachment = useMemo( @@ -985,6 +1197,58 @@ export function NewWorkspaceScreen({ setProjectPickerOpen(true); }, []); + const openBackingPicker = useCallback(() => { + setBackingPickerOpen(true); + }, []); + + const handleBackingPickerOpenChange = useCallback((nextOpen: boolean) => { + setBackingPickerOpen(nextOpen); + }, []); + + // "New worktree" is omitted entirely (not disabled) when the project isn't a + // git checkout, since worktree backing is impossible there. + const backingOptions = useMemo(() => { + const localOption = { id: "local", label: backingLabel(t, "local") }; + if (!canCreateWorktree) return [localOption]; + return [localOption, { id: "worktree", label: backingLabel(t, "worktree") }]; + }, [canCreateWorktree, t]); + + const handleSelectBackingOption = useCallback( + (id: string) => { + setBacking(id === "worktree" ? "worktree" : "local"); + setBackingPickerOpen(false); + }, + [setBacking], + ); + + const renderBackingOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOptionType; + selected: boolean; + active: boolean; + onPress: () => void; + }) => { + return ( + + ); + }, + [isPending, theme.colors.foregroundMuted, theme.iconSize.sm], + ); + const handleClearDraft = useCallback(() => { // No-op: screen navigates away on success, text should stay for retry on error }, []); @@ -1042,21 +1306,53 @@ export function NewWorkspaceScreen({ ); const ensureWorkspace = useCallback( - async (input: { cwd: string; prompt: string; attachments: AgentAttachment[] }) => { + async (input: { + cwd: string; + prompt: string; + attachments: AgentAttachment[]; + withInitialAgent: boolean; + }) => { if (createdWorkspace) { return createdWorkspace; } - const normalizedWorkspace = await createAndMergeWorkspace({ - client: withConnectedClient(), - createInput: buildCreateWorktreeInput(input), - mergeWorkspaces, - serverId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }); + if (!selectedProject) { + throw new Error("Choose a project"); + } + const normalizedWorkspace = supportsWorkspaceMultiplicity + ? await createMultiplicityWorkspace({ + client: withConnectedClient(), + backing: effectiveBacking, + project: selectedProject, + selectedItem, + currentBranch, + withInitialAgent: input.withInitialAgent, + mergeWorkspaces, + serverId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), + }) + : await createAndMergeWorkspace({ + client: withConnectedClient(), + createInput: buildCreateWorktreeInput(input), + mergeWorkspaces, + serverId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), + }); setCreatedWorkspace(normalizedWorkspace); return normalizedWorkspace; }, - [buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, t, withConnectedClient], + [ + buildCreateWorktreeInput, + createdWorkspace, + currentBranch, + effectiveBacking, + mergeWorkspaces, + selectedItem, + selectedProject, + serverId, + supportsWorkspaceMultiplicity, + t, + withConnectedClient, + ], ); const handleSubmitNewWorkspace = useCallback( @@ -1173,12 +1469,12 @@ export function NewWorkspaceScreen({ description={project.iconWorkingDir} selected={selected} active={active} - disabled={isPending || !project.canCreateWorktree} + disabled={isPending || (!supportsWorkspaceMultiplicity && !project.canCreateWorktree)} onPress={onPress} /> ); }, - [isPending, projectByOptionId, projectIconDataByProjectKey], + [isPending, projectByOptionId, projectIconDataByProjectKey, supportsWorkspaceMultiplicity], ); const contentStyle = useMemo( @@ -1211,69 +1507,161 @@ export function NewWorkspaceScreen({ ? t("newWorkspace.refPicker.searching") : t("newWorkspace.refPicker.noMatchingRefs"); + const backingTriggerLabel = backingLabel(t, effectiveBacking); + + const formStack = useMemo( + () => ( + + + + + + + + {/* The Isolation row keeps its height for non-git projects so switching + projects never shifts the form; worktree backing is git-only, so a + non-git project renders an invisible spacer matching the trigger + height exactly. */} + {canCreateWorktree ? ( + + + + + + + ) : ( + + )} + {/* The Base row keeps its height so toggling Isolation never shifts the + form; on Local backing it renders an invisible spacer with no label + or control, matching the trigger height exactly. */} + {showRefPicker ? ( + + + + + + + ) : ( + + )} + + ), + [ + backingOptions, + backingPickerOpen, + backingTriggerLabel, + badgePressableStyle, + canCreateWorktree, + effectiveBacking, + handleBackingPickerOpenChange, + handlePickerOpenChange, + handleProjectPickerOpenChange, + handleSelectBackingOption, + handleSelectOption, + handleSelectProjectOption, + isPending, + openBackingPicker, + openPicker, + openProjectPicker, + options, + pickerEmptyText, + pickerOpen, + projectPickerOpen, + projectPickerOptions, + projectTriggerLabel, + projectIconDataByProjectKey, + renderBackingOption, + renderPickerOption, + renderProjectOption, + selectedItem, + selectedOptionId, + selectedProject, + selectedProjectOptionId, + selectedSourceDirectory, + setPickerSearchQuery, + showRefPicker, + t, + theme.colors.foregroundMuted, + theme.iconSize.sm, + triggerLabel, + ], + ); + const composerFooter = useMemo( () => ( - - - - - - - - - + <> {agentControlsWithDisabled ? ( ) : null} @@ -1294,40 +1682,16 @@ export function NewWorkspaceScreen({ iconSize={theme.iconSize.sm} /> ) : null} - + ), [ acceptCheckoutHint, - badgePressableStyle, + agentControlsWithDisabled, checkoutHintPrAttachment, dismissCheckoutHint, - handlePickerOpenChange, - handleProjectPickerOpenChange, - handleSelectOption, - handleSelectProjectOption, - isPending, - openPicker, - openProjectPicker, - options, - pickerEmptyText, - pickerOpen, - projectPickerOpen, - projectPickerOptions, - projectTriggerLabel, - projectIconDataByProjectKey, - renderPickerOption, - renderProjectOption, - selectedItem, - selectedOptionId, - selectedProject, - selectedProjectOptionId, - selectedSourceDirectory, - setPickerSearchQuery, - agentControlsWithDisabled, t, theme.colors.foregroundMuted, theme.iconSize.sm, - triggerLabel, ], ); const screenHeaderLeft = useMemo(() => , []); @@ -1342,6 +1706,7 @@ export function NewWorkspaceScreen({ {t("newWorkspace.title")} + {formStack} ({ color: theme.colors.destructive, lineHeight: 20, }, - optionsRow: { + formStack: { + marginBottom: theme.spacing[8], + gap: theme.spacing[2], + }, + // The row's left inset matches the heading's text x (composerTitleContainer + // paddingLeft) so each label glyph aligns with the "New workspace" glyph. The + // label keeps its natural width and the control sits immediately beside it. + row: { flexDirection: "row", alignItems: "center", - flexWrap: "wrap", - gap: theme.spacing[2], + paddingLeft: theme.spacing[6], + gap: theme.spacing[1], + }, + rowLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + baseSpacer: { + height: BADGE_HEIGHT, }, badge: { flexDirection: "row", alignItems: "center", - height: 28, + height: BADGE_HEIGHT, maxWidth: 240, paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius["2xl"], @@ -1428,7 +1808,7 @@ const styles = StyleSheet.create((theme) => ({ checkoutHintBadge: { flexDirection: "row", alignItems: "center", - height: 28, + height: BADGE_HEIGHT, maxWidth: 240, paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius["2xl"], @@ -1467,20 +1847,22 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", flexShrink: 0, }, - badgeProjectIcon: { + projectIcon: { width: theme.iconSize.md, height: theme.iconSize.md, borderRadius: theme.borderRadius.sm, }, - badgeProjectIconFallback: { + projectIconFallback: { width: theme.iconSize.md, height: theme.iconSize.md, borderRadius: theme.borderRadius.sm, alignItems: "center", justifyContent: "center", }, - badgeProjectIconFallbackText: { - fontSize: 10, + projectIconFallbackText: { + // Single uppercase initial inside an iconSize.md (16px) square — below the + // smallest font-size token, so it stays a literal sized to the box. + fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE, fontWeight: "600", }, rowIconBox: { @@ -1489,20 +1871,4 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, - projectOptionIcon: { - width: theme.iconSize.md, - height: theme.iconSize.md, - borderRadius: theme.borderRadius.sm, - }, - projectOptionIconFallback: { - width: theme.iconSize.md, - height: theme.iconSize.md, - borderRadius: theme.borderRadius.sm, - alignItems: "center", - justifyContent: "center", - }, - projectOptionIconFallbackText: { - fontSize: 10, - fontWeight: "600", - }, })); diff --git a/packages/app/src/screens/workspace/terminals/state.ts b/packages/app/src/screens/workspace/terminals/state.ts index 551867d65..b129c9a1d 100644 --- a/packages/app/src/screens/workspace/terminals/state.ts +++ b/packages/app/src/screens/workspace/terminals/state.ts @@ -7,8 +7,12 @@ export type ListTerminalsPayload = ListTerminalsResponse["payload"]; type TerminalEntry = ListTerminalsPayload["terminals"][number]; type CreatedTerminal = NonNullable; -export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) { - return ["terminals", serverId, workspaceDirectory] as const; +export function buildTerminalsQueryKey( + serverId: string, + workspaceDirectory: string | null, + workspaceId?: string | null, +) { + return ["terminals", serverId, workspaceDirectory, workspaceId ?? null] as const; } export function canCreateWorkspaceTerminal(input: { diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index ca1bd6128..589eae678 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -73,8 +73,9 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { [isRouteFocused, client, isConnected, workspaceDirectory], ); const queryKey = useMemo( - () => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory), - [normalizedServerId, workspaceDirectory], + () => + buildTerminalsQueryKey(normalizedServerId, workspaceDirectory, normalizedWorkspaceId || null), + [normalizedServerId, normalizedWorkspaceId, workspaceDirectory], ); const query = useQuery({ @@ -84,7 +85,9 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { if (!client || !workspaceDirectory) { throw new Error(t("workspace.terminal.hostDisconnected")); } - return await client.listTerminals(workspaceDirectory); + return await client.listTerminals(workspaceDirectory, undefined, { + workspaceId: normalizedWorkspaceId || undefined, + }); }, staleTime: TERMINALS_QUERY_STALE_TIME, }); @@ -125,8 +128,11 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { ? await client.createTerminal(workspaceDirectory, _input.profile.name, undefined, { command: _input.profile.command, args: _input.profile.args, + workspaceId: normalizedWorkspaceId || undefined, }) - : await client.createTerminal(workspaceDirectory); + : await client.createTerminal(workspaceDirectory, undefined, undefined, { + workspaceId: normalizedWorkspaceId || undefined, + }); // The daemon reports a failed spawn (e.g. a profile command that isn't // installed) via payload.error with a null terminal. Surface it instead // of silently treating the create as a no-op success. @@ -177,25 +183,47 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { return; } + const paneWorkspaceId = normalizedWorkspaceId || undefined; + const unsubscribeChanged = client.on("terminals_changed", (message) => { if (message.payload.cwd !== workspaceDirectory) { return; } + // Two workspaces can share a cwd, so the push can carry terminals from a + // sibling workspace. Keep only the ones whose workspaceId matches this + // pane; terminals without a workspaceId predate Model B and belong to + // whichever pane is watching the cwd. + const matchingTerminals = message.payload.terminals.filter( + (terminal) => + terminal.workspaceId === undefined || terminal.workspaceId === paneWorkspaceId, + ); + queryClient.setQueryData(queryKey, (current) => ({ cwd: message.payload.cwd, - terminals: message.payload.terminals, + terminals: matchingTerminals, requestId: current?.requestId ?? `terminals-changed-${Date.now()}`, })); }); - client.subscribeTerminals({ cwd: workspaceDirectory }); + client.subscribeTerminals({ + cwd: workspaceDirectory, + workspaceId: paneWorkspaceId, + }); return () => { unsubscribeChanged(); - client.unsubscribeTerminals({ cwd: workspaceDirectory }); + client.unsubscribeTerminals({ cwd: workspaceDirectory, workspaceId: paneWorkspaceId }); }; - }, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]); + }, [ + client, + isConnected, + isRouteFocused, + normalizedWorkspaceId, + queryClient, + queryKey, + workspaceDirectory, + ]); useEffect(() => { if (!pendingCreateInput) { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 861686996..5b7406923 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -42,7 +42,7 @@ import invariant from "tiny-invariant"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; -import { BranchSwitcher } from "@/components/branch-switcher"; +import { ScreenTitle } from "@/components/headers/screen-title"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -1183,7 +1183,6 @@ interface WorkspaceHeaderTitleBarProps { subtitle: string; showSubtitle: boolean; currentBranchName: string | null; - isGitCheckout: boolean; normalizedServerId: string; normalizedWorkspaceId: string; workspaceScripts: WorkspaceDescriptor["scripts"]; @@ -1219,7 +1218,6 @@ function WorkspaceHeaderTitleBar({ subtitle, showSubtitle, currentBranchName, - isGitCheckout, normalizedServerId, normalizedWorkspaceId, workspaceScripts, @@ -1256,13 +1254,7 @@ function WorkspaceHeaderTitleBar({ ) : ( - + {title} {showSubtitle ? ( { expect(sidebarWorkspace.statusBucket).toBe("running"); }); + it("maps the sidebar entry branch from gitRuntime.currentBranch", () => { + const entry = createSidebarWorkspaceEntry({ + serverId: "srv", + workspace: createWorkspaceDescriptor({ + name: "feat/workspace-sot", + gitRuntime: { + currentBranch: "feat/real-branch", + isDirty: false, + aheadOfOrigin: 0, + }, + }), + }); + + expect(entry.currentBranch).toBe("feat/real-branch"); + }); + + it("normalizes detached HEAD, blank, and missing branches to null", () => { + const detached = createSidebarWorkspaceEntry({ + serverId: "srv", + workspace: createWorkspaceDescriptor({ + gitRuntime: { currentBranch: "HEAD", isDirty: false, aheadOfOrigin: 0 }, + }), + }); + const blank = createSidebarWorkspaceEntry({ + serverId: "srv", + workspace: createWorkspaceDescriptor({ + gitRuntime: { currentBranch: " ", isDirty: false, aheadOfOrigin: 0 }, + }), + }); + const missing = createSidebarWorkspaceEntry({ + serverId: "srv", + workspace: createWorkspaceDescriptor(), + }); + + expect(detached.currentBranch).toBeNull(); + expect(blank.currentBranch).toBeNull(); + expect(missing.currentBranch).toBeNull(); + }); + it("keeps the header skeleton while the workspace descriptor is missing", () => { expect( resolveWorkspaceHeaderRenderState({ diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index e9f1a6108..d7388e785 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -232,6 +232,29 @@ describe("workspace structure composition", () => { tracked.stop(); }); + it("renders a project with zero active workspaces as an empty project parent", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces(SERVER_ID, new Map()); + useSessionStore.getState().setEmptyProjects(SERVER_ID, [ + { + projectId: "empty-project", + projectDisplayName: "Empty Project", + projectCustomName: null, + projectRootPath: "/repo/empty", + projectKind: "git", + }, + ]); + + const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), SERVER_ID); + expect(projects).toEqual([ + expect.objectContaining({ + projectKey: "empty-project", + projectName: "Empty Project", + workspaceKeys: [], + }), + ]); + }); + it("changes when a structure-relevant project identity field changes", () => { const workspace = createWorkspace({ id: "workspace-a", diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 18d5748af..73fed2fb9 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -9,7 +9,7 @@ import { resolveWorkspaceIdByDirectory, resolveWorkspaceMapKeyByIdentity, } from "@/utils/workspace-identity"; -import type { WorkspaceDescriptor } from "../session-store"; +import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store"; export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure"; @@ -17,7 +17,11 @@ export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/w export interface SessionsSnapshot { sessions: Record< string, - { hasHydratedWorkspaces?: boolean; workspaces: Map } + { + hasHydratedWorkspaces?: boolean; + workspaces: Map; + emptyProjects?: Map; + } >; } @@ -142,12 +146,18 @@ export function selectWorkspaceStructureProjects( return EMPTY_WORKSPACE_STRUCTURE.projects; } - const workspaces = state.sessions[serverId]?.workspaces; - if (!workspaces || workspaces.size === 0) { + const session = state.sessions[serverId]; + const workspaces = session?.workspaces; + const emptyProjects = session?.emptyProjects; + if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) { return EMPTY_WORKSPACE_STRUCTURE.projects; } - return buildWorkspaceStructureProjects({ serverId, workspaces: workspaces.values() }); + return buildWorkspaceStructureProjects({ + serverId, + workspaces: workspaces?.values() ?? [], + emptyProjects: emptyProjects?.values() ?? [], + }); } export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index dc69fa824..e265d5643 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -26,6 +26,7 @@ import type { ProjectPlacementPayload, ServerCapabilities, WorkspaceDescriptorPayload, + WorkspaceProjectDescriptorPayload, } from "@getpaseo/protocol/messages"; import { normalizeWorkspaceOpaqueId, @@ -106,6 +107,7 @@ export interface Agent { lastError?: string | null; title: string | null; cwd: string; + workspaceId?: string; model: string | null; features?: AgentFeature[]; thinkingOptionId?: string | null; @@ -128,6 +130,7 @@ export interface WorkspaceDescriptor { projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; + title?: string | null; status: WorkspaceDescriptorPayload["status"]; statusEnteredAt: Date | null; archivingAt: string | null; @@ -159,6 +162,7 @@ export function normalizeWorkspaceDescriptor( projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, name: payload.name, + title: payload.title ?? null, status: payload.status, statusEnteredAt, archivingAt: payload.archivingAt ?? null, @@ -170,6 +174,26 @@ export function normalizeWorkspaceDescriptor( }; } +export interface EmptyProjectDescriptor { + projectId: string; + projectDisplayName: string; + projectCustomName: string | null; + projectRootPath: string; + projectKind: WorkspaceDescriptorPayload["projectKind"]; +} + +export function normalizeEmptyProjectDescriptor( + payload: WorkspaceProjectDescriptorPayload, +): EmptyProjectDescriptor { + return { + projectId: payload.projectId, + projectDisplayName: payload.projectDisplayName, + projectCustomName: payload.projectCustomName ?? null, + projectRootPath: payload.projectRootPath, + projectKind: payload.projectKind, + }; +} + function preserveWorkspaceDescriptorIdentity( incoming: WorkspaceDescriptor, existing?: WorkspaceDescriptor | null, @@ -305,6 +329,9 @@ export interface SessionState { agents: Map; agentDetails: Map; workspaces: Map; + // Project parents with no active workspaces, keyed by projectId. Rendered as + // empty project rows in the sidebar. + emptyProjects: Map; // Permissions pendingPermissions: Map; @@ -426,6 +453,8 @@ interface SessionStoreActions { ) => void; mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; removeWorkspace: (serverId: string, workspaceId: string) => void; + setEmptyProjects: (serverId: string, emptyProjects: Iterable) => void; + addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void; // Agent activity timestamps setAgentLastActivity: (agentId: string, timestamp: Date) => void; @@ -497,6 +526,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess agents: new Map(), agentDetails: new Map(), workspaces: new Map(), + emptyProjects: new Map(), pendingPermissions: new Map(), fileExplorer: new Map(), queuedMessages: new Map(), @@ -1178,6 +1208,51 @@ export const useSessionStore = create()( }); }, + setEmptyProjects: (serverId, emptyProjects) => { + const next = new Map(); + for (const project of emptyProjects) { + next.set(project.projectId, project); + } + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + if (session.emptyProjects.size === 0 && next.size === 0) { + return prev; + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, emptyProjects: next }, + }, + }; + }); + }, + + addEmptyProject: (serverId, emptyProject) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const existing = session.emptyProjects.get(emptyProject.projectId); + if (existing && equal(existing, emptyProject)) { + return prev; + } + const next = new Map(session.emptyProjects); + next.set(emptyProject.projectId, emptyProject); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, emptyProjects: next }, + }, + }; + }); + }, + mergeWorkspaces: (serverId, workspaces) => { const nextEntries = Array.from(workspaces); set((prev) => { @@ -1187,7 +1262,14 @@ export const useSessionStore = create()( } const next = new Map(session.workspaces); let changed = false; + // A workspace landing in a project means that project is no longer + // empty: prune any stale empty descriptor so it stops governing the + // project's rendered metadata. + const nextEmptyProjects = new Map(session.emptyProjects); for (const workspace of nextEntries) { + if (nextEmptyProjects.delete(workspace.projectId)) { + changed = true; + } const existing = next.get(workspace.id); const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing); if (existing === nextWorkspace) { @@ -1203,7 +1285,7 @@ export const useSessionStore = create()( ...prev, sessions: { ...prev.sessions, - [serverId]: { ...session, workspaces: next }, + [serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects }, }, }; }); diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index f45c36438..08de1633e 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -47,6 +47,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: lastError: snapshot.lastError ?? null, title: snapshot.title ?? null, cwd: snapshot.cwd, + workspaceId: snapshot.workspaceId, model: snapshot.model ?? null, features: snapshot.features, thinkingOptionId: snapshot.thinkingOptionId ?? null, diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index ce1768daa..dc66aa03b 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - buildSidebarProjectRowModel, - isSidebarProjectFlattened, -} from "./sidebar-project-row-model"; +import { buildSidebarProjectRowModel } from "./sidebar-project-row-model"; import type { SidebarProjectEntry, SidebarWorkspaceEntry, @@ -18,6 +15,8 @@ function workspace(overrides: Partial = {}): SidebarWorks projectKind: "git", workspaceKind: "checkout", name: "paseo", + title: null, + currentBranch: null, statusBucket: "done", diffStat: null, prHint: null, @@ -45,62 +44,27 @@ function project(overrides: Partial = {}): SidebarProjectEn } describe("buildSidebarProjectRowModel", () => { - it("flattens non-git projects with one workspace into a direct workspace row model", () => { - const flattenedWorkspace = workspace({ - workspaceId: "ws-non-git", - workspaceKind: "checkout", - statusBucket: "running", - }); - + it("renders a non-git single-workspace project as an expandable section", () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: "directory", - workspaces: [flattenedWorkspace], + workspaces: [workspace({ workspaceId: "ws-non-git", workspaceKind: "checkout" })], }), collapsed: false, }); expect(result).toEqual({ - kind: "workspace_link", - workspace: flattenedWorkspace, - chevron: null, + kind: "project_section", + chevron: "collapse", trailingAction: "none", }); }); - it("builds flattened non-git rows without route selection input", () => { - const flattenedWorkspace = workspace({ - serverId: "srv-2", - workspaceId: "ws-non-git", - }); - - const result = buildSidebarProjectRowModel({ - project: project({ - projectKind: "directory", - workspaces: [flattenedWorkspace], - }), - collapsed: false, - }); - - expect(result).toMatchObject({ - kind: "workspace_link", - workspace: flattenedWorkspace, - chevron: null, - trailingAction: "none", - }); - expect(result).not.toHaveProperty("selected"); - }); - - it("keeps single-workspace git projects as sections with the new worktree action", () => { - const onlyWorkspace = workspace({ - workspaceId: "ws-main", - workspaceKind: "checkout", - }); - + it("renders a single-workspace git project as an expandable section with the new worktree action", () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: "git", - workspaces: [onlyWorkspace], + workspaces: [workspace({ workspaceId: "ws-main", workspaceKind: "checkout" })], }), collapsed: true, }); @@ -112,7 +76,7 @@ describe("buildSidebarProjectRowModel", () => { }); }); - it("keeps multi-workspace git projects as expandable sections with a new worktree action", () => { + it("renders a multi-workspace git project as an expandable section with a new worktree action", () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: "git", @@ -130,28 +94,17 @@ describe("buildSidebarProjectRowModel", () => { trailingAction: "new_worktree", }); }); -}); -describe("isSidebarProjectFlattened", () => { - it("returns true only for single-workspace non-git projects", () => { - expect( - isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })), - ).toBe(false); - expect( - isSidebarProjectFlattened(project({ projectKind: "directory", workspaces: [workspace()] })), - ).toBe(true); - }); + it("renders an empty project as an expandable section", () => { + const result = buildSidebarProjectRowModel({ + project: project({ projectKind: "git", workspaces: [] }), + collapsed: false, + }); - it("returns false for multi-workspace projects", () => { - expect( - isSidebarProjectFlattened( - project({ - workspaces: [ - workspace({ workspaceId: "ws-main" }), - workspace({ workspaceId: "ws-feat" }), - ], - }), - ), - ).toBe(false); + expect(result).toEqual({ + kind: "project_section", + chevron: "collapse", + trailingAction: "new_worktree", + }); }); }); diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 2a4ffc3d0..d7847b808 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -1,56 +1,20 @@ -import type { - SidebarProjectEntry, - SidebarWorkspaceEntry, -} from "@/hooks/use-sidebar-workspaces-list"; - -export interface SidebarProjectWorkspaceLinkRowModel { - kind: "workspace_link"; - workspace: SidebarWorkspaceEntry; - chevron: null; - trailingAction: "new_worktree" | "none"; -} +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; export interface SidebarProjectSectionRowModel { kind: "project_section"; - chevron: "expand" | "collapse" | null; + chevron: "expand" | "collapse"; trailingAction: "new_worktree" | "none"; } -export type SidebarProjectRowModel = - | SidebarProjectWorkspaceLinkRowModel - | SidebarProjectSectionRowModel; - -export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean { - return project.workspaces.length === 1 && project.projectKind !== "git"; -} +export type SidebarProjectRowModel = SidebarProjectSectionRowModel; export function buildSidebarProjectRowModel(input: { project: SidebarProjectEntry; collapsed: boolean; }): SidebarProjectRowModel { - const flattenedWorkspace = isSidebarProjectFlattened(input.project) - ? (input.project.workspaces[0] ?? null) - : null; - - if (flattenedWorkspace) { - return { - kind: "workspace_link", - workspace: flattenedWorkspace, - chevron: null, - trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none", - }; - } - - const collapsible = input.project.projectKind === "git" || input.project.workspaces.length > 1; - - let chevron: "expand" | "collapse" | null; - if (!collapsible) chevron = null; - else if (input.collapsed) chevron = "expand"; - else chevron = "collapse"; - return { kind: "project_section", - chevron, + chevron: input.collapsed ? "expand" : "collapse", trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none", }; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index bc53acbaa..1163e1c14 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -28,6 +28,8 @@ function workspace(input: { projectKind: "git", workspaceKind: "checkout", name: input.name, + title: null, + currentBranch: null, statusBucket: input.statusBucket ?? "done", archivingAt: null, statusEnteredAt: input.statusEnteredAt ?? null, @@ -119,21 +121,29 @@ describe("buildSidebarShortcutModel", () => { expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" }); }); - it("still excludes collapsed single-workspace git projects because they are not flattened", () => { - const projects = [ - project("p1", [ - workspace({ - serverId: "s1", - workspaceId: "ws-main", - workspaceDirectory: "/repo/main", - name: "main", - }), - ]), - ]; + it("excludes a collapsed project's workspaces regardless of project kind", () => { + const gitProject = project("p1", [ + workspace({ + serverId: "s1", + workspaceId: "ws-main", + workspaceDirectory: "/repo/main", + name: "main", + }), + ]); + const directoryProject = project("p2", [ + workspace({ + serverId: "s1", + workspaceId: "ws-script", + workspaceDirectory: "/scripts", + name: "scripts", + }), + ]); + directoryProject.projectKind = "directory"; + directoryProject.canCreateWorktree = false; const model = buildSidebarShortcutModel({ - projects, - collapsedProjectKeys: new Set(["p1"]), + projects: [gitProject, directoryProject], + collapsedProjectKeys: new Set(["p1", "p2"]), }); expect(model.shortcutTargets).toEqual([]); diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index 2c75c8905..2fb34cf05 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -3,7 +3,6 @@ import type { SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; -import { isSidebarProjectFlattened } from "./sidebar-project-row-model"; export interface SidebarShortcutWorkspaceTarget { serverId: string; @@ -32,7 +31,7 @@ export function buildSidebarShortcutModel(input: { const shortcutIndexByWorkspaceKey = new Map(); for (const project of input.projects) { - if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) { + if (input.collapsedProjectKeys.has(project.projectKey)) { continue; } diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 1fd1ae6e0..72138ac70 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -10,6 +10,7 @@ import { function makeAgent(input: { id: string; cwd: string; + workspaceId?: string; parentAgentId?: string | null; archivedAt?: Date | null; createdAt?: Date; @@ -44,6 +45,7 @@ function makeAgent(input: { }, title: null, cwd: input.cwd, + workspaceId: input.workspaceId, model: null, thinkingOptionId: null, parentAgentId: input.parentAgentId ?? null, @@ -267,6 +269,65 @@ describe("workspace agent visibility", () => { expect(result.knownAgentIds).toEqual(new Set(["recent-agent"])); }); + it("matches agents by workspaceId regardless of cwd", () => { + const sessionAgents = new Map([ + [ + "stamped-agent", + makeAgent({ + id: "stamped-agent", + cwd: "/repo/subdir", + workspaceId: "ws-1", + }), + ], + ]); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents, + workspaceId: "ws-1", + workspaceDirectory: "/repo/worktree", + }); + + expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"])); + expect(result.knownAgentIds).toEqual(new Set(["stamped-agent"])); + }); + + it("excludes a stamped agent whose workspaceId belongs to another workspace sharing the cwd", () => { + const sessionAgents = new Map([ + [ + "other-ws-agent", + makeAgent({ + id: "other-ws-agent", + cwd: "/repo/worktree", + workspaceId: "ws-2", + }), + ], + ]); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents, + workspaceId: "ws-1", + workspaceDirectory: "/repo/worktree", + }); + + expect(result.activeAgentIds).toEqual(new Set()); + expect(result.knownAgentIds).toEqual(new Set()); + }); + + it("falls back to cwd matching for legacy agents without a workspaceId", () => { + const sessionAgents = new Map([ + ["legacy-agent", makeAgent({ id: "legacy-agent", cwd: "/repo/worktree" })], + ]); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents, + workspaceId: "ws-1", + workspaceDirectory: "/repo/worktree", + }); + + expect(result.activeAgentIds).toEqual(new Set(["legacy-agent"])); + expect(result.knownAgentIds).toEqual(new Set(["legacy-agent"])); + }); + it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { const agentVisibility = { activeAgentIds: new Set(["active-agent"]), diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 06d920fe1..affdb5fc6 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -1,7 +1,7 @@ import type { Agent } from "@/stores/session-store"; import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions"; import { shouldAutoOpenAgentTab } from "@/subagents/policies"; -import { normalizeWorkspacePath } from "@/utils/workspace-identity"; +import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity"; function normalizeWorkspaceDirectory(value: string | null | undefined): string { return normalizeWorkspacePath(value) ?? ""; @@ -13,14 +13,31 @@ export interface WorkspaceAgentVisibility { knownAgentIds: Set; } +function agentBelongsToWorkspace(input: { + agent: Agent; + workspaceId: string | null; + normalizedWorkspaceDirectory: string; +}): boolean { + const agentWorkspaceId = normalizeWorkspaceOpaqueId(input.agent.workspaceId); + if (agentWorkspaceId) { + return input.workspaceId !== null && agentWorkspaceId === input.workspaceId; + } + // COMPAT(workspaceOwnership): legacy agents predate workspaceId stamping. Fall + // back to the single approved cwd→id inference by comparing the agent's cwd to + // this workspace's directory. Drop when the daemon floor always stamps workspaceId. + return normalizeWorkspaceDirectory(input.agent.cwd) === input.normalizedWorkspaceDirectory; +} + export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | undefined; agentDetails?: Map | undefined; + workspaceId?: string | null | undefined; workspaceDirectory: string | null | undefined; }): WorkspaceAgentVisibility { const { sessionAgents, agentDetails, workspaceDirectory } = input; + const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory); - if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) { + if ((!sessionAgents && !agentDetails) || (!workspaceId && !normalizedWorkspaceDirectory)) { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), @@ -32,7 +49,7 @@ export function deriveWorkspaceAgentVisibility(input: { const autoOpenAgentIds = new Set(); const knownAgentIds = new Set(); for (const agent of sessionAgents?.values() ?? []) { - if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) { + if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) { continue; } knownAgentIds.add(agent.id); @@ -44,7 +61,7 @@ export function deriveWorkspaceAgentVisibility(input: { } } for (const agent of agentDetails?.values() ?? []) { - if (normalizeWorkspaceDirectory(agent.cwd) !== normalizedWorkspaceDirectory) { + if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) { continue; } knownAgentIds.add(agent.id); diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts new file mode 100644 index 000000000..1c94a0266 --- /dev/null +++ b/packages/app/src/workspace/use-workspace-archive.ts @@ -0,0 +1,164 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSessionStore } from "@/stores/session-store"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useToast } from "@/contexts/toast-context"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; +import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; +import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; + +// A workspace is the last reference to its backing worktree when no other active +// workspace on the same host points at the same directory. A directory can back +// multiple workspaces (Model B), so a sibling reference must keep the worktree on +// disk even when this workspace is archived. Local checkouts are never worktrees, +// so they never reach the disk-deletion prompt. +export function useIsLastWorktreeReference(workspace: SidebarWorkspaceEntry): boolean { + return useSessionStore((state) => { + if (workspace.workspaceKind !== "worktree") { + return false; + } + const directory = normalizeWorkspacePath(workspace.workspaceDirectory); + if (!directory) { + return false; + } + const workspaces = state.sessions[workspace.serverId]?.workspaces; + if (!workspaces) { + return true; + } + for (const candidate of workspaces.values()) { + if (candidate.id === workspace.workspaceId) { + continue; + } + if (normalizeWorkspacePath(candidate.workspaceDirectory) === directory) { + return false; + } + } + return true; + }); +} + +export interface WorkspaceArchiveController { + // Begins the archive flow. For a last-reference worktree this opens the inline + // keep/delete prompt; otherwise it archives the workspace record directly. + beginArchive: () => void; + // Inline prompt state for the last-reference worktree case. + deletePromptOpen: boolean; + confirmKeepOnDisk: () => void; + confirmDeleteFromDisk: () => void; + cancelDeletePrompt: () => void; +} + +export function useWorkspaceArchive(input: { + workspace: SidebarWorkspaceEntry; + onArchiveStarted: () => void; + onSetHiding?: (hiding: boolean) => void; +}): WorkspaceArchiveController { + const { workspace, onArchiveStarted, onSetHiding } = input; + const { t } = useTranslation(); + const toast = useToast(); + const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const isLastWorktreeReference = useIsLastWorktreeReference(workspace); + const [deletePromptOpen, setDeletePromptOpen] = useState(false); + + const archiveWorktreeRecord = useCallback( + (deleteWorktreeFromDisk: boolean) => { + let archiveDirectory: string; + try { + archiveDirectory = requireWorkspaceDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t("sidebar.workspace.toasts.workspacePathUnavailable"), + ); + return; + } + onArchiveStarted(); + void archiveWorktree({ + serverId: workspace.serverId, + cwd: archiveDirectory, + worktreePath: archiveDirectory, + workspaceId: workspace.workspaceId, + deleteWorktreeFromDisk, + }).catch((error) => { + toast.error( + error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"), + ); + }); + }, + [archiveWorktree, onArchiveStarted, t, toast, workspace], + ); + + const archiveNonWorktreeRecord = useCallback(async () => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + toast.error(t("sidebar.workspace.toasts.hostDisconnected")); + return; + } + onSetHiding?.(true); + try { + await archiveWorkspaceOptimistically({ + client, + workspace, + afterHide: onArchiveStarted, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"), + ); + } finally { + onSetHiding?.(false); + } + }, [onArchiveStarted, onSetHiding, t, toast, workspace]); + + const beginArchive = useCallback(() => { + void (async () => { + if (workspace.workspaceKind === "worktree") { + const confirmed = await confirmRiskyWorktreeArchive({ + worktreeName: workspace.name, + isDirty: workspace.archiveHasUncommittedChanges, + aheadOfOrigin: workspace.archiveUnpushedCommitCount, + diffStat: workspace.diffStat, + }); + if (!confirmed) { + return; + } + if (isLastWorktreeReference) { + setDeletePromptOpen(true); + return; + } + archiveWorktreeRecord(false); + return; + } + await archiveNonWorktreeRecord(); + })(); + }, [archiveNonWorktreeRecord, archiveWorktreeRecord, isLastWorktreeReference, workspace]); + + const confirmKeepOnDisk = useCallback(() => { + setDeletePromptOpen(false); + archiveWorktreeRecord(false); + }, [archiveWorktreeRecord]); + + const confirmDeleteFromDisk = useCallback(() => { + setDeletePromptOpen(false); + archiveWorktreeRecord(true); + }, [archiveWorktreeRecord]); + + const cancelDeletePrompt = useCallback(() => { + setDeletePromptOpen(false); + }, []); + + return { + beginArchive, + deletePromptOpen, + confirmKeepOnDisk, + confirmDeleteFromDisk, + cancelDeletePrompt, + }; +} diff --git a/packages/app/src/workspace/worktree-delete-prompt.tsx b/packages/app/src/workspace/worktree-delete-prompt.tsx new file mode 100644 index 000000000..0bd591951 --- /dev/null +++ b/packages/app/src/workspace/worktree-delete-prompt.tsx @@ -0,0 +1,71 @@ +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; + +// Inline keep/delete confirmation shown when archiving the last reference to a +// Paseo-owned worktree. "Keep on disk" is the default, non-destructive choice; +// "Delete" removes the worktree directory from disk. +export function WorktreeDeletePrompt({ + visible, + workspaceName, + onKeep, + onDelete, + onCancel, +}: { + visible: boolean; + workspaceName: string; + onKeep: () => void; + onDelete: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const header = useMemo( + () => ({ title: t("sidebar.workspace.confirmations.deleteWorktreePrompt.title") }), + [t], + ); + + return ( + + + + {t("sidebar.workspace.confirmations.deleteWorktreePrompt.message", { + workspaceName, + })} + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + gap: theme.spacing[4], + }, + message: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + actions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, +})); diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts new file mode 100644 index 000000000..65ea370ec --- /dev/null +++ b/packages/cli/src/commands/agent/run.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runRunCommand, type AgentRunOptions } from "./run"; + +// validateRunOptions runs before the CLI ever connects to a daemon, so these +// invalid combinations reject without one running. +describe("runRunCommand option validation", () => { + const originalWorkspaceId = process.env.PASEO_WORKSPACE_ID; + + beforeEach(() => { + delete process.env.PASEO_WORKSPACE_ID; + }); + + afterEach(() => { + if (originalWorkspaceId === undefined) { + delete process.env.PASEO_WORKSPACE_ID; + } else { + process.env.PASEO_WORKSPACE_ID = originalWorkspaceId; + } + }); + + async function expectInvalidOptions(options: AgentRunOptions, messageMatch: RegExp) { + await expect(runRunCommand("do something", options, {} as never)).rejects.toMatchObject({ + code: "INVALID_OPTIONS", + message: expect.stringMatching(messageMatch), + }); + } + + it("rejects --worktree combined with --workspace", async () => { + await expectInvalidOptions( + { worktree: "feat", workspace: "ws-1" }, + /--worktree and --workspace cannot be combined/, + ); + }); + + it("rejects --worktree combined with an ambient PASEO_WORKSPACE_ID", async () => { + process.env.PASEO_WORKSPACE_ID = "ws-ambient"; + await expectInvalidOptions( + { worktree: "feat" }, + /--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID/, + ); + }); + + it("allows a bare --worktree through validation when no workspace is selected", async () => { + // A bare --worktree with no --workspace and no ambient PASEO_WORKSPACE_ID + // must clear validation. It still fails later (provider resolution), which + // is enough to prove the new guard did not reject it. + await expect( + runRunCommand("do something", { worktree: "feat", provider: undefined }, {} as never), + ).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" }); + }); +}); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 977929cca..b127e64dc 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -36,6 +36,10 @@ export function addRunOptions(cmd: Command): Command { .option("--mode ", "Provider-specific mode (e.g., plan, default, bypass)") .option("--worktree ", "Create agent in a new git worktree") .option("--base ", "Base branch for worktree (default: current branch)") + .option( + "--workspace ", + "Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)", + ) .option( "--image ", "Attach image(s) to the initial prompt (can be used multiple times)", @@ -96,6 +100,7 @@ export interface AgentRunOptions extends CommandOptions { mode?: string; worktree?: string; base?: string; + workspace?: string; image?: string[]; cwd?: string; env?: string[]; @@ -273,6 +278,23 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche } satisfies CommandError; } + if (options.worktree && options.workspace) { + throw { + code: "INVALID_OPTIONS", + message: "--worktree and --workspace cannot be combined", + details: "--workspace runs in an existing workspace; --worktree mints a new one", + } satisfies CommandError; + } + + if (options.worktree && !options.workspace && process.env.PASEO_WORKSPACE_ID) { + throw { + code: "INVALID_OPTIONS", + message: "--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID", + details: + "PASEO_WORKSPACE_ID selects an existing workspace; --worktree mints a new one. Unset PASEO_WORKSPACE_ID to use --worktree.", + } satisfies CommandError; + } + if (outputSchema && options.detach) { throw { code: "INVALID_OPTIONS", @@ -383,6 +405,62 @@ async function connectToDaemonOrThrow( } } +// A workspace is the explicit home of a run: it owns the directory the agent +// runs in. The CLI resolves one before creating any agent, so no run leans on +// createAgent's legacy cwd->workspace fallback. +interface RunWorkspace { + id: string; + cwd: string; +} + +// Workspace policy for `paseo run`. Precedence: +// 1. --workspace -> run in that existing workspace +// 2. $PASEO_WORKSPACE_ID -> exported by workspace terminals +// 3. --worktree -> mint a new worktree-backed workspace +// 4. bare run -> mint a new local-backed workspace for cwd +// --worktree is rejected alongside both --workspace and an ambient +// $PASEO_WORKSPACE_ID (validateRunOptions), so worktree resolution here never +// races an existing-workspace selection. +async function resolveRunWorkspace( + client: ConnectedDaemonClient, + options: AgentRunOptions, + cwd: string, +): Promise { + // An explicit --worktree mints its own workspace; --workspace and an ambient + // PASEO_WORKSPACE_ID are both rejected alongside --worktree upstream. + const explicit = options.worktree + ? undefined + : options.workspace?.trim() || process.env.PASEO_WORKSPACE_ID?.trim(); + if (explicit) { + console.error(`Using workspace ${explicit}`); + return { id: explicit, cwd }; + } + + const result = options.worktree + ? await client.createWorkspace({ + backing: "worktree", + cwd, + branch: options.worktree, + baseBranch: options.base, + }) + : await client.createWorkspace({ backing: "local", cwd }); + + if (!result.workspace) { + throw { + code: "WORKSPACE_CREATE_FAILED", + message: result.error ?? "Failed to create workspace for this run", + } satisfies CommandError; + } + + const branch = result.workspace.gitRuntime?.currentBranch; + const label = branch ? `${result.workspace.name} (${branch})` : result.workspace.name; + console.error(`Created workspace ${result.workspace.id} - ${label}`); + console.error( + "Tip: pass --workspace (or set PASEO_WORKSPACE_ID) to run in an existing workspace.", + ); + return { id: result.workspace.id, cwd: result.workspace.workspaceDirectory ?? cwd }; +} + export async function runRunCommand( prompt: string, options: AgentRunOptions, @@ -415,18 +493,14 @@ export async function runRunCommand( const images = loadRunImages(options.image); - const git = options.worktree - ? { - createWorktree: true, - worktreeSlug: options.worktree, - baseBranch: options.base, - } - : undefined; - const labels = parseRunLabels(options.label); const env = parseRunEnv(options.env); const requestEnv = Object.keys(env).length > 0 ? env : undefined; + const workspace = await resolveRunWorkspace(client, options, cwd); + const workspaceId = workspace.id; + const runCwd = workspace.cwd; + if (outputSchema) { let structuredAgent: AgentSnapshotPayload | null = null; @@ -434,7 +508,8 @@ export async function runRunCommand( if (!structuredAgent) { structuredAgent = await client.createAgent({ provider: resolvedProviderModel.provider, - cwd, + cwd: runCwd, + workspaceId, title: resolvedTitle, modeId: options.mode, model: resolvedProviderModel.model, @@ -443,8 +518,6 @@ export async function runRunCommand( outputSchema, images, env: requestEnv, - git, - worktreeName: options.worktree, labels: Object.keys(labels).length > 0 ? labels : undefined, }); } else { @@ -505,7 +578,8 @@ export async function runRunCommand( // Create the agent const agent = await client.createAgent({ provider: resolvedProviderModel.provider, - cwd, + cwd: runCwd, + workspaceId, title: resolvedTitle, modeId: options.mode, model: resolvedProviderModel.model, @@ -513,8 +587,6 @@ export async function runRunCommand( initialPrompt: prompt, images, env: requestEnv, - git, - worktreeName: options.worktree, labels: Object.keys(labels).length > 0 ? labels : undefined, }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 482ab6fb4..aec8fbc50 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -89,6 +89,7 @@ import type { } from "@getpaseo/protocol/agent-types"; import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; +import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; import { asUint8Array, decodeFileTransferFrame, @@ -311,6 +312,10 @@ type CreatePaseoWorktreePayload = Extract< SessionOutboundMessage, { type: "create_paseo_worktree_response" } >["payload"]; +type WorkspaceCreatePayload = Extract< + SessionOutboundMessage, + { type: "workspace.create.response" } +>["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; export type FileExplorerDirectoryPayload = NonNullable; type LegacyFileExplorerFilePayload = NonNullable; @@ -879,7 +884,7 @@ export class DaemonClient { compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; } >(); - private terminalDirectorySubscriptions = new Set(); + private terminalDirectorySubscriptions = new Map(); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); @@ -1895,10 +1900,13 @@ export class DaemonClient { if (this.terminalDirectorySubscriptions.size === 0) { return; } - for (const cwd of this.terminalDirectorySubscriptions) { + for (const subscription of this.terminalDirectorySubscriptions.values()) { this.sendSessionMessage({ type: "subscribe_terminals_request", - cwd, + cwd: subscription.cwd, + ...(subscription.workspaceId !== undefined + ? { workspaceId: subscription.workspaceId } + : {}), }); } } @@ -2064,6 +2072,27 @@ export class DaemonClient { return { customName: payload.customName }; } + async setWorkspaceTitle( + workspaceId: string, + title: string | null, + requestId?: string, + ): Promise<{ title: string | null }> { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "workspace.title.set.request", + workspaceId, + title, + }, + responseType: "workspace.title.set.response", + timeout: 10000, + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "setWorkspaceTitle rejected"); + } + return { title: payload.title }; + } + async resumeAgent( handle: AgentPersistenceHandle, overrides?: Partial, @@ -3177,7 +3206,13 @@ export class DaemonClient { } async archivePaseoWorktree( - input: { worktreePath?: string; repoRoot?: string; branchName?: string }, + input: { + worktreePath?: string; + repoRoot?: string; + branchName?: string; + workspaceId?: string; + deleteWorktreeFromDisk?: boolean; + }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ @@ -3187,6 +3222,10 @@ export class DaemonClient { worktreePath: input.worktreePath, repoRoot: input.repoRoot, branchName: input.branchName, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + ...(input.deleteWorktreeFromDisk !== undefined + ? { deleteWorktreeFromDisk: input.deleteWorktreeFromDisk } + : {}), }, responseType: "paseo_worktree_archive_response", timeout: 60000, @@ -3216,6 +3255,33 @@ export class DaemonClient { }); } + async createWorkspace( + input: { + backing: "local" | "worktree"; + cwd?: string; + projectId?: string; + branch?: string; + baseBranch?: string; + title?: string; + }, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "workspace.create.request", + backing: input.backing, + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.branch !== undefined ? { branch: input.branch } : {}), + ...(input.baseBranch !== undefined ? { baseBranch: input.baseBranch } : {}), + ...(input.title !== undefined ? { title: input.title } : {}), + }, + responseType: "workspace.create.response", + timeout: 60000, + }); + } + async validateBranch( options: { cwd: string; branchName: string }, requestId?: string, @@ -3835,33 +3901,45 @@ export class DaemonClient { // Terminals // ============================================================================ - subscribeTerminals(input: { cwd: string }): void { - this.terminalDirectorySubscriptions.add(input.cwd); + subscribeTerminals(input: { cwd: string; workspaceId?: string }): void { + this.terminalDirectorySubscriptions.set(terminalSubscriptionKey(input.cwd, input.workspaceId), { + cwd: input.cwd, + workspaceId: input.workspaceId, + }); if (!this.transport || this.connectionState.status !== "connected") { return; } this.sendSessionMessage({ type: "subscribe_terminals_request", cwd: input.cwd, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), }); } - unsubscribeTerminals(input: { cwd: string }): void { - this.terminalDirectorySubscriptions.delete(input.cwd); + unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void { + this.terminalDirectorySubscriptions.delete( + terminalSubscriptionKey(input.cwd, input.workspaceId), + ); if (!this.transport || this.connectionState.status !== "connected") { return; } this.sendSessionMessage({ type: "unsubscribe_terminals_request", cwd: input.cwd, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), }); } - async listTerminals(cwd?: string, requestId?: string): Promise { + async listTerminals( + cwd?: string, + requestId?: string, + options?: { workspaceId?: string }, + ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "list_terminals_request", ...(cwd === undefined ? {} : { cwd }), + ...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ @@ -3877,7 +3955,7 @@ export class DaemonClient { cwd: string, name?: string, requestId?: string, - options?: { agentId?: string; command?: string; args?: string[] }, + options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string }, ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ @@ -3887,6 +3965,7 @@ export class DaemonClient { agentId: options?.agentId, command: options?.command, args: options?.args, + ...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index a765839dc..030fe64fb 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -199,6 +199,7 @@ test("createPaseoClient exposes workspace list through the daemon client", async await expect(listPromise).resolves.toEqual({ requestId: request.requestId, entries: [], + emptyProjects: [], pageInfo: { nextCursor: null, prevCursor: null, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 7e527961d..4ff30b344 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -666,6 +666,7 @@ export const AgentSnapshotPayloadSchema = z.object({ id: z.string(), provider: AgentProviderSchema, cwd: z.string(), + workspaceId: z.string().optional(), model: z.string().nullable(), features: z.array(AgentFeatureSchema).optional(), thinkingOptionId: z.string().nullable().optional(), @@ -797,6 +798,14 @@ export const ProjectRenameRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceTitleSetRequestSchema = z.object({ + type: z.literal("workspace.title.set.request"), + workspaceId: z.string(), + // Null or empty string clears the title and reverts to the derived name. + title: z.string().nullable(), + requestId: z.string(), +}); + export const SetVoiceModeMessageSchema = z.object({ type: z.literal("set_voice_mode"), enabled: z.boolean(), @@ -1334,6 +1343,19 @@ export const ProjectRenameResponseSchema = z.object({ payload: ProjectRenameResponsePayloadSchema, }); +export const WorkspaceTitleSetResponsePayloadSchema = z.object({ + requestId: z.string(), + workspaceId: z.string(), + accepted: z.boolean(), + title: z.string().nullable(), + error: z.string().nullable(), +}); + +export const WorkspaceTitleSetResponseSchema = z.object({ + type: z.literal("workspace.title.set.response"), + payload: WorkspaceTitleSetResponsePayloadSchema, +}); + export const SetVoiceModeResponseMessageSchema = z.object({ type: z.literal("set_voice_mode_response"), payload: z.object({ @@ -1586,6 +1608,17 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({ worktreePath: z.string().optional(), repoRoot: z.string().optional(), branchName: z.string().optional(), + // COMPAT(worktreeArchiveWorkspaceId): added in v0.1.97, drop the optional gate when floor >= v0.1.97. + // Explicit workspace record to archive. A directory can back multiple workspaces + // (Model B), so resolving the target by cwd alone picks the wrong record. When + // present the daemon archives this exact workspace; when absent it falls back to + // resolving by worktreePath, preferring the worktree-kind record on a cwd tie. + workspaceId: z.string().optional(), + // COMPAT(worktreeDiskDeletion): added in v0.1.97, drop the optional gate when floor >= v0.1.97. + // When true, and the workspace is the last active reference to a Paseo-owned + // worktree, the daemon also removes the worktree directory from disk. Default + // false: archiving only removes the workspace record and leaves the directory. + deleteWorktreeFromDisk: z.boolean().optional().default(false), requestId: z.string(), }); @@ -1642,6 +1675,26 @@ export const ArchiveWorkspaceRequestSchema = z.object({ requestId: z.string(), }); +// Create a new workspace record. Unlike open_project, this never deduplicates by +// directory: it always produces a fresh workspace. The backing directory is a +// choice — an existing local checkout/directory, or a newly created worktree. +export const WorkspaceCreateRequestSchema = z.object({ + type: z.literal("workspace.create.request"), + backing: z.enum(["local", "worktree"]), + // local: path of the existing checkout/directory to back the workspace. + cwd: z.string().optional(), + // worktree: the project whose repo the worktree is cut from. local may also + // pass projectId, but the directory governs placement there. + projectId: z.string().optional(), + // worktree only: branch is the new/checked-out branch (slug); baseBranch is + // the branch to cut from. + branch: z.string().optional(), + baseBranch: z.string().optional(), + // Optional user-set title applied to the created workspace. + title: z.string().optional(), + requestId: z.string(), +}); + export const WorkspaceClearAttentionRequestSchema = z.object({ type: z.literal("workspace.clear_attention.request"), workspaceId: z.union([z.string(), z.array(z.string())]), @@ -1790,22 +1843,26 @@ export const RegisterPushTokenMessageSchema = z.object({ export const ListTerminalsRequestSchema = z.object({ type: z.literal("list_terminals_request"), cwd: z.string().optional(), + workspaceId: z.string().optional(), requestId: z.string(), }); export const SubscribeTerminalsRequestSchema = z.object({ type: z.literal("subscribe_terminals_request"), cwd: z.string(), + workspaceId: z.string().optional(), }); export const UnsubscribeTerminalsRequestSchema = z.object({ type: z.literal("unsubscribe_terminals_request"), cwd: z.string(), + workspaceId: z.string().optional(), }); export const CreateTerminalRequestSchema = z.object({ type: z.literal("create_terminal_request"), cwd: z.string(), + workspaceId: z.string().optional(), name: z.string().optional(), agentId: z.string().optional(), command: z.string().optional(), @@ -1897,6 +1954,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CloseItemsRequestMessageSchema, UpdateAgentRequestMessageSchema, ProjectRenameRequestSchema, + WorkspaceTitleSetRequestSchema, SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, @@ -1963,6 +2021,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ LegacyOpenInEditorRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, + WorkspaceCreateRequestSchema, WorkspaceClearAttentionRequestSchema, FileExplorerRequestSchema, ProjectIconRequestSchema, @@ -2181,6 +2240,8 @@ export const ServerInfoStatusPayloadSchema = z rewind: z.boolean().optional(), // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29. checkoutRefresh: z.boolean().optional(), + // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 + workspaceMultiplicity: z.boolean().optional(), }) .optional(), }) @@ -2452,6 +2513,12 @@ export const WorkspaceDescriptorPayloadSchema = z // COMPAT(workspaces): keep legacy directory workspace kind parseable. workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), name: z.string(), + // COMPAT(workspaceTitles): added in v0.1.97, drop the optional gate when floor >= v0.1.97. + // When the user has titled a workspace, `name` carries the resolved value + // (title) and `title` mirrors the raw override so the rename UI can prefill + // its input and offer a "reset to branch name" action. Null means the name + // is derived from the branch/directory. + title: z.string().nullable().optional(), archivingAt: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, // Best-effort workspace status entry timestamp. Old daemons omit the @@ -2562,12 +2629,27 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({ }), }); +// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97. +// A project parent that has zero active workspaces. The sidebar renders it as an +// empty project row so projects persist after their last workspace is archived. +export const WorkspaceProjectDescriptorPayloadSchema = z.object({ + projectId: z.string(), + projectDisplayName: z.string(), + projectCustomName: z.string().nullable().optional(), + projectRootPath: z.string(), + projectKind: z.enum(["git", "non_git", "directory"]), +}); + export const FetchWorkspacesResponseMessageSchema = z.object({ type: z.literal("fetch_workspaces_response"), payload: z.object({ requestId: z.string(), subscriptionId: z.string().nullable().optional(), entries: z.array(WorkspaceDescriptorPayloadSchema), + // COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97. + // Project parents with no active workspaces. Old daemons omit it; old clients + // ignore it. Only populated on the first page (no cursor). + emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]), pageInfo: z.object({ nextCursor: z.string().nullable(), prevCursor: z.string().nullable(), @@ -2586,6 +2668,13 @@ export const WorkspaceUpdateMessageSchema = z.object({ z.object({ kind: z.literal("remove"), id: z.string(), + // COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97. + // When archiving this workspace leaves its project with no active + // workspaces, the daemon includes the now-empty project parent so the + // sidebar keeps rendering it without waiting for a full re-hydration. Old + // daemons omit it; old clients ignore it and surface the empty project on + // their next workspace fetch instead. + emptyProject: WorkspaceProjectDescriptorPayloadSchema.optional(), }), ]), }); @@ -2747,6 +2836,17 @@ export const ClearAgentAttentionResponseMessageSchema = z.object({ }), }); +export const WorkspaceCreateResponseSchema = z.object({ + type: z.literal("workspace.create.response"), + payload: z.object({ + workspace: WorkspaceDescriptorPayloadSchema.nullable(), + setupTerminalId: z.string().nullable(), + error: z.string().nullable(), + errorCode: z.string().optional(), + requestId: z.string(), + }), +}); + export const WorkspaceClearAttentionResponseSchema = z.object({ type: z.literal("workspace.clear_attention.response"), payload: z.object({ @@ -3691,6 +3791,7 @@ const TerminalInfoSchema = z.object({ id: z.string(), name: z.string(), cwd: z.string(), + workspaceId: z.string().optional(), title: z.string().optional(), activity: TerminalActivitySchema.nullable().optional(), }); @@ -3862,6 +3963,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentTimelineResponseMessageSchema, CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, + WorkspaceCreateResponseSchema, WorkspaceClearAttentionResponseSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, @@ -3878,6 +3980,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentRewindResponseMessageSchema, UpdateAgentResponseMessageSchema, ProjectRenameResponseSchema, + WorkspaceTitleSetResponseSchema, WaitForFinishResponseMessageSchema, AgentPermissionRequestMessageSchema, AgentPermissionResolvedMessageSchema, @@ -3983,6 +4086,9 @@ export type ProjectCheckoutLitePayload = z.infer; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; +export type WorkspaceProjectDescriptorPayload = z.infer< + typeof WorkspaceProjectDescriptorPayloadSchema +>; export type WorkspaceScriptLifecycle = z.infer; export type WorkspaceScriptHealth = z.infer; export type WorkspaceScriptPayload = z.infer; @@ -4020,6 +4126,12 @@ export type SetAgentFeatureResponseMessage = z.infer; export type UpdateAgentResponseMessage = z.infer; export type ProjectRenameResponse = z.infer; +export type WorkspaceTitleSetResponse = z.infer; +export type WorkspaceTitleSetResponsePayload = z.infer< + typeof WorkspaceTitleSetResponsePayloadSchema +>; +export type WorkspaceCreateRequest = z.infer; +export type WorkspaceCreateResponse = z.infer; export type ProjectRenameResponsePayload = z.infer; export type WaitForFinishResponseMessage = z.infer; export type AgentPermissionRequestMessage = z.infer; @@ -4136,6 +4248,7 @@ export type ResumeAgentRequestMessage = z.infer; export type UpdateAgentRequestMessage = z.infer; export type ProjectRenameRequest = z.infer; +export type WorkspaceTitleSetRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; export type SetAgentModelRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; diff --git a/packages/protocol/src/terminal-subscription-key.ts b/packages/protocol/src/terminal-subscription-key.ts new file mode 100644 index 000000000..648ff96f7 --- /dev/null +++ b/packages/protocol/src/terminal-subscription-key.ts @@ -0,0 +1,8 @@ +// A terminal directory subscription is scoped to a (cwd, workspaceId) pair, not +// cwd alone: under Model B two workspaces can share a cwd, and each must track +// and tear down its own live subscription without disturbing the other. An +// absent workspaceId (old clients) keys to the cwd on its own. The `::` +// separator cannot collide with a workspace id, which is always `wks_`. +export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string { + return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`; +} diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index 6e434e905..cf6e521f1 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -63,7 +63,10 @@ export async function ensureAgentLoaded( if (!config) { throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); } - snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels }); + snapshot = await deps.agentManager.createAgent(config, agentId, { + labels: record.labels, + workspaceId: record.workspaceId, + }); deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3b765b3ab..75500a8ec 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -13,6 +13,7 @@ import { type ManagedAgent, } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; +import { toAgentPayload } from "./agent-projections.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { formatSystemNotificationPrompt } from "./agent-prompt.js"; import type { StoredAgentRecord } from "./agent-storage.js"; @@ -930,6 +931,39 @@ test("createAgent passes persistSession to provider create options", async () => rmSync(workdir, { recursive: true, force: true }); }); +test("createAgent persists workspaceId on the stored record and emits it in the snapshot", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-0000000000a1", + }); + + try { + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: "wks_owner" }, + ); + + expect(agent.workspaceId).toBe("wks_owner"); + expect(toAgentPayload(agent).workspaceId).toBe("wks_owner"); + + const record = await storage.get(agent.id); + expect(record?.workspaceId).toBe("wks_owner"); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("createAgent injects paseo MCP server only into provider launch config", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b4b95d191..f2cff102a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -250,6 +250,12 @@ interface ManagedAgentBase { id: string; provider: AgentProvider; cwd: string; + /** + * Workspace this agent belongs to, stamped at creation. Independent of cwd: + * cwd answers "where does it run", workspaceId answers "which workspace owns it". + * Null/undefined for legacy agents created before ownership stamping. + */ + workspaceId?: string; capabilities: AgentCapabilityFlags; config: AgentSessionConfig; runtimeInfo?: AgentRuntimeInfo; @@ -853,6 +859,7 @@ export class AgentManager { env?: Record; persistSession?: boolean; initialTitle?: string | null; + workspaceId?: string; }, ): Promise { const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); @@ -867,6 +874,7 @@ export class AgentManager { return this.registerSession(session, storedConfig, resolvedAgentId, { labels: options?.labels, initialTitle: options?.initialTitle, + workspaceId: options?.workspaceId, }); } @@ -889,6 +897,7 @@ export class AgentManager { updatedAt?: Date; lastUserMessageAt?: Date | null; labels?: Record; + workspaceId?: string; }, ): Promise { const resolvedAgentId = validateAgentId( @@ -1019,6 +1028,7 @@ export class AgentManager { // Preserve existing labels and timeline during reload. return this.registerSession(session, storedConfig, agentId, { labels: existing.labels, + workspaceId: existing.workspaceId, createdAt: existing.createdAt, updatedAt: existing.updatedAt, lastUserMessageAt: existing.lastUserMessageAt, @@ -1202,6 +1212,7 @@ export class AgentManager { id: record.id, provider: record.provider, cwd: record.cwd, + workspaceId: record.workspaceId, session: null, capabilities: STORED_AGENT_CAPABILITIES, config: buildStoredAgentConfig(record), @@ -2333,6 +2344,7 @@ export class AgentManager { attention?: AttentionState; initialTitle?: string | null; publishWhenReady?: boolean; + workspaceId?: string; }, ): Promise { const resolvedAgentId = validateAgentId(agentId, "registerSession"); @@ -2432,6 +2444,7 @@ export class AgentManager { lastError?: string; attention?: AttentionState; persistence?: AgentPersistenceHandle; + workspaceId?: string; } | undefined; }): ActiveManagedAgent { @@ -2440,6 +2453,7 @@ export class AgentManager { id: resolvedAgentId, provider: config.provider, cwd: config.cwd, + workspaceId: options?.workspaceId, session, capabilities: session.capabilities, config, diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 8ac6650e7..6adee4bd5 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -73,6 +73,7 @@ export function toStoredAgentRecord( id: agent.id, provider: agent.provider, cwd: agent.cwd, + workspaceId: agent.workspaceId, createdAt, updatedAt: agent.updatedAt.toISOString(), lastActivityAt: agent.updatedAt.toISOString(), @@ -110,6 +111,7 @@ export function toAgentPayload( id: agent.id, provider: agent.provider, cwd: agent.cwd, + ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), model: agent.config.model ?? null, thinkingOptionId, effectiveThinkingOptionId, @@ -210,6 +212,7 @@ export function buildStoredAgentPayload( id: record.id, provider: record.provider, cwd: record.cwd, + ...(record.workspaceId ? { workspaceId: record.workspaceId } : {}), model: record.config?.model ?? null, thinkingOptionId: record.config?.thinkingOptionId ?? null, effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({ diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 92762e772..99d4528c1 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -36,6 +36,7 @@ const STORED_AGENT_SCHEMA = z.object({ id: z.string(), provider: z.string(), cwd: z.string(), + workspaceId: z.string().optional(), createdAt: z.string(), updatedAt: z.string(), lastActivityAt: z.string().optional(), diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 5bbff68e6..4e246d24b 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -3,7 +3,10 @@ import type pino from "pino"; import type { GitHubService } from "../../services/github-service.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; -import { archivePaseoWorktree } from "../paseo-worktree-archive-service.js"; +import { + type ActiveWorkspaceRef, + archivePaseoWorktree, +} from "../paseo-worktree-archive-service.js"; import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, @@ -27,14 +30,14 @@ interface CreateAgentLifecycleDispatchDependencies { createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn; archiveAgentForClose: (agentId: string) => Promise; resolveWorkspaceIdForCwd: (cwd: string) => Promise; + listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; emit: (message: SessionOutboundMessage) => void; emitAgentRemove: (agentId: string) => void; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; - isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; - killTerminalsUnderPath: (rootPath: string) => Promise; + killTerminalsForWorkspace: (workspaceId: string) => Promise; logger: pino.Logger; } @@ -209,12 +212,12 @@ export class CreateAgentLifecycleDispatch { agentManager: this.dependencies.agentManager, agentStorage: this.dependencies.agentStorage, resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd, + listActiveWorkspaces: this.dependencies.listActiveWorkspaces, archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving, - isPathWithinRoot: this.dependencies.isPathWithinRoot, - killTerminalsUnderPath: this.dependencies.killTerminalsUnderPath, + killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace, sessionLogger: this.dependencies.logger, }, { diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index 1f3fda1da..fb7b0ff5d 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -1,9 +1,44 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { expect, test, vi } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createTestAgentClients } from "../../test-utils/fake-agent-client.js"; +import { createProviderSnapshotManagerStub } from "../../test-utils/session-stubs.js"; +import { AgentManager } from "../agent-manager.js"; +import { AgentStorage } from "../agent-storage.js"; +import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createAgentCommand } from "./create.js"; import type { ManagedAgent } from "../agent-manager.js"; +const logger = createTestLogger(); + +function createRealAgentManager(storage: AgentStorage): AgentManager { + return new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); +} + +// Creates a worktree directory under repoRoot and reports it back as a fresh +// workspace so the command can stamp the agent with it (mirrors the production +// worktree service). +function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) { + const worktreePath = join(args.repoRoot, "worktree"); + mkdirSync(worktreePath, { recursive: true }); + return async (): Promise => + ({ + worktree: { worktreePath }, + intent: {}, + workspace: { workspaceId: args.createdWorkspaceId }, + repoRoot: args.repoRoot, + created: true, + setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} }, + }) as unknown as CreatePaseoWorktreeWorkflowResult; +} + test("session create forwards clientMessageId to the initial prompt run options", async () => { const snapshot = { id: "agent-1", @@ -44,3 +79,122 @@ test("session create forwards clientMessageId to the initial prompt run options" messageId: "msg-create-1", }); }); + +test("session create stamps the requested workspaceId when no worktree setup runs", async () => { + const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const agentManager = createRealAgentManager(storage); + + try { + const { snapshot } = await createAgentCommand( + { + agentManager, + agentStorage: storage, + logger, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + }, + { + kind: "session", + config: { provider: "codex", cwd: workdir }, + workspaceId: "ws-source", + labels: {}, + provisionalTitle: null, + explicitTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config) => ({ sessionConfig: config }), + }, + ); + + const stored = await storage.get(snapshot.id); + expect(stored?.workspaceId).toBe("ws-source"); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("session create stamps the new worktree's workspaceId when a setup continuation runs", async () => { + const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const agentManager = createRealAgentManager(storage); + + try { + const { snapshot } = await createAgentCommand( + { + agentManager, + agentStorage: storage, + logger, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + }, + { + kind: "session", + config: { provider: "codex", cwd: workdir }, + workspaceId: "ws-source", + labels: {}, + provisionalTitle: null, + explicitTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config) => ({ + sessionConfig: config, + setupContinuation: { kind: "agent", startAfterAgentCreate: () => {} }, + createdWorkspaceId: "ws-new-worktree", + }), + }, + ); + + const stored = await storage.get(snapshot.id); + expect(stored?.workspaceId).toBe("ws-new-worktree"); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("mcp create stamps the new worktree's workspaceId, not the parent's", async () => { + const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const agentManager = createRealAgentManager(storage); + const providerSnapshotManager = createProviderSnapshotManagerStub().manager; + + try { + const { snapshot: parent } = await createAgentCommand( + { agentManager, agentStorage: storage, logger, providerSnapshotManager }, + { + kind: "session", + config: { provider: "codex", cwd: workdir }, + workspaceId: "ws-parent", + labels: {}, + provisionalTitle: null, + explicitTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config) => ({ sessionConfig: config }), + }, + ); + + const { snapshot: child } = await createAgentCommand( + { + agentManager, + agentStorage: storage, + logger, + providerSnapshotManager, + createPaseoWorktree: fakeWorktreeCreator({ + repoRoot: workdir, + createdWorkspaceId: "ws-new-worktree", + }), + }, + { + kind: "mcp", + provider: "codex/gpt-5.4", + title: "child", + initialPrompt: "do the thing", + background: true, + notifyOnFinish: false, + callerAgentId: parent.id, + worktree: { worktreeName: "feature", baseBranch: "main" }, + }, + ); + + const storedChild = await storage.get(child.id); + expect(storedChild?.workspaceId).toBe("ws-new-worktree"); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index e636d2481..76b86c97c 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -35,6 +35,9 @@ import { export interface CreateAgentSessionWorktreeResult { sessionConfig: AgentSessionConfig; setupContinuation?: AgentWorktreeSetupContinuation; + // Set when this build created a fresh worktree workspace. The agent must be + // stamped with it so workspaceId-scoped archive can find the agent later. + createdWorkspaceId?: string; } interface CreateAgentCommandDependencies { @@ -132,6 +135,7 @@ interface AgentCreateOptions { initialPrompt?: string; env?: Record; initialTitle?: string | null; + workspaceId?: string; } export async function createAgentCommand( @@ -184,7 +188,7 @@ async function resolveSessionCreateAgent( input: CreateAgentFromSessionInput, ): Promise { const trimmedPrompt = input.initialPrompt?.trim(); - const { sessionConfig, setupContinuation } = await input.buildSessionConfig( + const { sessionConfig, setupContinuation, createdWorkspaceId } = await input.buildSessionConfig( input.config, input.git, input.worktreeName, @@ -208,6 +212,10 @@ async function resolveSessionCreateAgent( initialPrompt: trimmedPrompt, env: input.env, initialTitle: input.provisionalTitle, + // A legacy git/worktreeName worktree creates a fresh workspace, so the + // agent belongs to that workspace, not the source one (mirrors the MCP + // path). createdWorkspaceId is the freshly created worktree's workspace. + workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId, }, metadataInitialPrompt: trimmedPrompt, prompt: hasPromptContent ? prompt : undefined, @@ -239,13 +247,20 @@ async function resolveMcpCreateAgent( allowCustomCwd: input.callerContext?.allowCustomCwd ?? true, }) : expandUserPath(input.cwd ?? process.cwd()); - const { resolvedCwd, setupContinuation } = await resolveMcpCwd({ + const { resolvedCwd, setupContinuation, createdWorkspaceId } = await resolveMcpCwd({ dependencies, cwd, worktree: input.worktree, initialPrompt: input.initialPrompt, }); + // A child agent created in its parent's working tree belongs to the parent's + // workspace. When a new worktree is created the child lives in that fresh + // workspace, so it is stamped with the new worktree's workspaceId instead + // (mirrors the session path) — keeping the agent discoverable by + // workspaceId-scoped archive. + const workspaceId = setupContinuation ? createdWorkspaceId : parentAgent?.workspaceId; + const { modeId: resolvedMode, featureValues: resolvedFeatures } = await dependencies.providerSnapshotManager.resolveCreateConfig({ cwd: resolvedCwd, @@ -274,7 +289,13 @@ async function resolveMcpCreateAgent( thinkingOptionId: input.thinking, ...(resolvedFeatures ? { featureValues: resolvedFeatures } : {}), }, - createOptions: labels ? { labels } : undefined, + createOptions: + labels || workspaceId + ? { + ...(labels ? { labels } : {}), + ...(workspaceId ? { workspaceId } : {}), + } + : undefined, metadataInitialPrompt: trimmedPrompt, prompt: trimmedPrompt, explicitTitle: input.title.trim(), @@ -387,7 +408,11 @@ async function resolveMcpCwd(params: { cwd: string; initialPrompt: string; worktree: CreateAgentFromMcpInput["worktree"]; -}): Promise<{ resolvedCwd: string; setupContinuation?: AgentWorktreeSetupContinuation }> { +}): Promise<{ + resolvedCwd: string; + setupContinuation?: AgentWorktreeSetupContinuation; + createdWorkspaceId?: string; +}> { const { dependencies, worktree } = params; if (!worktree) { return { resolvedCwd: params.cwd }; @@ -443,6 +468,7 @@ async function resolveMcpCwd(params: { return { resolvedCwd: createdWorktree.worktree.worktreePath, setupContinuation: createdWorktree.setupContinuation, + createdWorkspaceId: createdWorktree.workspace.workspaceId, }; } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 6ea4b386b..0c743eeab 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; -import { realpathSync } from "node:fs"; +import { realpathSync, rmSync } from "node:fs"; import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { join, resolve as resolvePath } from "node:path"; import { tmpdir } from "node:os"; @@ -10,8 +10,9 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; -import type { AgentManager, ManagedAgent } from "./agent-manager.js"; -import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; +import { AgentManager, type ManagedAgent } from "./agent-manager.js"; +import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"; import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js"; import { @@ -1068,12 +1069,14 @@ describe("create_agent MCP tool", () => { expect(broadcasts[0]).toBe(createdWorkspaceIds[0]); expect(setupContinuations).toEqual(["agent"]); expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]); + // The agent is stamped with the freshly created worktree's workspaceId so + // workspaceId-scoped archive can find and tear it down later. expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ cwd: expect.stringContaining("agent-worktree"), }), undefined, - undefined, + { workspaceId: createdWorkspaceIds[0] }, ); } finally { await rm(tempDir, { recursive: true, force: true }); @@ -1329,7 +1332,7 @@ describe("create_agent MCP tool", () => { expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }), undefined, - undefined, + { workspaceId: "ws-pr-123" }, ); await new Promise((resolve) => setTimeout(resolve, 0)); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); @@ -1445,6 +1448,7 @@ describe("create_agent MCP tool", () => { "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >, resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"), + listActiveWorkspaces: vi.fn(async () => []), archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving, @@ -1528,6 +1532,7 @@ describe("create_agent MCP tool", () => { "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >, resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"), + listActiveWorkspaces: vi.fn(async () => []), archiveWorkspaceRecord: vi.fn(async () => undefined), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined), markWorkspaceArchiving: vi.fn(), @@ -1871,6 +1876,45 @@ describe("create_agent MCP tool", () => { ); }); + it("inherits the parent's workspaceId when an MCP child is created in the parent's working tree", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-workspace-inherit-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const agentManager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "create_agent"); + const result = await tool.handler({ + title: "Child", + provider: "codex/gpt-5.4", + initialPrompt: "Do work", + }); + + const childId = z.object({ agentId: z.string() }).parse(result.structuredContent).agentId; + const storedChild = await storage.get(childId); + expect(storedChild?.workspaceId).toBe("wks_parent"); + expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }); + it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 14f6b97e3..ac816bb68 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -30,7 +30,7 @@ import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { isStoredAgentProviderAvailable } from "../persistence-hooks.js"; import { - killTerminalsUnderPath, + killTerminalsForWorkspace, type ArchivePaseoWorktreeDependencies, } from "../paseo-worktree-archive-service.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; @@ -94,6 +94,7 @@ export interface AgentMcpServerOptions { "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >; resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"]; + listActiveWorkspaces?: ArchivePaseoWorktreeDependencies["listActiveWorkspaces"]; archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; @@ -2218,6 +2219,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom repoRoot, worktreePath, worktreeSlug, + // This tool's contract is to delete the worktree; on-disk removal still + // only happens when no sibling workspace references the directory. + deleteWorktreeFromDisk: true, }, ); if (!result.ok) { @@ -2410,6 +2414,9 @@ function archiveWorktreeDependencies( if (!options.resolveWorkspaceIdForCwd) { throw new Error("Workspace resolver is required to archive worktrees"); } + if (!options.listActiveWorkspaces) { + throw new Error("Active workspace lister is required to archive worktrees"); + } if (!options.emitWorkspaceUpdatesForWorkspaceIds) { throw new Error("Workspace update emitter is required to archive worktrees"); } @@ -2427,20 +2434,18 @@ function archiveWorktreeDependencies( agentManager: context.agentManager, agentStorage: context.agentStorage, resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd, + listActiveWorkspaces: options.listActiveWorkspaces, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: options.markWorkspaceArchiving, clearWorkspaceArchiving: options.clearWorkspaceArchiving, - isPathWithinRoot: isSameOrDescendantPath, - killTerminalsUnderPath: (rootPath: string) => - killTerminalsUnderPath( + killTerminalsForWorkspace: (workspaceId: string) => + killTerminalsForWorkspace( { terminalManager: context.terminalManager, - isPathWithinRoot: isSameOrDescendantPath, - killTrackedTerminal: () => {}, sessionLogger: context.logger, }, - rootPath, + workspaceId, ), sessionLogger: context.logger, }; diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 0f63f31ed..f5d2701f8 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -28,7 +28,7 @@ import { resolveACPModelSelection, summarizeACPRequestError, } from "./acp-agent.js"; -import * as treeKillModule from "../../../utils/tree-kill.js"; +import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js"; import { COPILOT_ALLOW_ALL_MODE_ID, COPILOT_MODES, @@ -86,7 +86,7 @@ interface ACPConfiguredOverrideInternals { applyConfiguredOverrides(): Promise; } -function createSession(): ACPAgentSession { +function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession { return new ACPAgentSession( { provider: "claude-acp", @@ -105,10 +105,36 @@ function createSession(): ACPAgentSession { supportsReasoningStream: true, supportsToolInvocations: true, }, + ...(terminateProcess ? { terminateProcess } : {}), }, ); } +// Typed substitute for the real tree-kill terminator. Records which child +// processes it was asked to terminate, so tests assert on observable state +// instead of spying on the production function. In "deferred" mode the +// terminations hang until releaseAll(), letting tests observe parallelism. +class FakeTerminator { + readonly terminated: TreeKillTarget[] = []; + private readonly pending: Array<() => void> = []; + + constructor(private readonly mode: "immediate" | "deferred" = "immediate") {} + + readonly terminate: ProcessTerminator = async (child) => { + this.terminated.push(child); + if (this.mode === "deferred") { + await new Promise((resolve) => this.pending.push(resolve)); + } + return "terminated"; + }; + + releaseAll(): void { + for (const resolve of this.pending.splice(0)) { + resolve(); + } + } +} + function createSessionWithConfig( config: { provider?: string; modeId?: string | null; model?: string | null } = {}, logger: ReturnType = createTestLogger(), @@ -145,33 +171,23 @@ function createTerminalChildStub(): ChildProcess { return child; } -function createProbeChildStub(order: string[]): ChildProcessWithoutNullStreams { - const child = new EventEmitter() as ChildProcessWithoutNullStreams; - child.stdin = { - destroy: vi.fn(() => { - order.push("stdin.destroy"); - }), - } as ChildProcessWithoutNullStreams["stdin"]; - child.stdout = { - destroy: vi.fn(() => { - order.push("stdout.destroy"); - }), - } as ChildProcessWithoutNullStreams["stdout"]; - child.stderr = { - destroy: vi.fn(() => { - order.push("stderr.destroy"); - }), - } as ChildProcessWithoutNullStreams["stderr"]; - child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"]; - return child; +function createDestroyableStream(): { destroyed: boolean; destroy: () => void } { + const stream = { + destroyed: false, + destroy() { + stream.destroyed = true; + }, + }; + return stream; } -function createDeferredTermination(): { promise: Promise<"terminated">; resolve: () => void } { - let resolveTermination: () => void = () => {}; - const promise = new Promise<"terminated">((resolve) => { - resolveTermination = () => resolve("terminated"); - }); - return { promise, resolve: resolveTermination }; +function createProbeChildStub(): ChildProcessWithoutNullStreams { + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + child.stdin = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stdin"]; + child.stdout = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stdout"]; + child.stderr = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stderr"]; + child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"]; + return child; } function selectConfigOption( @@ -2007,12 +2023,23 @@ describe("ACPAgentSession", () => { interface ACPCloseInternals { child: ChildProcess | null; - closed: boolean; connection: unknown; sessionId: string | null; - terminalEntries: Map; - subscribers: Map; - activeForegroundTurnId: string | null; +} + +async function startTerminal( + session: ACPAgentSession, + child: ChildProcess, + command = "sleep", +): Promise { + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child as ChildProcessWithoutNullStreams); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command, + args: ["60"], + }); + vi.restoreAllMocks(); + return terminal.terminalId; } describe("ACPAgentSession close() tree-kill", () => { @@ -2020,128 +2047,82 @@ describe("ACPAgentSession close() tree-kill", () => { vi.restoreAllMocks(); }); - test("close() uses terminateWithTreeKill for the main child process", async () => { - const terminateWithTreeKill = vi - .spyOn(treeKillModule, "terminateWithTreeKill") - .mockResolvedValue("terminated"); - const session = createSession(); + test("close() terminates the main child process via the process tree", async () => { + const terminator = new FakeTerminator(); + const session = createSession(terminator.terminate); const internals = asInternals(session); const child = createTerminalChildStub(); + // The ACP host process is set by the live connect handshake, which has no + // in-test seam; everything else is driven through the public API. internals.child = child; internals.connection = null; internals.sessionId = null; await session.close(); - expect(terminateWithTreeKill).toHaveBeenCalledWith(child, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }); + expect(terminator.terminated).toContain(child); expect(child.kill).not.toHaveBeenCalled(); }); - test("close() uses terminateWithTreeKill for terminal child processes", async () => { - const terminateWithTreeKill = vi - .spyOn(treeKillModule, "terminateWithTreeKill") - .mockResolvedValue("terminated"); - const session = createSession(); - const internals = asInternals(session); + test("close() terminates running terminal child processes", async () => { + const terminator = new FakeTerminator(); + const session = createSession(terminator.terminate); const terminalChild = createTerminalChildStub(); - internals.terminalEntries = new Map([["terminal-1", { child: terminalChild, exit: null }]]); - internals.child = null; - internals.connection = null; - internals.sessionId = null; + await startTerminal(session, terminalChild); await session.close(); - expect(terminateWithTreeKill).toHaveBeenCalledWith(terminalChild, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }); + expect(terminator.terminated).toContain(terminalChild); expect(terminalChild.kill).not.toHaveBeenCalled(); }); test("close() terminates terminal child processes in parallel", async () => { - const resolvers: Array<() => void> = []; - const terminateWithTreeKill = vi - .spyOn(treeKillModule, "terminateWithTreeKill") - .mockImplementation(() => { - const termination = createDeferredTermination(); - resolvers.push(termination.resolve); - return termination.promise; - }); - const session = createSession(); - const internals = asInternals(session); + const terminator = new FakeTerminator("deferred"); + const session = createSession(terminator.terminate); - const firstTerminalChild = createTerminalChildStub(); - const secondTerminalChild = createTerminalChildStub(); - internals.terminalEntries = new Map([ - ["terminal-1", { child: firstTerminalChild, exit: null }], - ["terminal-2", { child: secondTerminalChild, exit: null }], - ]); - internals.child = null; - internals.connection = null; - internals.sessionId = null; + const firstChild = createTerminalChildStub(); + const secondChild = createTerminalChildStub(); + await startTerminal(session, firstChild); + await startTerminal(session, secondChild); const close = session.close(); await Promise.resolve(); - expect(terminateWithTreeKill).toHaveBeenCalledTimes(2); + expect(terminator.terminated).toEqual([firstChild, secondChild]); - for (const resolve of resolvers) { - resolve(); - } + terminator.releaseAll(); await close; }); - test("killTerminal uses terminateWithTreeKill instead of direct SIGTERM", async () => { - const terminateWithTreeKill = vi - .spyOn(treeKillModule, "terminateWithTreeKill") - .mockResolvedValue("terminated"); - const session = createSession(); - const internals = asInternals(session); + test("killTerminal terminates the terminal process tree without a direct SIGTERM", async () => { + const terminator = new FakeTerminator(); + const session = createSession(terminator.terminate); const child = createTerminalChildStub(); - internals.terminalEntries = new Map([["terminal-1", { child, exit: null }]]); + const terminalId = await startTerminal(session, child); - await session.killTerminal({ sessionId: "session-1", terminalId: "terminal-1" }); + await session.killTerminal({ sessionId: "session-1", terminalId }); - expect(terminateWithTreeKill).toHaveBeenCalledWith(child, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }); + expect(terminator.terminated).toContain(child); expect(child.kill).not.toHaveBeenCalled(); }); - test("releaseTerminal uses terminateWithTreeKill before removing a running terminal", async () => { - const terminateWithTreeKill = vi - .spyOn(treeKillModule, "terminateWithTreeKill") - .mockResolvedValue("terminated"); + test("releaseTerminal terminates and removes a running terminal", async () => { + const terminator = new FakeTerminator(); + const session = createSession(terminator.terminate); + const child = createTerminalChildStub(); - vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); - const session = createSession(); + const terminalId = await startTerminal(session, child); - const terminal = await session.createTerminal({ - sessionId: "session-1", - command: "sleep", - args: ["60"], - }); + await session.releaseTerminal({ sessionId: "session-1", terminalId }); - await session.releaseTerminal({ - sessionId: "session-1", - terminalId: terminal.terminalId, - }); - - expect(terminateWithTreeKill).toHaveBeenCalledWith(child, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }); + expect(terminator.terminated).toContain(child); expect(child.kill).not.toHaveBeenCalled(); - await expect( - session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), - ).rejects.toThrow(`Unknown terminal '${terminal.terminalId}'`); + await expect(session.terminalOutput({ sessionId: "session-1", terminalId })).rejects.toThrow( + `Unknown terminal '${terminalId}'`, + ); }); }); @@ -2150,15 +2131,9 @@ describe("ACPAgentClient probe cleanup", () => { vi.restoreAllMocks(); }); - test("signals the probe process tree before destroying stdio", async () => { - const order: string[] = []; - const terminateWithTreeKill = vi - .spyOn(treeKillModule, "terminateWithTreeKill") - .mockImplementation(async () => { - order.push("tree-kill"); - return "terminated"; - }); - const child = createProbeChildStub(order); + test("terminates the probe process tree and closes its stdio", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); class TestACPAgentClient extends ACPAgentClient { protected override async spawnProcess(): Promise { @@ -2181,14 +2156,14 @@ describe("ACPAgentClient probe cleanup", () => { logger: createTestLogger(), defaultCommand: ["claude", "--acp"], defaultModes: [], + terminateProcess: terminator.terminate, }); await client.listModels({ cwd: "/tmp/acp-models", force: false }); - expect(terminateWithTreeKill).toHaveBeenCalledWith(child, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }); - expect(order).toEqual(["tree-kill", "stdin.destroy", "stdout.destroy", "stderr.destroy"]); + expect(terminator.terminated).toContain(child); + expect(child.stdin.destroyed).toBe(true); + expect(child.stdout.destroyed).toBe(true); + expect(child.stderr.destroyed).toBe(true); }); }); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 3c2ab9b28..662acf300 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { Readable, Writable } from "node:stream"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; +import type { ProcessTerminator } from "../../../utils/tree-kill.js"; import type { ReadableStream as NodeReadableStream, WritableStream as NodeWritableStream, @@ -331,6 +332,7 @@ interface ACPAgentClientOptions { capabilities?: AgentCapabilityFlags; waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; + terminateProcess?: ProcessTerminator; } interface ACPAgentSessionOptions { @@ -359,6 +361,7 @@ interface ACPAgentSessionOptions { launchEnv?: Record; waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; + terminateProcess?: ProcessTerminator; } export interface SpawnedACPProcess { @@ -606,9 +609,11 @@ export class ACPAgentClient implements AgentClient { ) => Promise; private readonly waitForInitialCommands: boolean; private readonly initialCommandsWaitTimeoutMs: number; + protected readonly terminateProcess: ProcessTerminator; constructor(options: ACPAgentClientOptions) { this.provider = options.provider; + this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill; this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES; this.logger = options.logger.child({ module: "agent", @@ -868,7 +873,7 @@ export class ACPAgentClient implements AgentClient { ]), ); } catch (error) { - await terminateChildProcess(child, 2_000); + await terminateChildProcess(child, 2_000, this.terminateProcess); throw error; } finally { if (timeout) { @@ -906,7 +911,7 @@ export class ACPAgentClient implements AgentClient { // No active session to close here; ignore capability. } } finally { - await terminateChildProcess(probe.child, 2_000); + await terminateChildProcess(probe.child, 2_000, this.terminateProcess); } } @@ -1016,9 +1021,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { private historyPending = false; private replayingHistory = false; private bootstrapThreadEventPending = false; + private readonly terminateProcess: ProcessTerminator; constructor(config: AgentSessionConfig, options: ACPAgentSessionOptions) { this.provider = options.provider; + this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill; this.capabilities = options.capabilities; this.logger = options.logger.child({ module: "agent", provider: options.provider }); this.runtimeSettings = options.runtimeSettings; @@ -1667,7 +1674,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { } const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) => - terminateWithTreeKill(terminal.child, { + this.terminateProcess(terminal.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000, }), @@ -1676,7 +1683,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.terminalEntries.clear(); if (this.child) { - await terminateWithTreeKill(this.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); + await this.terminateProcess(this.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } this.subscribers.clear(); @@ -1858,7 +1865,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise { const entry = this.getTerminalEntry(params.terminalId); if (!entry.exit) { - await terminateWithTreeKill(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); + await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } this.terminalEntries.delete(params.terminalId); } @@ -1866,7 +1873,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async killTerminal(params: KillTerminalRequest): Promise> { const entry = this.getTerminalEntry(params.terminalId); if (!entry.exit) { - await terminateWithTreeKill(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); + await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } return {}; } @@ -2892,9 +2899,10 @@ function coerceSessionConfigMetadata( async function terminateChildProcess( child: ChildProcessWithoutNullStreams, timeoutMs: number, + terminate: ProcessTerminator, ): Promise { try { - await terminateWithTreeKill(child, { gracefulTimeoutMs: timeoutMs, forceTimeoutMs: timeoutMs }); + await terminate(child, { gracefulTimeoutMs: timeoutMs, forceTimeoutMs: timeoutMs }); } finally { child.stdin.destroy(); child.stdout.destroy(); diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 300c0ea46..78fbf01a7 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -94,6 +94,7 @@ function createHarness(overrides?: { agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"], terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"], resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"), + listActiveWorkspaces: vi.fn(async () => []), archiveWorkspaceRecord: vi.fn(), markWorkspaceArchiving: vi.fn(), clearWorkspaceArchiving: vi.fn(), @@ -114,8 +115,7 @@ function createHarness(overrides?: { const deps: ArchiveIfSafeDependencies = { archivePaseoWorktree, isPaseoOwnedWorktreeCwd, - killTerminalsUnderPath: vi.fn(), - isPathWithinRoot: vi.fn(() => true), + killTerminalsForWorkspace: vi.fn(), }; const log = createLogger(); const inFlight = new Set(); @@ -289,6 +289,10 @@ describe("archiveIfSafe", () => { targetPath: CWD, repoRoot: "/tmp/repo", worktreesRoot: WORKTREES_ROOT, + // A merged worktree is the last reference to its directory; remove it from + // disk so merged worktrees do not accumulate. Sibling protection still + // happens inside the service (last-reference + ownership gated). + deleteWorktreeFromDisk: true, requestId: "auto-archive-on-merge", }, ); diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 6c84dc4ee..762344063 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -3,8 +3,11 @@ import type { Logger } from "pino"; import type { AgentManager } from "../agent/agent-manager.js"; import type { AgentStorage } from "../agent/agent-storage.js"; import type { DaemonConfigStore } from "../daemon-config-store.js"; -import { archivePaseoWorktree, killTerminalsUnderPath } from "../paseo-worktree-archive-service.js"; -import { isSameOrDescendantPath } from "../path-utils.js"; +import { + type ActiveWorkspaceRef, + archivePaseoWorktree, + killTerminalsForWorkspace, +} from "../paseo-worktree-archive-service.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitServiceImpl, @@ -23,6 +26,7 @@ export interface AutoArchiveArchiveOptions { agentStorage: AgentStorage; terminalManager: TerminalManager; resolveWorkspaceIdForCwd: (cwd: string) => Promise; + listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; @@ -32,15 +36,13 @@ export interface AutoArchiveArchiveOptions { export interface ArchiveIfSafeDependencies { archivePaseoWorktree: typeof archivePaseoWorktree; isPaseoOwnedWorktreeCwd: typeof isPaseoOwnedWorktreeCwd; - killTerminalsUnderPath: typeof killTerminalsUnderPath; - isPathWithinRoot: typeof isSameOrDescendantPath; + killTerminalsForWorkspace: typeof killTerminalsForWorkspace; } const defaultDependencies: ArchiveIfSafeDependencies = { archivePaseoWorktree, isPaseoOwnedWorktreeCwd, - killTerminalsUnderPath, - isPathWithinRoot: isSameOrDescendantPath, + killTerminalsForWorkspace, }; export async function archiveIfSafe(input: { @@ -104,20 +106,18 @@ export async function archiveIfSafe(input: { agentManager: options.agentManager, agentStorage: options.agentStorage, resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd, + listActiveWorkspaces: options.listActiveWorkspaces, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: options.markWorkspaceArchiving, clearWorkspaceArchiving: options.clearWorkspaceArchiving, - isPathWithinRoot: deps.isPathWithinRoot, - killTerminalsUnderPath: (rootPath) => - deps.killTerminalsUnderPath( + killTerminalsForWorkspace: (workspaceId) => + deps.killTerminalsForWorkspace( { terminalManager: options.terminalManager, - isPathWithinRoot: deps.isPathWithinRoot, - killTrackedTerminal: () => {}, sessionLogger: log, }, - rootPath, + workspaceId, ), sessionLogger: log, }, @@ -126,6 +126,10 @@ export async function archiveIfSafe(input: { repoRoot: ownership.repoRoot ?? null, worktreesRoot: ownership.worktreeRoot, worktreesBaseRoot: options.worktreesRoot, + // Last-reference + Paseo-ownership gated inside the service, so sibling + // workspaces sharing the directory stay protected. Removing the merged + // worktree off disk prevents merged worktrees from accumulating. + deleteWorktreeFromDisk: true, requestId: "auto-archive-on-merge", }, ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 985863bf5..b82991e32 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -113,6 +113,7 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; +import type { ActiveWorkspaceRef } from "./paseo-worktree-archive-service.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js"; @@ -745,12 +746,21 @@ export async function createPaseoDaemon( await archivePersistedWorkspaceRecord({ workspaceId, workspaceRegistry, - projectRegistry, }); }; const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise => { return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list()); }; + const listActiveWorkspacesExternal = async (): Promise => { + const workspaces = await workspaceRegistry.list(); + return workspaces + .filter((workspace) => !workspace.archivedAt) + .map((workspace) => ({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + kind: workspace.kind, + })); + }; const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { const workspaceIdList = Array.from(workspaceIds); for (const session of wsServer?.listActiveSessions() ?? []) { @@ -786,6 +796,7 @@ export async function createPaseoDaemon( terminalManager, logger, resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal, + listActiveWorkspaces: listActiveWorkspacesExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, @@ -809,6 +820,7 @@ export async function createPaseoDaemon( github, workspaceGitService, resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal, + listActiveWorkspaces: listActiveWorkspacesExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts new file mode 100644 index 000000000..7e8eaddf6 --- /dev/null +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -0,0 +1,94 @@ +import { test, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { DaemonClient } from "./test-utils/index.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; + +// The daemon-level workspace contract that `paseo run` depends on: each +// local-backed createWorkspace for a cwd mints a fresh, distinct workspace, +// createAgent stamps the agent with the workspaceId it is given, and attaching +// to an existing workspace by id creates no new record. The CLI's own flag +// precedence (--workspace > $PASEO_WORKSPACE_ID > --worktree > bare) is covered +// in packages/cli/src/commands/agent/run.test.ts; this test only proves the +// daemon behaviors the CLI builds on. + +async function workspaceIds(client: DaemonClient): Promise> { + const workspaces = await client.fetchWorkspaces(); + return new Set(workspaces.entries.map((entry) => entry.id)); +} + +async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise { + const result = await client.createWorkspace({ backing: "local", cwd }); + if (!result.workspace) { + throw new Error(result.error ?? "Failed to create workspace"); + } + return result.workspace.id; +} + +test("daemon mints a distinct local workspace per run and stamps agents by id", async () => { + const daemon = await createTestPaseoDaemon(); + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-cwd-")); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + + // A bare run mints a fresh local workspace for the cwd, then the agent is + // stamped with that workspace's id. + const firstWorkspaceId = await mintLocalWorkspace(client, cwd); + + const firstAgent = await client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + workspaceId: firstWorkspaceId, + title: "First run agent", + }); + expect(firstAgent.workspaceId).toBe(firstWorkspaceId); + expect(await workspaceIds(client)).toContain(firstWorkspaceId); + + const fetchedFirst = await client.fetchAgent(firstAgent.id); + expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId); + + // A second bare run in the SAME cwd mints a DISTINCT workspace; each run + // owns its own workspace rather than reattaching to the first. + const secondWorkspaceId = await mintLocalWorkspace(client, cwd); + expect(secondWorkspaceId).not.toBe(firstWorkspaceId); + + const secondAgent = await client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + workspaceId: secondWorkspaceId, + title: "Second run agent", + }); + expect(secondAgent.workspaceId).toBe(secondWorkspaceId); + expect(secondAgent.workspaceId).not.toBe(firstAgent.workspaceId); + + const idsAfterTwoMints = await workspaceIds(client); + expect(idsAfterTwoMints).toContain(firstWorkspaceId); + expect(idsAfterTwoMints).toContain(secondWorkspaceId); + + // Attaching to an existing workspace by id (how --workspace and + // $PASEO_WORKSPACE_ID land a run) creates no new workspace record: the + // agent lands in the named workspace and the workspace set is unchanged. + const idsBeforeAttach = await workspaceIds(client); + const attachedAgent = await client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + workspaceId: firstWorkspaceId, + title: "Attached agent", + }); + expect(attachedAgent.workspaceId).toBe(firstWorkspaceId); + expect(await workspaceIds(client)).toEqual(idsBeforeAttach); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 180000); diff --git a/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts index e7907bd88..d68678383 100644 --- a/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts @@ -289,8 +289,8 @@ describe("daemon checkout PR merge loop", () => { }); expect(archiveResult.error).toBeNull(); expect(archiveResult.success).toBe(true); - expect(existsSync(worktree.worktreePath)).toBe(false); - worktreePath = null; + // Archiving leaves the worktree on disk; disk deletion is a separate step. + expect(existsSync(worktree.worktreePath)).toBe(true); const remainingAgents = await ctx.client.fetchAgents(); expect(remainingAgents.entries.some((entry) => entry.agent.id === agent.id)).toBe(false); diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index 235578fe6..9fdc90635 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -255,13 +255,15 @@ describe("daemon checkout ship loop", () => { expect(archiveResult.error).toBeNull(); expect(archiveResult.success).toBe(true); + // Archiving removes the agent from the active list but leaves the + // worktree on disk — disk deletion is a separate, explicit step. const worktreeListAfter = await ctx.client.getPaseoWorktreeList({ cwd: repoDir, }); expect( worktreeListAfter.worktrees.some((entry) => entry.worktreePath === worktree.worktreePath), - ).toBe(false); - expect(existsSync(worktree.worktreePath)).toBe(false); + ).toBe(true); + expect(existsSync(worktree.worktreePath)).toBe(true); const remainingAgents = await ctx.client.fetchAgents(); expect(remainingAgents.entries.some((entry) => entry.agent.id === agent.id)).toBe(false); diff --git a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts new file mode 100644 index 000000000..c5b20afe2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts @@ -0,0 +1,121 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + type PersistedProjectRecord, +} from "../workspace-registry.js"; + +const cleanupPaths = new Set(); +const cleanupDaemons = new Set(); +const cleanupClients = new Set(); + +afterEach(async () => { + await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined))); + cleanupClients.clear(); + await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined))); + cleanupDaemons.clear(); + await Promise.all( + Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })), + ); + cleanupPaths.clear(); +}); + +// Unit 2.2: a project persists as a first-class empty project after its last +// workspace is archived, and is exposed to clients via fetch_workspaces. +test("archiving the last workspace leaves the project as an empty project parent", async () => { + const previousSupervised = process.env.PASEO_SUPERVISED; + process.env.PASEO_SUPERVISED = "0"; + try { + const repoRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-empty-project-repo-"))); + const paseoHomeRoot = realpathSync( + mkdtempSync(path.join(os.tmpdir(), "paseo-empty-project-home-")), + ); + cleanupPaths.add(repoRoot); + cleanupPaths.add(paseoHomeRoot); + + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@getpaseo.dev'", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.name 'Paseo Test'", { cwd: repoRoot, stdio: "pipe" }); + writeFileSync(path.join(repoRoot, "README.md"), "# repo\n", "utf8"); + execSync("git add README.md", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgSign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" }); + + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const projectsPath = path.join(paseoHome, "projects", "projects.json"); + const workspacesPath = path.join(paseoHome, "projects", "workspaces.json"); + const timestamp = "2026-04-24T09:46:43.146Z"; + + await mkdir(path.dirname(projectsPath), { recursive: true }); + await writeFile( + projectsPath, + JSON.stringify( + [ + createPersistedProjectRecord({ + projectId: repoRoot, + rootPath: repoRoot, + kind: "git", + displayName: "repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ], + null, + 2, + ), + "utf8", + ); + await writeFile( + workspacesPath, + JSON.stringify( + [ + createPersistedWorkspaceRecord({ + workspaceId: repoRoot, + projectId: repoRoot, + cwd: repoRoot, + kind: "local_checkout", + displayName: "main", + createdAt: timestamp, + updatedAt: timestamp, + }), + ], + null, + 2, + ), + "utf8", + ); + + const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false }); + cleanupDaemons.add(daemon); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanupClients.add(client); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } }); + + const beforeArchive = await client.fetchWorkspaces(); + expect(beforeArchive.entries.map((entry) => entry.id)).toContain(repoRoot); + expect(beforeArchive.emptyProjects.map((project) => project.projectId)).not.toContain(repoRoot); + + await client.archiveWorkspace(repoRoot); + + const afterArchive = await client.fetchWorkspaces(); + expect(afterArchive.entries.map((entry) => entry.id)).not.toContain(repoRoot); + expect(afterArchive.emptyProjects.map((project) => project.projectId)).toContain(repoRoot); + + const persistedProjects = JSON.parse( + await readFile(projectsPath, "utf8"), + ) as PersistedProjectRecord[]; + expect( + persistedProjects.find((project) => project.projectId === repoRoot)?.archivedAt, + ).toBeNull(); + } finally { + process.env.PASEO_SUPERVISED = previousSupervised; + } +}, 30_000); diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 9693a16e1..09c29f076 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -684,7 +684,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as rmSync(cwd, { recursive: true, force: true }); }, 60000); -test("archives worktree by running teardown commands and shutting down worktree terminals", async () => { +test("archiving a worktree shuts down its terminals but leaves the worktree on disk", async () => { const repoRoot = tmpCwd(); const { execSync } = await import("child_process"); @@ -766,9 +766,11 @@ test("archives worktree by running teardown commands and shutting down worktree expect(archive.success).toBe(true); expect(archive.removedAgents).toContain(agent.id); - expect(existsSync(agent.cwd)).toBe(false); - expect(existsSync(teardownMarkerPath)).toBe(true); - expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd); + // Archiving tears down the workspace's terminals but never deletes the + // worktree from disk and never runs teardown commands — on-disk removal is a + // separate, explicit step. + expect(existsSync(agent.cwd)).toBe(true); + expect(existsSync(teardownMarkerPath)).toBe(false); const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); expect(afterArchiveDirectories).not.toContain(agent.cwd); diff --git a/packages/server/src/server/paseo-worktree-archive-service.ts b/packages/server/src/server/paseo-worktree-archive-service.ts index 337e00992..e889964ec 100644 --- a/packages/server/src/server/paseo-worktree-archive-service.ts +++ b/packages/server/src/server/paseo-worktree-archive-service.ts @@ -1,3 +1,5 @@ +import { resolve } from "node:path"; + import type { Logger } from "pino"; import type { AgentManager } from "./agent/agent-manager.js"; @@ -6,11 +8,18 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { GitHubService } from "../services/github-service.js"; import { deletePaseoWorktree, + isPaseoOwnedWorktreeCwd, resolvePaseoWorktreeRootForCwd, WorktreeTeardownError, } from "../utils/worktree.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; +export interface ActiveWorkspaceRef { + workspaceId: string; + cwd: string; + kind?: "local_checkout" | "worktree" | "directory"; +} + export interface ArchivePaseoWorktreeDependencies { paseoHome?: string; worktreesRoot?: string; @@ -19,23 +28,34 @@ export interface ArchivePaseoWorktreeDependencies { agentManager: Pick; agentStorage: Pick; resolveWorkspaceIdForCwd: (cwd: string) => Promise; + // Active (non-archived) workspaces, used to decide whether the workspace being + // archived is the last reference to its backing worktree directory, and to + // break a same-cwd tie in favor of the worktree-kind record when archiving by + // path (no explicit workspaceId). + listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; - isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; - killTerminalsUnderPath: (rootPath: string) => Promise; + killTerminalsForWorkspace: (workspaceId: string) => Promise; sessionLogger?: Logger; } -export interface KillTerminalsUnderPathDependencies { - isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; - killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void; +export interface KillTerminalsForWorkspaceDependencies { detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void; sessionLogger: Logger; terminalManager: TerminalManager | null; } +// Archiving is scoped to a single workspace RECORD (by workspaceId), not to a +// directory. A directory can back multiple workspaces (Model B), so cwd-scoped +// teardown would destroy a sibling workspace's agents and terminals. We tear +// down only the agents and terminals owned by the target workspaceId. +// +// On-disk worktree removal is opt-in (deleteWorktreeFromDisk) and only happens +// when this workspace is the LAST active reference to a Paseo-owned worktree +// directory. If a sibling workspace still references the directory, it is kept. +// Local checkouts are never deleted. export async function archivePaseoWorktree( dependencies: ArchivePaseoWorktreeDependencies, options: { @@ -43,6 +63,8 @@ export async function archivePaseoWorktree( repoRoot: string | null; worktreesRoot?: string; worktreesBaseRoot?: string; + workspaceId?: string; + deleteWorktreeFromDisk?: boolean; requestId: string; }, ): Promise { @@ -55,84 +77,31 @@ export async function archivePaseoWorktree( targetPath = resolvedWorktree.worktreePath; } - const archivedAgents = new Set(); - const affectedWorkspaceCwds = new Set([targetPath]); - - const liveAgents = dependencies.agentManager - .listAgents() - .filter((agent) => dependencies.isPathWithinRoot(targetPath, agent.cwd)); - for (const agent of liveAgents) { - archivedAgents.add(agent.id); - affectedWorkspaceCwds.add(agent.cwd); - } - - let storedRecords: StoredAgentRecord[] = []; - try { - storedRecords = await dependencies.agentStorage.list(); - } catch (error) { + // A directory can back multiple workspaces (Model B), so resolving the target + // by cwd alone picks an arbitrary record. Prefer the explicit workspaceId the + // caller supplied; otherwise resolve by path, breaking a same-cwd tie toward + // the worktree-kind record. + const targetWorkspaceId = + options.workspaceId ?? (await resolveTargetWorkspaceId(dependencies, targetPath)); + if (!targetWorkspaceId) { dependencies.sessionLogger?.warn( - { err: error, targetPath }, - "Failed to list stored agents during worktree archive; continuing", + { targetPath }, + "Skipping workspace archive for unregistered directory", ); - } - const liveAgentIds = new Set(liveAgents.map((agent) => agent.id)); - const matchingStoredRecords = storedRecords.filter((record) => - dependencies.isPathWithinRoot(targetPath, record.cwd), - ); - for (const record of matchingStoredRecords) { - archivedAgents.add(record.id); - affectedWorkspaceCwds.add(record.cwd); + return []; } - const affectedWorkspaceIdList = await resolveAffectedWorkspaceIds( - dependencies, - affectedWorkspaceCwds, - ); + const affectedWorkspaceIdList = [targetWorkspaceId]; dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString()); + let archivedAgents = new Set(); + try { await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList); - const archivedAt = new Date().toISOString(); - const archiveResults = await Promise.allSettled([ - ...liveAgents.map((agent) => dependencies.agentManager.archiveAgent(agent.id)), - ...matchingStoredRecords - .filter((record) => !liveAgentIds.has(record.id) && !record.archivedAt) - .map((record) => dependencies.agentManager.archiveSnapshot(record.id, archivedAt)), - dependencies.killTerminalsUnderPath(targetPath), - ]); + archivedAgents = await archiveWorkspaceContents(dependencies, targetWorkspaceId); - for (const result of archiveResults) { - if (result.status === "rejected") { - dependencies.sessionLogger?.warn( - { err: result.reason, targetPath }, - "Worktree archive teardown step failed; continuing", - ); - } - } - - let teardownError: WorktreeTeardownError | null = null; - try { - await deletePaseoWorktree({ - cwd: options.repoRoot, - worktreePath: targetPath, - worktreesRoot: options.worktreesRoot, - paseoHome: dependencies.paseoHome, - worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot, - }); - } catch (error) { - if (error instanceof WorktreeTeardownError) { - teardownError = error; - dependencies.sessionLogger?.warn( - { err: error, targetPath }, - "Worktree teardown failed during archive; archiving workspace record anyway", - ); - } else { - throw error; - } - } - - if (!teardownError && options.repoRoot) { + if (options.repoRoot) { try { await dependencies.workspaceGitService.getSnapshot(options.repoRoot, { force: true, @@ -146,27 +115,25 @@ export async function archivePaseoWorktree( } } - for (const cwd of affectedWorkspaceCwds) { - dependencies.github.invalidate({ cwd }); + dependencies.github.invalidate({ cwd: targetPath }); + + try { + await dependencies.archiveWorkspaceRecord(targetWorkspaceId); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, workspaceId: targetWorkspaceId }, + "Failed to archive workspace record", + ); } - await Promise.all( - affectedWorkspaceIdList.map(async (workspaceId) => { - try { - await dependencies.archiveWorkspaceRecord(workspaceId); - } catch (error) { - dependencies.sessionLogger?.warn( - { err: error, workspaceId }, - teardownError - ? "Failed to archive workspace record after teardown failed" - : "Failed to archive workspace record; worktree FS already removed", - ); - } - }), - ); - - if (teardownError) { - throw teardownError; + if (options.deleteWorktreeFromDisk) { + await deleteWorktreeFromDiskIfLastReference(dependencies, { + targetPath, + targetWorkspaceId, + repoRoot: options.repoRoot, + worktreesRoot: options.worktreesRoot, + worktreesBaseRoot: options.worktreesBaseRoot, + }); } } finally { dependencies.clearWorkspaceArchiving(affectedWorkspaceIdList); @@ -176,31 +143,147 @@ export async function archivePaseoWorktree( return Array.from(archivedAgents); } -async function resolveAffectedWorkspaceIds( +// Resolves the workspace record to archive when no explicit workspaceId was +// supplied. When several active workspaces share the exact target cwd, prefer +// the worktree-kind record so archiving-by-path tears down the worktree rather +// than an arbitrary sibling. Falls back to the path-based resolver otherwise. +async function resolveTargetWorkspaceId( dependencies: Pick< ArchivePaseoWorktreeDependencies, - "resolveWorkspaceIdForCwd" | "sessionLogger" + "resolveWorkspaceIdForCwd" | "listActiveWorkspaces" >, - cwds: Iterable, -): Promise { - const workspaceIds = new Set(); - for (const cwd of cwds) { - const workspaceId = await dependencies.resolveWorkspaceIdForCwd(cwd); - if (!workspaceId) { - dependencies.sessionLogger?.warn( - { cwd }, - "Skipping workspace archive update for unregistered directory", - ); - continue; - } - workspaceIds.add(workspaceId); + targetPath: string, +): Promise { + const targetDir = resolve(targetPath); + const exactMatches = (await dependencies.listActiveWorkspaces()).filter( + (workspace) => resolve(workspace.cwd) === targetDir, + ); + const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree"); + if (worktreeMatch) { + return worktreeMatch.workspaceId; } - return Array.from(workspaceIds); + return dependencies.resolveWorkspaceIdForCwd(targetPath); } -export async function killTerminalsUnderPath( - dependencies: KillTerminalsUnderPathDependencies, - rootPath: string, +export type ArchiveWorkspaceContentsDependencies = Pick< + ArchivePaseoWorktreeDependencies, + "agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger" +>; + +// Tears down everything OWNED by a single workspace record: its live agents, +// its persisted-but-not-running agent snapshots, and its terminals. Scoped by +// workspaceId so a sibling workspace sharing the same directory is untouched. +// Returns the set of archived agent ids. +export async function archiveWorkspaceContents( + dependencies: ArchiveWorkspaceContentsDependencies, + workspaceId: string, +): Promise> { + const archivedAgents = new Set(); + + const liveAgents = dependencies.agentManager + .listAgents() + .filter((agent) => agent.workspaceId === workspaceId); + for (const agent of liveAgents) { + archivedAgents.add(agent.id); + } + + let storedRecords: StoredAgentRecord[] = []; + try { + storedRecords = await dependencies.agentStorage.list(); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, workspaceId }, + "Failed to list stored agents during workspace archive; continuing", + ); + } + const liveAgentIds = new Set(liveAgents.map((agent) => agent.id)); + const matchingStoredRecords = storedRecords.filter( + (record) => record.workspaceId === workspaceId, + ); + for (const record of matchingStoredRecords) { + archivedAgents.add(record.id); + } + + const archivedAt = new Date().toISOString(); + const archiveResults = await Promise.allSettled([ + ...liveAgents.map((agent) => dependencies.agentManager.archiveAgent(agent.id)), + ...matchingStoredRecords + .filter((record) => !liveAgentIds.has(record.id) && !record.archivedAt) + .map((record) => dependencies.agentManager.archiveSnapshot(record.id, archivedAt)), + dependencies.killTerminalsForWorkspace(workspaceId), + ]); + + for (const result of archiveResults) { + if (result.status === "rejected") { + dependencies.sessionLogger?.warn( + { err: result.reason, workspaceId }, + "Workspace archive teardown step failed; continuing", + ); + } + } + + return archivedAgents; +} + +// Removes the worktree directory from disk, but only when the just-archived +// workspace was the last active reference to a Paseo-owned worktree. A directory +// can back multiple workspaces (Model B), so a sibling still referencing it must +// keep the directory. Local checkouts are never Paseo-owned and so never deleted. +async function deleteWorktreeFromDiskIfLastReference( + dependencies: Pick< + ArchivePaseoWorktreeDependencies, + "paseoHome" | "worktreesRoot" | "listActiveWorkspaces" | "github" | "sessionLogger" + >, + options: { + targetPath: string; + targetWorkspaceId: string; + repoRoot: string | null; + worktreesRoot?: string; + worktreesBaseRoot?: string; + }, +): Promise { + const ownership = await isPaseoOwnedWorktreeCwd(options.targetPath, { + paseoHome: dependencies.paseoHome, + worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot, + }); + if (!ownership.allowed) { + return; + } + + const targetDir = resolve(options.targetPath); + const activeWorkspaces = await dependencies.listActiveWorkspaces(); + const siblingStillReferences = activeWorkspaces.some( + (workspace) => + workspace.workspaceId !== options.targetWorkspaceId && resolve(workspace.cwd) === targetDir, + ); + if (siblingStillReferences) { + return; + } + + try { + await deletePaseoWorktree({ + cwd: options.repoRoot, + worktreePath: options.targetPath, + worktreesRoot: options.worktreesRoot, + paseoHome: dependencies.paseoHome, + worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot, + }); + dependencies.github.invalidate({ cwd: options.targetPath }); + } catch (error) { + if (error instanceof WorktreeTeardownError) { + dependencies.sessionLogger?.warn( + { err: error, targetPath: options.targetPath }, + "Worktree disk removal failed during archive; workspace already archived", + ); + return; + } + throw error; + } +} + +export async function killTerminalsForWorkspace( + dependencies: KillTerminalsForWorkspaceDependencies, + workspaceId: string, ): Promise { const terminalManager = dependencies.terminalManager; if (!terminalManager) { @@ -208,17 +291,14 @@ export async function killTerminalsUnderPath( } const terminalIds: string[] = []; - const relevantCwds = [...terminalManager.listDirectories()].filter((terminalCwd) => - dependencies.isPathWithinRoot(rootPath, terminalCwd), - ); const terminalLists = await Promise.all( - relevantCwds.map(async (terminalCwd) => { + terminalManager.listDirectories().map(async (terminalCwd) => { try { - return await terminalManager.getTerminals(terminalCwd); + return await terminalManager.getTerminals(terminalCwd, { workspaceId }); } catch (error) { dependencies.sessionLogger.warn( { err: error, cwd: terminalCwd }, - "Failed to enumerate worktree terminals during archive", + "Failed to enumerate workspace terminals during archive", ); return []; } @@ -226,7 +306,9 @@ export async function killTerminalsUnderPath( ); for (const terminals of terminalLists) { for (const terminal of terminals) { - terminalIds.push(terminal.id); + if (terminal.workspaceId === workspaceId) { + terminalIds.push(terminal.id); + } } } diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 008cfe702..1da1e5df1 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -6,9 +6,14 @@ import { afterEach, expect, test, vi } from "vitest"; import type { GitHubService } from "../services/github-service.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; -import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, +} from "./workspace-registry.js"; import { attemptFirstAgentBranchAutoName, + createLocalCheckoutWorkspace, createPaseoWorktree, type CreatePaseoWorktreeDeps, } from "./paseo-worktree-service.js"; @@ -138,9 +143,25 @@ test.skipIf(isPlatform("win32"))( expect(second.created).toBe(false); expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); expect(events).toContain(`workspace:${second.workspace.workspaceId}`); + // Creation never dedupes by directory: the same worktree path yields a + // distinct workspace record on the second call. + expect(second.workspace.workspaceId).not.toBe(first.workspace.workspaceId); }, ); +test("creates a distinct local checkout workspace for the same cwd on every call", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const deps = createDeps(); + + const first = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps); + const second = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps); + + expect(first.cwd).toBe(second.cwd); + expect(first.workspaceId).not.toBe(second.workspaceId); + expect(deps.workspaces.size).toBe(2); +}); + test("renames an eligible unnamed branch-off worktree once on first agent context", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -479,6 +500,7 @@ test("does not mutate registries or broadcast when core worktree creation fails" }); interface TestDeps extends CreatePaseoWorktreeDeps { + projectRegistry: Pick; projects: Map; workspaces: Map; } @@ -498,6 +520,7 @@ function createDeps(options?: { workspaces, projectRegistry: { get: async (projectId) => projects.get(projectId) ?? null, + list: async () => Array.from(projects.values()), upsert: async (record) => { events.push(`project:${record.projectId}`); projects.set(record.projectId, record); @@ -583,6 +606,15 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService { unsubscribe: () => {}, }), peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd), + getCheckout: async (cwd) => ({ + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd), resolveRepoRoot: async (cwd) => { try { diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 6893c5aee..73d3cdfb5 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -7,7 +7,11 @@ import { createPersistedProjectRecord, createPersistedWorkspaceRecord, } from "./workspace-registry.js"; -import { deriveProjectGroupingName, generateWorkspaceId } from "./workspace-registry-model.js"; +import { + classifyDirectoryForProjectMembership, + deriveProjectGroupingName, + generateWorkspaceId, +} from "./workspace-registry-model.js"; import { createWorktreeCore, type CreateWorktreeCoreDeps, @@ -204,18 +208,17 @@ async function upsertWorkspaceForWorktree(options: { const normalizedCwd = resolve(options.worktree.worktreePath); const normalizedInputCwd = resolve(options.inputCwd); const normalizedRepoRoot = resolve(options.repoRoot); - const existingWorkspace = await findWorkspaceByDirectory( - normalizedCwd, - options.deps.workspaceRegistry, - ); + // Creation never deduplicates by directory: a worktree directory may back + // more than one workspace. We still resolve the source project from the + // originating checkout, but always mint a fresh workspace record. const sourceProject = await resolveSourceProjectForWorktree({ inputCwd: normalizedInputCwd, projectId: options.projectId, repoRoot: normalizedRepoRoot, - existingWorkspace, + existingWorkspace: null, deps: options.deps, }); - const workspaceId = existingWorkspace?.workspaceId ?? generateWorkspaceId(); + const workspaceId = generateWorkspaceId(); const now = new Date().toISOString(); await options.deps.projectRegistry.upsert( @@ -237,7 +240,7 @@ async function upsertWorkspaceForWorktree(options: { cwd: normalizedCwd, kind: "worktree", displayName: options.worktree.branchName || normalizedCwd, - createdAt: existingWorkspace?.createdAt ?? now, + createdAt: now, updatedAt: now, archivedAt: null, }); @@ -246,6 +249,77 @@ async function upsertWorkspaceForWorktree(options: { return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace; } +export interface CreateLocalCheckoutWorkspaceDeps { + projectRegistry: Pick; + workspaceRegistry: Pick; + workspaceGitService: Pick; +} + +// Always create a NEW workspace record backed by the existing directory `cwd`. +// Never reuses a same-cwd record: a directory may back any number of +// workspaces. Used by explicit user creation. +export async function createLocalCheckoutWorkspace( + options: { cwd: string; title?: string | null }, + deps: CreateLocalCheckoutWorkspaceDeps, +): Promise { + const normalizedCwd = resolve(options.cwd); + const checkout = await deps.workspaceGitService.getCheckout(normalizedCwd); + const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout }); + const now = new Date().toISOString(); + const projectRecord = await resolveProjectRecordForMembership({ + membership, + timestamp: now, + projectRegistry: deps.projectRegistry, + }); + await deps.projectRegistry.upsert(projectRecord); + + const trimmedTitle = options.title?.trim(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: generateWorkspaceId(), + projectId: projectRecord.projectId, + cwd: normalizedCwd, + kind: membership.workspaceKind, + displayName: membership.workspaceDisplayName, + title: trimmedTitle ? trimmedTitle : null, + createdAt: now, + updatedAt: now, + }); + await deps.workspaceRegistry.upsert(workspace); + return workspace; +} + +async function resolveProjectRecordForMembership(options: { + membership: ReturnType; + timestamp: string; + projectRegistry: Pick; +}) { + const rootPath = options.membership.projectRootPath; + const projects = await options.projectRegistry.list(); + const existingProject = + projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ?? + projects.find((project) => project.rootPath === rootPath) ?? + null; + + if (!existingProject) { + return createPersistedProjectRecord({ + projectId: options.membership.projectKey, + rootPath, + kind: options.membership.projectKind, + displayName: options.membership.projectName, + createdAt: options.timestamp, + updatedAt: options.timestamp, + }); + } + + return { + ...existingProject, + rootPath, + kind: options.membership.projectKind, + archivedAt: null, + updatedAt: options.timestamp, + }; +} + interface SourceProjectForWorktree { projectId: string; rootPath: string; @@ -362,11 +436,3 @@ async function findWorkspaceForSource(options: { null ); } - -async function findWorkspaceByDirectory( - cwd: string, - workspaceRegistry: Pick, -): Promise { - const workspaces = await workspaceRegistry.list(); - return workspaces.find((workspace) => workspace.cwd === cwd) ?? null; -} diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index c3d4dbd69..c7dc20f67 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -98,12 +98,14 @@ export function extractTimestamps(record: StoredAgentRecord): { updatedAt: Date; lastUserMessageAt: Date | null; labels?: Record; + workspaceId?: string; } { return { createdAt: new Date(record.createdAt), updatedAt: new Date(record.lastActivityAt ?? record.updatedAt), lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, labels: record.labels, + workspaceId: record.workspaceId, }; } diff --git a/packages/server/src/server/script-proxy.ts b/packages/server/src/server/script-proxy.ts index 0f6a00810..1762697f5 100644 --- a/packages/server/src/server/script-proxy.ts +++ b/packages/server/src/server/script-proxy.ts @@ -3,5 +3,6 @@ export { createScriptProxyUpgradeHandler, findFreePort, ScriptRouteStore, + ServiceProxyRouteCollisionError, } from "./service-proxy.js"; export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js"; diff --git a/packages/server/src/server/script-route-branch-handler.test.ts b/packages/server/src/server/script-route-branch-handler.test.ts index 68e397f6b..a3ca4cfb0 100644 --- a/packages/server/src/server/script-route-branch-handler.test.ts +++ b/packages/server/src/server/script-route-branch-handler.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { ScriptRouteStore } from "./script-proxy.js"; +import { ScriptRouteStore, ServiceProxyRouteCollisionError } from "./script-proxy.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; function createWorkspaceRepo(options?: { @@ -338,7 +338,7 @@ describe("script-route-branch-handler", () => { }); expect(() => handleBranchChange("workspace-a", "feature/auth", "feature/billing")).toThrow( - "Service proxy hostname collision", + ServiceProxyRouteCollisionError, ); expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ @@ -393,7 +393,7 @@ describe("script-route-branch-handler", () => { }); expect(() => handleBranchChange("workspace-a", "feature/one", "feature/collide")).toThrow( - "Service proxy hostname collision", + ServiceProxyRouteCollisionError, ); expect(routeStore.getRouteEntry("api--feature-one--repo.localhost")).toMatchObject({ port: 3001, diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index a3b3c1b83..508bc4646 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -10,6 +10,7 @@ import { buildServiceProxyLabel, createServiceProxySubsystem, findFreePort, + ServiceProxyRouteCollisionError, ServiceProxyRouteRegistry, } from "./service-proxy.js"; @@ -179,7 +180,7 @@ describe("service proxy subsystem shape", () => { scriptName: "api", port: 4000, }), - ).toThrow("Service proxy hostname collision"); + ).toThrow(ServiceProxyRouteCollisionError); expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({ workspaceId: "workspace-a", @@ -207,7 +208,7 @@ describe("service proxy subsystem shape", () => { port: 4000, publicBaseUrl: "https://services.example.com", }), - ).toThrow("Service proxy hostname collision"); + ).toThrow(ServiceProxyRouteCollisionError); expect(serviceProxy.getHealthTargetForHostname("api--repo.services.example.com")).toMatchObject( { @@ -239,7 +240,7 @@ describe("service proxy subsystem shape", () => { projectSlug: "repo-b", scriptName: "api", }), - ).toThrow("Service proxy hostname collision"); + ).toThrow(ServiceProxyRouteCollisionError); expect(serviceProxy.getRouteEntry("api--repo-a.localhost")).toMatchObject({ port: 3000 }); expect(serviceProxy.getRouteEntry("api--repo-b.localhost")).toBeNull(); @@ -265,7 +266,7 @@ describe("service proxy subsystem shape", () => { projectSlug: "other", scriptName: "api", }), - ).toThrow("Service proxy hostname collision"); + ).toThrow(ServiceProxyRouteCollisionError); expect(serviceProxy.getRouteEntry("api--repo.localhost")).toMatchObject({ port: 3000 }); expect(serviceProxy.getRouteEntry("api.services.example.com")).toMatchObject({ port: 3000 }); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index a5c64915f..73588e028 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -360,7 +360,7 @@ export class ServiceProxyRouteCollisionError extends Error { public readonly incoming: Pick, ) { super( - `Service proxy hostname collision for ${hostname}: ${existing.workspaceId}/${existing.scriptName} already owns it`, + `Another workspace is already serving "${incoming.scriptName}" at ${hostname}. Stop that service or run this one on a different branch to free the address.`, ); this.name = "ServiceProxyRouteCollisionError"; } diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index 7b050d971..391936228 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -64,18 +64,6 @@ async function expectActiveAgentListEmpty(): Promise { expect(active.entries).toEqual([]); } -async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise { - await expect - .poll( - async () => { - const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); - return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath); - }, - { timeout: 15000, interval: 100 }, - ) - .toBe(false); -} - async function expectWorktreePresentInList(repoDir: string, worktreePath: string): Promise { await expect .poll( @@ -148,8 +136,10 @@ test("create_agent_request creates a worktree and auto-archives both after the f await ctx.client.waitForFinish(created.id, 10000); + // Archiving removes the task and its agents, but never deletes the worktree + // from disk — a directory can back multiple workspaces. await expectAgentAbsentFromActiveList(created.id); - await expectWorktreeAbsentFromList(repoDir, created.cwd); + await expectWorktreePresentInList(repoDir, created.cwd); }, 30000); test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => { @@ -216,14 +206,14 @@ test("create_agent_request with worktree but no autoArchive leaves agent and wor await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); }); -test("archiving a created worktree still archives nested agents", async () => { +test("archiving a created worktree archives its agents but leaves the worktree on disk", async () => { const created = await createAgentInBranchOffWorktree(); await ctx.client.waitForFinish(created.agentId, 10000); await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); await expectAgentAbsentFromActiveList(created.agentId); - await expectWorktreeAbsentFromList(created.repoDir, created.worktreePath); + await expectWorktreePresentInList(created.repoDir, created.worktreePath); }); test("create_agent_request rejects legacy git options before creating a worktree", async () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 340412931..7f191aeaa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -164,6 +164,8 @@ import { createPersistedProjectRecord, createPersistedWorkspaceRecord, resolveProjectDisplayName, + resolveWorkspaceDisplayName, + resolveWorkspaceName, type PersistedProjectRecord, type PersistedWorkspaceRecord, type ProjectRegistry, @@ -241,6 +243,7 @@ import { import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js"; import { attemptFirstAgentBranchAutoName, + createLocalCheckoutWorkspace, createPaseoWorktree, type CreatePaseoWorktreeInput, type CreatePaseoWorktreeResult, @@ -257,6 +260,10 @@ import { handlePaseoWorktreeListRequest as handleWorktreeListRequest, handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, } from "./worktree-session.js"; +import { + type ActiveWorkspaceRef, + archiveWorkspaceContents, +} from "./paseo-worktree-archive-service.js"; import { toWorktreeWireError } from "./worktree-errors.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; @@ -493,6 +500,7 @@ type FetchWorkspacesResponsePayload = Extract< >["payload"]; type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number]; type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"]; +type WorkspaceProjectDescriptorPayload = FetchWorkspacesResponsePayload["emptyProjects"][number]; type WorkspaceUpdatePayload = Extract< SessionOutboundMessage, { type: "workspace_update" } @@ -967,6 +975,7 @@ export class Session { this.createPaseoWorktreeWorkflow(input, workflowOptions), archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd), + listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), emit: (message) => this.emit(message), emitAgentRemove: (agentId) => { @@ -982,9 +991,8 @@ export class Session { markWorkspaceArchiving: (workspaceIds, archivingAt) => this.markWorkspaceArchiving(workspaceIds, archivingAt), clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds), - isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), - killTerminalsUnderPath: (rootPath) => - this.terminalController.killTerminalsUnderPath(rootPath), + killTerminalsForWorkspace: (workspaceId) => + this.terminalController.killTerminalsForWorkspace(workspaceId), logger: this.sessionLogger, }); this.providerSnapshotManager = providerSnapshotManager; @@ -2174,8 +2182,12 @@ export class Session { return this.handleOpenProjectRequest(msg); case "archive_workspace_request": return this.handleArchiveWorkspaceRequest(msg); + case "workspace.create.request": + return this.handleWorkspaceCreateRequest(msg); case "workspace.clear_attention.request": return this.handleWorkspaceClearAttentionRequest(msg); + case "workspace.title.set.request": + return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "file_explorer_request": return this.handleFileExplorerRequest(msg); case "project_icon_request": @@ -2667,6 +2679,82 @@ export class Session { } } + private async handleWorkspaceTitleSetRequest( + workspaceId: string, + title: string | null, + requestId: string, + ): Promise { + this.sessionLogger.info( + { workspaceId, requestId, hasTitle: typeof title === "string" }, + "session: workspace.title.set.request", + ); + + try { + const existing = await this.workspaceRegistry.get(workspaceId); + if (!existing) { + this.emit({ + type: "workspace.title.set.response", + payload: { + requestId, + workspaceId, + accepted: false, + title: null, + error: "Workspace not found", + }, + }); + return; + } + + const trimmed = title?.trim() ?? ""; + const nextTitle = trimmed.length === 0 ? null : trimmed; + + await this.workspaceRegistry.upsert({ + ...existing, + title: nextTitle, + updatedAt: new Date().toISOString(), + }); + + this.emit({ + type: "workspace.title.set.response", + payload: { + requestId, + workspaceId, + accepted: true, + title: nextTitle, + error: null, + }, + }); + + await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { + skipReconcile: true, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, workspaceId, requestId }, + "session: workspace.title.set.request error", + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to set workspace title: ${getErrorMessage(error)}`, + }, + }); + this.emit({ + type: "workspace.title.set.response", + payload: { + requestId, + workspaceId, + accepted: false, + title: null, + error: getErrorMessageOr(error, "Failed to set workspace title"), + }, + }); + } + } + private toVoiceFeatureUnavailableContext( state: SpeechReadinessState, ): VoiceFeatureUnavailableContext { @@ -3175,7 +3263,7 @@ export class Session { { kind: "session", config: createAgentConfig, - workspaceId: msg.workspaceId, + workspaceId: createdWorktree ? createdWorktree.workspace.workspaceId : msg.workspaceId, worktreeName, initialPrompt, clientMessageId, @@ -3527,6 +3615,7 @@ export class Session { ): Promise<{ sessionConfig: AgentSessionConfig; setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"]; + createdWorkspaceId?: string; }> { return buildWorktreeAgentSessionConfig( { @@ -5826,6 +5915,7 @@ export class Session { agentManager: this.agentManager, agentStorage: this.agentStorage, resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd), + listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), emit: (message) => this.emit(message), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => @@ -5833,10 +5923,8 @@ export class Session { markWorkspaceArchiving: (workspaceIds, archivingAt) => this.markWorkspaceArchiving(workspaceIds, archivingAt), clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds), - isPathWithinRoot: (rootPath, candidatePath) => - this.isPathWithinRoot(rootPath, candidatePath), - killTerminalsUnderPath: (rootPath) => - this.terminalController.killTerminalsUnderPath(rootPath), + killTerminalsForWorkspace: (workspaceId) => + this.terminalController.killTerminalsForWorkspace(workspaceId), sessionLogger: this.sessionLogger, }, msg, @@ -6076,7 +6164,7 @@ export class Session { } private async listTerminalActivityContributions(): Promise< - Array<{ cwd: string; activity: TerminalActivity | null }> + Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> > { const terminalManager = this.terminalManager; if (!terminalManager) { @@ -6086,10 +6174,17 @@ export class Session { const terminalsByDirectory = await Promise.all( directories.map((cwd) => terminalManager.getTerminals(cwd)), ); - return terminalsByDirectory.flat().map((session) => ({ - cwd: session.cwd, - activity: session.getActivity(), - })); + return terminalsByDirectory.flat().map((session) => { + const contribution: { cwd: string; workspaceId?: string; activity: TerminalActivity | null } = + { + cwd: session.cwd, + activity: session.getActivity(), + }; + if (session.workspaceId) { + contribution.workspaceId = session.workspaceId; + } + return contribution; + }); } /** @@ -6415,7 +6510,8 @@ export class Session { workspaceDirectory: workspace.cwd, projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", workspaceKind: workspace.kind, - name: workspace.displayName, + name: resolveWorkspaceDisplayName(workspace), + title: workspace.title, archivingAt: null, status: "done", statusEnteredAt: null, @@ -6486,7 +6582,7 @@ export class Session { return { ...base, - name: displayName, + name: resolveWorkspaceName({ title: workspace.title, derivedDisplayName: displayName }), diffStat: snapshot.git.diffStat ?? null, gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined, githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot), @@ -6508,7 +6604,11 @@ export class Session { workspaceDirectory: result.workspace.cwd, projectKind: "git", workspaceKind: result.workspace.kind, - name: result.worktree.branchName || result.workspace.displayName, + name: resolveWorkspaceName({ + title: result.workspace.title, + derivedDisplayName: result.worktree.branchName || result.workspace.displayName, + }), + title: result.workspace.title, archivingAt: null, status: "done", statusEnteredAt: null, @@ -6577,6 +6677,7 @@ export class Session { request: Extract, ): Promise<{ entries: FetchWorkspacesResponseEntry[]; + emptyProjects: WorkspaceProjectDescriptorPayload[]; pageInfo: FetchWorkspacesResponsePageInfo; }> { try { @@ -6844,13 +6945,23 @@ export class Session { return result; } + private async listActiveWorkspaceRefs(): Promise { + const workspaces = await this.workspaceRegistry.list(); + return workspaces + .filter((workspace) => !workspace.archivedAt) + .map((workspace) => ({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + kind: workspace.kind, + })); + } + private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { const archiveTimestamp = archivedAt ?? new Date().toISOString(); const existingWorkspace = await archivePersistedWorkspaceRecord({ workspaceId, archivedAt: archiveTimestamp, workspaceRegistry: this.workspaceRegistry, - projectRegistry: this.projectRegistry, }); if (!existingWorkspace) { const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId); @@ -6999,6 +7110,7 @@ export class Session { this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "remove", id: workspaceId, + ...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)), }); continue; } @@ -7025,6 +7137,22 @@ export class Session { } } + // When a workspace is archived its project may become empty. Resolve the + // now-empty project parent so the `remove` update can carry it, keeping the + // sidebar's empty project row in sync without a full re-hydration. + private async resolveEmptyProjectForArchivedWorkspace( + workspaceId: string, + ): Promise<{ emptyProject: WorkspaceProjectDescriptorPayload } | null> { + const archivedWorkspace = await this.workspaceRegistry.get(workspaceId); + if (!archivedWorkspace) { + return null; + } + const emptyProject = (await this.workspaceDirectory.listEmptyProjects()).find( + (project) => project.projectId === archivedWorkspace.projectId, + ); + return emptyProject ? { emptyProject } : null; + } + private async emitWorkspaceUpdateForCwd( cwd: string, options?: { @@ -7280,6 +7408,168 @@ export class Session { } } + private async handleWorkspaceCreateRequest( + request: Extract, + ): Promise { + try { + if (request.backing === "local") { + await this.handleWorkspaceCreateLocal(request); + return; + } + await this.handleWorkspaceCreateWorktree(request); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create workspace"; + this.sessionLogger.error( + { err: error, backing: request.backing, requestId: request.requestId }, + "Failed to create workspace", + ); + this.emit({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: null, + setupTerminalId: null, + error: message, + }, + }); + } + } + + private async handleWorkspaceCreateLocal( + request: Extract, + ): Promise { + if (!request.cwd) { + this.emit({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: null, + setupTerminalId: null, + error: "cwd is required for a local-backed workspace", + errorCode: "cwd_required", + }, + }); + return; + } + + const cwd = expandTilde(request.cwd); + const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); + if (!directoryExists) { + this.emit({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: null, + setupTerminalId: null, + error: `Directory not found: ${cwd}`, + errorCode: "directory_not_found", + }, + }); + return; + } + + const workspace = await createLocalCheckoutWorkspace( + { cwd, title: request.title ?? null }, + { + projectRegistry: this.projectRegistry, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }, + ); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + const descriptor = await this.describeWorkspaceRecord(workspace); + this.emit({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: descriptor, + setupTerminalId: null, + error: null, + }, + }); + await this.emitWorkspaceUpdateForCwd(workspace.cwd); + void this.workspaceGitService + .getSnapshot(workspace.cwd, { force: true, includeGitHub: true, reason: "open_project" }) + .catch((error) => { + this.sessionLogger.warn( + { err: error, cwd: workspace.cwd }, + "Background snapshot refresh failed after workspace.create", + ); + }); + } + + private async handleWorkspaceCreateWorktree( + request: Extract, + ): Promise { + if (!request.cwd && !request.projectId) { + this.emit({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: null, + setupTerminalId: null, + error: "cwd or projectId is required for a worktree-backed workspace", + errorCode: "source_required", + }, + }); + return; + } + + const sourceCwd = await this.resolveWorktreeSourceCwd({ + cwd: request.cwd, + projectId: request.projectId, + }); + + const result = await this.createPaseoWorktreeWorkflow( + { + cwd: sourceCwd, + projectId: request.projectId, + worktreeSlug: request.branch, + }, + request.baseBranch + ? { resolveDefaultBranch: async () => request.baseBranch as string } + : undefined, + ); + + if (request.title?.trim()) { + await this.workspaceRegistry.upsert({ + ...result.workspace, + title: request.title.trim(), + updatedAt: new Date().toISOString(), + }); + result.workspace.title = request.title.trim(); + } + + const descriptor = await this.describeCreatedWorktreeWorkspace(result); + this.emit({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: descriptor, + setupTerminalId: null, + error: null, + }, + }); + this.emit({ + type: "workspace_update", + payload: { kind: "upsert", workspace: descriptor }, + }); + } + + private async resolveWorktreeSourceCwd(input: { + cwd?: string; + projectId?: string; + }): Promise { + if (input.cwd) { + return expandTilde(input.cwd); + } + const project = await this.projectRegistry.get(input.projectId as string); + if (!project || project.archivedAt) { + throw new Error(`Project not found: ${input.projectId}`); + } + return project.rootPath; + } + private async handleOpenProjectRequest( request: Extract, ): Promise { @@ -7586,7 +7876,26 @@ export class Session { throw new Error("Use worktree archive for Paseo worktrees"); } const archivedAt = new Date().toISOString(); - await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt); + // Archive removes the task (the workspace record) and everything the + // workspace owns — its agents and terminals — scoped by workspaceId so a + // sibling workspace sharing the same directory is untouched. The directory + // itself is never deleted here; local checkouts are not Paseo-owned. + this.markWorkspaceArchiving([existing.workspaceId], archivedAt); + try { + await archiveWorkspaceContents( + { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + killTerminalsForWorkspace: (workspaceId) => + this.terminalController.killTerminalsForWorkspace(workspaceId), + sessionLogger: this.sessionLogger, + }, + existing.workspaceId, + ); + await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt); + } finally { + this.clearWorkspaceArchiving([existing.workspaceId]); + } await this.emitWorkspaceUpdateForCwd(existing.cwd); this.emit({ type: "archive_workspace_response", diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 082a30a4b..f4a4586eb 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -857,7 +857,7 @@ test("workspace reconciliation reports archived workspaces to subscribed clients expect(changedWorkspaceIds).toEqual(new Set(["ws-missing"])); expect(workspaces.get("ws-missing")?.archivedAt).toBeTruthy(); - expect(projects.get("proj-missing")?.archivedAt).toBeTruthy(); + expect(projects.get("proj-missing")?.archivedAt).toBeFalsy(); }); test("agent_update placement does not refresh git snapshots", async () => { @@ -2765,6 +2765,67 @@ test("workspace update stream keeps persisted workspace visible after agents sto }); }); +test("archiving the last workspace emits a remove carrying the now-empty project", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + }); + + const project = createPersistedProjectRecord({ + projectId: "proj-empty-after-archive", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-last", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [archivedWorkspace]; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaceId === archivedWorkspace.workspaceId ? archivedWorkspace : null; + + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-1", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.reconcileActiveWorkspaceRecords = async () => new Set(); + // The archived workspace no longer resolves to an active descriptor. + session.buildWorkspaceDescriptorMap = async () => new Map(); + + await session.emitWorkspaceUpdatesForWorkspaceIds([archivedWorkspace.workspaceId], { + skipReconcile: true, + }); + + const removeUpdate = filterByType(emitted, "workspace_update").find( + (message) => message.payload.kind === "remove", + ); + expect(removeUpdate?.payload).toEqual({ + kind: "remove", + id: archivedWorkspace.workspaceId, + emptyProject: { + projectId: project.projectId, + projectDisplayName: "repo", + projectCustomName: null, + projectRootPath: REPO_CWD, + projectKind: "git", + }, + }); +}); + test("create paseo worktree request returns a registered workspace descriptor", async () => { const emitted: SessionOutboundMessage[] = []; const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); @@ -5155,6 +5216,145 @@ test("project.rename.request returns accepted=false when project is not found", expect(response?.payload.error).toBeTruthy(); }); +test("workspace.title.set.request stores the title and emits an updated descriptor", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = asTestSession( + createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), + ); + + const project = createPersistedProjectRecord({ + projectId: "proj-1", + rootPath: REPO_CWD, + kind: "git", + displayName: "acme/repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-1", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + const projects = new Map([[project.projectId, project]]); + const workspaces = new Map([[workspace.workspaceId, workspace]]); + session.projectRegistry.get = async (id: string) => projects.get(id) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; + session.workspaceRegistry.upsert = async (record: unknown) => { + const parsed = record as typeof workspace; + workspaces.set(parsed.workspaceId, parsed); + }; + + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-workspaces", + filter: {}, + isBootstrapping: false, + lastEmittedByWorkspaceId: new Map(), + pendingUpdatesByWorkspaceId: new Map(), + }; + + await session.handleMessage({ + type: "workspace.title.set.request", + workspaceId: workspace.workspaceId, + title: " Payments work ", + requestId: "req-title-1", + }); + + const response = findByType(emitted, "workspace.title.set.response"); + expect(response?.payload).toEqual({ + requestId: "req-title-1", + workspaceId: workspace.workspaceId, + accepted: true, + title: "Payments work", + error: null, + }); + + expect(workspaces.get(workspace.workspaceId)?.title).toBe("Payments work"); + + const update = findByType(emitted, "workspace_update"); + expect(update?.payload).toMatchObject({ + kind: "upsert", + workspace: { + id: "ws-1", + name: "Payments work", + title: "Payments work", + }, + }); +}); + +test("workspace.title.set.request with whitespace-only title clears the title", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = asTestSession( + createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), + ); + + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-1", + projectId: "proj-1", + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + title: "Old title", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + const workspaces = new Map([[workspace.workspaceId, workspace]]); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; + session.workspaceRegistry.upsert = async (record: unknown) => { + const parsed = record as typeof workspace; + workspaces.set(parsed.workspaceId, parsed); + }; + + await session.handleMessage({ + type: "workspace.title.set.request", + workspaceId: workspace.workspaceId, + title: " ", + requestId: "req-title-clear", + }); + + const response = findByType(emitted, "workspace.title.set.response"); + expect(response?.payload).toEqual({ + requestId: "req-title-clear", + workspaceId: workspace.workspaceId, + accepted: true, + title: null, + error: null, + }); + expect(workspaces.get(workspace.workspaceId)?.title).toBeNull(); +}); + +test("workspace.title.set.request returns accepted=false when workspace is not found", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = asTestSession( + createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), + ); + session.workspaceRegistry.get = async () => null; + + await session.handleMessage({ + type: "workspace.title.set.request", + workspaceId: "does-not-exist", + title: "X", + requestId: "req-title-missing", + }); + + const response = findByType(emitted, "workspace.title.set.response"); + expect(response?.payload).toMatchObject({ + requestId: "req-title-missing", + workspaceId: "does-not-exist", + accepted: false, + title: null, + }); + expect(response?.payload.error).toBeTruthy(); +}); + test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a prefix", () => { const session = createSessionForWorkspaceTests(); const home = homedir(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4af704a0d..f02aabd71 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1150,6 +1150,8 @@ export class VoiceAssistantWebSocketServer { rewind: true, // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29. checkoutRefresh: true, + // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 + workspaceMultiplicity: true, }, }; } diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts new file mode 100644 index 000000000..971886338 --- /dev/null +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -0,0 +1,298 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; + +import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; + +// Model B archive is scoped to a single workspace RECORD (by workspaceId), not +// to a directory on disk. A directory can back multiple workspaces, so archiving +// one must never tear down a sibling's agents/terminals, and must never delete a +// directory another workspace still references. On-disk worktree removal is an +// explicit, last-reference-only opt-in (deleteWorktreeFromDisk). + +let ctx: DaemonTestContext; +const tempRoots: string[] = []; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); +}); + +afterEach(async () => { + await ctx.cleanup(); + for (const tempRoot of tempRoots.splice(0)) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(path.join(tmpdir(), prefix)); + tempRoots.push(dir); + return dir; +} + +function createGitRepo(): string { + const tempRoot = makeTempDir("workspace-archive-repo-"); + const repoDir = path.join(tempRoot, "repo"); + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + return repoDir; +} + +async function createLocalWorkspace(cwd: string, title: string): Promise { + const result = await ctx.client.createWorkspace({ backing: "local", cwd, title }); + if (!result.workspace) { + throw new Error(result.error ?? "Failed to create local workspace"); + } + return result.workspace.id; +} + +async function activeWorkspaceIds(): Promise> { + const workspaces = await ctx.client.fetchWorkspaces(); + return new Set(workspaces.entries.map((entry) => entry.id)); +} + +async function activeAgentIds(): Promise> { + const agents = await ctx.client.fetchAgents(); + return new Set(agents.entries.map((entry) => entry.agent.id)); +} + +async function archivedAgentIds(): Promise> { + const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } }); + return new Set( + agents.entries + .filter((entry) => entry.agent.archivedAt !== null && entry.agent.archivedAt !== undefined) + .map((entry) => entry.agent.id), + ); +} + +async function terminalIdsForWorkspace(cwd: string, workspaceId: string): Promise> { + const listed = await ctx.client.listTerminals(cwd, undefined, { workspaceId }); + return new Set(listed.terminals.map((terminal) => terminal.id)); +} + +test("archiving one of two workspaces sharing a cwd spares the sibling and the directory", async () => { + const cwd = makeTempDir("workspace-archive-shared-cwd-"); + + const workspaceA = await createLocalWorkspace(cwd, "workspace-a"); + const workspaceB = await createLocalWorkspace(cwd, "workspace-b"); + expect(workspaceA).not.toBe(workspaceB); + + const agentA = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + workspaceId: workspaceA, + title: "A agent", + initialPrompt: "Say done.", + }); + const agentB = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + workspaceId: workspaceB, + title: "B agent", + initialPrompt: "Say done.", + }); + expect(agentA.workspaceId).toBe(workspaceA); + expect(agentB.workspaceId).toBe(workspaceB); + + const terminalA = await ctx.client.createTerminal(cwd, "A terminal", undefined, { + workspaceId: workspaceA, + }); + const terminalB = await ctx.client.createTerminal(cwd, "B terminal", undefined, { + workspaceId: workspaceB, + }); + const terminalAId = terminalA.terminal?.id; + const terminalBId = terminalB.terminal?.id; + if (!terminalAId || !terminalBId) { + throw new Error("Expected both terminals to be created"); + } + + // Both workspaces, both agents, and both terminals exist before the archive. + expect(await activeWorkspaceIds()).toContain(workspaceA); + expect(await activeWorkspaceIds()).toContain(workspaceB); + expect(await activeAgentIds()).toContain(agentA.id); + expect(await activeAgentIds()).toContain(agentB.id); + expect(await terminalIdsForWorkspace(cwd, workspaceA)).toContain(terminalAId); + expect(await terminalIdsForWorkspace(cwd, workspaceB)).toContain(terminalBId); + + // Archive workspace A by its workspaceId. Because two workspaces share the cwd, + // teardown must be scoped to A's workspaceId, not the directory. + const archive = await ctx.client.archiveWorkspace(workspaceA); + expect(archive.error).toBe(null); + + await expect + .poll(async () => (await activeWorkspaceIds()).has(workspaceA), { + timeout: 10000, + interval: 100, + }) + .toBe(false); + + // A's record is gone; B's survives. + const remainingWorkspaces = await activeWorkspaceIds(); + expect(remainingWorkspaces.has(workspaceA)).toBe(false); + expect(remainingWorkspaces.has(workspaceB)).toBe(true); + + // A's agent is archived; B's agent stays active. + expect((await activeAgentIds()).has(agentA.id)).toBe(false); + expect(await archivedAgentIds()).toContain(agentA.id); + expect((await activeAgentIds()).has(agentB.id)).toBe(true); + + // A's terminal is killed; B's terminal survives. + expect((await terminalIdsForWorkspace(cwd, workspaceA)).has(terminalAId)).toBe(false); + expect((await terminalIdsForWorkspace(cwd, workspaceB)).has(terminalBId)).toBe(true); + + // The shared directory is never deleted — a sibling still references it. + expect(existsSync(cwd)).toBe(true); + + await ctx.client.killTerminal(terminalBId); +}, 60000); + +test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", async () => { + const repoDir = createGitRepo(); + + const keepResult = await ctx.client.createWorkspace({ + backing: "worktree", + cwd: repoDir, + branch: "keep-on-disk", + baseBranch: "main", + }); + const keepWorkspace = keepResult.workspace; + if (!keepWorkspace?.workspaceDirectory) { + throw new Error(keepResult.error ?? "Failed to create worktree workspace"); + } + const keepDir = keepWorkspace.workspaceDirectory; + expect(existsSync(keepDir)).toBe(true); + + // Last reference, deleteWorktreeFromDisk omitted (defaults false) → dir stays. + const keepArchive = await ctx.client.archivePaseoWorktree({ worktreePath: keepDir }); + expect(keepArchive.success).toBe(true); + await expect + .poll(async () => (await activeWorkspaceIds()).has(keepWorkspace.id), { + timeout: 10000, + interval: 100, + }) + .toBe(false); + expect(existsSync(keepDir)).toBe(true); + + const deleteResult = await ctx.client.createWorkspace({ + backing: "worktree", + cwd: repoDir, + branch: "delete-from-disk", + baseBranch: "main", + }); + const deleteWorkspace = deleteResult.workspace; + if (!deleteWorkspace?.workspaceDirectory) { + throw new Error(deleteResult.error ?? "Failed to create worktree workspace"); + } + const deleteDir = deleteWorkspace.workspaceDirectory; + expect(existsSync(deleteDir)).toBe(true); + + // Last reference, deleteWorktreeFromDisk true → dir is removed from disk. + const deleteArchive = await ctx.client.archivePaseoWorktree({ + worktreePath: deleteDir, + deleteWorktreeFromDisk: true, + }); + expect(deleteArchive.success).toBe(true); + await expect + .poll(async () => (await activeWorkspaceIds()).has(deleteWorkspace.id), { + timeout: 10000, + interval: 100, + }) + .toBe(false); + await expect.poll(() => existsSync(deleteDir), { timeout: 10000, interval: 100 }).toBe(false); +}, 60000); + +test("worktree archive targets the explicit workspaceId when a directory backs multiple workspaces", async () => { + const repoDir = createGitRepo(); + + const worktreeResult = await ctx.client.createWorkspace({ + backing: "worktree", + cwd: repoDir, + branch: "targeted-worktree", + baseBranch: "main", + }); + const worktreeWorkspace = worktreeResult.workspace; + if (!worktreeWorkspace?.workspaceDirectory) { + throw new Error(worktreeResult.error ?? "Failed to create worktree workspace"); + } + const worktreeDir = worktreeWorkspace.workspaceDirectory; + + // A local workspace records the SAME directory as its cwd. Resolving the + // archive target by cwd alone is ambiguous; the explicit workspaceId must win. + const localWorkspaceId = await createLocalWorkspace(worktreeDir, "local-sibling"); + expect(localWorkspaceId).not.toBe(worktreeWorkspace.id); + + const archive = await ctx.client.archivePaseoWorktree({ + worktreePath: worktreeDir, + workspaceId: localWorkspaceId, + }); + expect(archive.success).toBe(true); + + // Exactly the targeted workspace is archived; the worktree-backed sibling stays. + await expect + .poll(async () => (await activeWorkspaceIds()).has(localWorkspaceId), { + timeout: 10000, + interval: 100, + }) + .toBe(false); + const remaining = await activeWorkspaceIds(); + expect(remaining.has(localWorkspaceId)).toBe(false); + expect(remaining.has(worktreeWorkspace.id)).toBe(true); + expect(existsSync(worktreeDir)).toBe(true); + + await ctx.client.archivePaseoWorktree({ + worktreePath: worktreeDir, + workspaceId: worktreeWorkspace.id, + }); +}, 60000); + +test("deleteWorktreeFromDisk keeps the worktree when a sibling workspace still references it", async () => { + const repoDir = createGitRepo(); + + const worktreeResult = await ctx.client.createWorkspace({ + backing: "worktree", + cwd: repoDir, + branch: "shared-worktree", + baseBranch: "main", + }); + const worktreeWorkspace = worktreeResult.workspace; + if (!worktreeWorkspace?.workspaceDirectory) { + throw new Error(worktreeResult.error ?? "Failed to create worktree workspace"); + } + const worktreeDir = worktreeWorkspace.workspaceDirectory; + expect(existsSync(worktreeDir)).toBe(true); + + // A second workspace records the SAME worktree directory as its cwd. + const siblingWorkspaceId = await createLocalWorkspace(worktreeDir, "sibling"); + expect(siblingWorkspaceId).not.toBe(worktreeWorkspace.id); + + // Archive the worktree-backed workspace with deleteWorktreeFromDisk true. + // It is NOT the last reference, so the directory must survive. + const archive = await ctx.client.archivePaseoWorktree({ + worktreePath: worktreeDir, + deleteWorktreeFromDisk: true, + }); + expect(archive.success).toBe(true); + + await expect + .poll(async () => (await activeWorkspaceIds()).has(worktreeWorkspace.id), { + timeout: 10000, + interval: 100, + }) + .toBe(false); + + const remaining = await activeWorkspaceIds(); + expect(remaining.has(worktreeWorkspace.id)).toBe(false); + expect(remaining.has(siblingWorkspaceId)).toBe(true); + expect(existsSync(worktreeDir)).toBe(true); +}, 60000); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 487b162f3..faa974bae 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -1,13 +1,11 @@ -import type { - PersistedWorkspaceRecord, - ProjectRegistry, - WorkspaceRegistry, -} from "./workspace-registry.js"; +import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; +// Archiving the last workspace of a project leaves the project as a first-class +// empty project — it persists until explicitly removed, so we never archive the +// parent project here. export async function archivePersistedWorkspaceRecord(input: { workspaceId: string; - workspaceRegistry: Pick; - projectRegistry: Pick; + workspaceRegistry: Pick; archivedAt?: string; }): Promise { const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId); @@ -21,12 +19,6 @@ export async function archivePersistedWorkspaceRecord(input: { const archivedAt = input.archivedAt ?? new Date().toISOString(); await input.workspaceRegistry.archive(input.workspaceId, archivedAt); - const activeSiblings = (await input.workspaceRegistry.list()).filter( - (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, - ); - if (activeSiblings.length === 0) { - await input.projectRegistry.archive(existingWorkspace.projectId, archivedAt); - } return existingWorkspace; } diff --git a/packages/server/src/server/workspace-create-errors.e2e.test.ts b/packages/server/src/server/workspace-create-errors.e2e.test.ts new file mode 100644 index 000000000..1752cf2ca --- /dev/null +++ b/packages/server/src/server/workspace-create-errors.e2e.test.ts @@ -0,0 +1,57 @@ +import { test, expect } from "vitest"; +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { DaemonClient } from "./test-utils/index.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; + +// workspace.create has four reject branches before it ever touches the +// registries; this pins each one's errorCode (or, for project-not-found, its +// message) as seen by the daemon client, so the CLI/app contract on top of them +// stays covered. +test("workspace.create surfaces each early-reject error branch", async () => { + const daemon = await createTestPaseoDaemon(); + const missingDir = path.join(tmpdir(), `paseo-workspace-create-missing-${Date.now()}`); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + // local backing without a cwd -> cwd_required + const cwdRequired = await client.createWorkspace({ backing: "local" }); + expect(cwdRequired.workspace).toBeNull(); + expect(cwdRequired.errorCode).toBe("cwd_required"); + + // local backing pointed at a path that does not exist -> directory_not_found + const directoryNotFound = await client.createWorkspace({ + backing: "local", + cwd: missingDir, + }); + expect(directoryNotFound.workspace).toBeNull(); + expect(directoryNotFound.errorCode).toBe("directory_not_found"); + expect(directoryNotFound.error).toContain(missingDir); + + // worktree backing without a cwd or projectId -> source_required + const sourceRequired = await client.createWorkspace({ backing: "worktree", branch: "feat" }); + expect(sourceRequired.workspace).toBeNull(); + expect(sourceRequired.errorCode).toBe("source_required"); + + // worktree backing with an unknown projectId -> project-not-found message + // (surfaced via the generic catch, so it carries an error but no errorCode) + const projectNotFound = await client.createWorkspace({ + backing: "worktree", + projectId: "proj-does-not-exist", + branch: "feat", + }); + expect(projectNotFound.workspace).toBeNull(); + expect(projectNotFound.error).toContain("Project not found: proj-does-not-exist"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(missingDir, { recursive: true, force: true }); + } +}, 180000); diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 0c8d74c22..4e881f356 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -42,10 +42,27 @@ class WorkspaceStatus { archivedAt: null, }; + // Second workspace sharing the SAME cwd as `workspace`. Created later so the + // deterministic-oldest fallback never attributes a stamped agent to it by cwd. + private readonly sameCwdWorkspace: PersistedWorkspaceRecord = { + workspaceId: "workspace-1-sibling", + projectId: this.project.projectId, + cwd: this.project.rootPath, + kind: "local_checkout", + displayName: "main-2", + createdAt: "2026-03-02T12:00:00.000Z", + updatedAt: "2026-03-02T12:00:00.000Z", + archivedAt: null, + }; + private readonly workspaces = [this.workspace]; private readonly agents: AgentSnapshotPayload[] = []; - private readonly terminals: Array<{ cwd: string; activity: TerminalActivity | null }> = []; + private readonly terminals: Array<{ + cwd: string; + workspaceId?: string; + activity: TerminalActivity | null; + }> = []; private readonly directory = new WorkspaceDirectory({ logger: createTestLogger(), projectRegistry: { list: async () => [this.project] }, @@ -77,6 +94,18 @@ class WorkspaceStatus { this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd })); } + hasSiblingWorkspaceSameCwd(): void { + this.workspaces.push(this.sameCwdWorkspace); + } + + // A root agent owned by a specific workspace, even though both same-cwd + // workspaces share the directory. Attribution must follow workspaceId. + hasStampedRootAgent(input: AgentState & { workspaceId: string }): void { + this.agents.push( + createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }), + ); + } + hasDelegatedAgent(input: AgentState): void { this.agents.push( createAgent({ @@ -124,6 +153,16 @@ class WorkspaceStatus { }); } + // A working terminal owned by a specific same-cwd workspace. Attribution must + // follow workspaceId, not the deterministic-oldest cwd fallback. + hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void { + this.terminals.push({ + cwd: this.workspace.cwd, + workspaceId: input.workspaceId, + activity: { state: "working", changedAt: input.changedAt }, + }); + } + hasWorkingTerminalInSubdirectory(changedAt: number): void { this.terminals.push({ cwd: `${this.workspace.cwd}/packages/app`, @@ -166,12 +205,15 @@ interface AgentState { attentionReason?: AgentSnapshotPayload["attentionReason"]; } -function createAgent(input: AgentState & { cwd: string; labels?: Record }) { +function createAgent( + input: AgentState & { cwd: string; labels?: Record; workspaceId?: string }, +) { const pendingPermissionCount = input.pendingPermissionCount ?? 0; return { id: input.id, provider: "codex", cwd: input.cwd, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), model: null, thinkingOptionId: null, effectiveThinkingOptionId: null, @@ -229,6 +271,46 @@ describe("WorkspaceDirectory", () => { await expect(workspace.workspaceStatus()).resolves.toBe("running"); }); + test("two same-cwd workspaces keep independent status, attributed by workspaceId", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasSiblingWorkspaceSameCwd(); + // The running agent is stamped to the SIBLING. A cwd-only fallback would + // attribute it to the older `workspace-1` and leave the sibling "done", so + // this asserts the running status follows workspaceId, not directory. + workspace.hasStampedRootAgent({ + id: "agent-a", + status: "running", + workspaceId: "workspace-1-sibling", + }); + workspace.hasStampedRootAgent({ + id: "agent-b", + status: "idle", + workspaceId: "workspace-1", + }); + + await expect(workspace.workspaceStatuses()).resolves.toEqual({ + "workspace-1": "done", + "workspace-1-sibling": "running", + }); + }); + + test("two same-cwd workspaces keep independent terminal status, attributed by workspaceId", async () => { + const workspace = new WorkspaceStatus(); + const changedAt = new Date(NOW).getTime(); + + workspace.hasSiblingWorkspaceSameCwd(); + // The working terminal is owned by the SIBLING. A cwd-only resolver would + // attribute it to the older `workspace-1`, so this asserts the running + // status follows the terminal's workspaceId, not the directory. + workspace.hasStampedWorkingTerminal({ workspaceId: "workspace-1-sibling", changedAt }); + + await expect(workspace.workspaceStatuses()).resolves.toEqual({ + "workspace-1": "done", + "workspace-1-sibling": "running", + }); + }); + test("running delegated child contributes running to the parent workspace, not its worktree", async () => { const workspace = new WorkspaceStatus(); @@ -313,3 +395,97 @@ describe("WorkspaceDirectory", () => { expect(descriptor.statusEnteredAt).toBe("2027-01-01T00:00:00.000Z"); }); }); + +describe("WorkspaceDirectory empty projects", () => { + function makeDirectory(input: { + projects: PersistedProjectRecord[]; + workspaces: PersistedWorkspaceRecord[]; + }): WorkspaceDirectory { + return new WorkspaceDirectory({ + logger: createTestLogger(), + projectRegistry: { list: async () => input.projects }, + workspaceRegistry: { list: async () => input.workspaces }, + listAgentPayloads: async () => [], + listTerminalActivityContributions: async () => [], + isProviderVisibleToClient: () => true, + buildWorkspaceDescriptor: async ({ workspace }) => ({ + id: workspace.workspaceId, + projectId: workspace.projectId, + projectDisplayName: "project", + projectCustomName: null, + projectRootPath: "/workspace/project", + workspaceDirectory: workspace.cwd, + projectKind: "non_git", + workspaceKind: workspace.kind, + name: workspace.displayName, + archivingAt: null, + status: "done", + activityAt: null, + diffStat: null, + gitRuntime: null, + githubRuntime: null, + }), + }); + } + + function project(input: Partial & { projectId: string }) { + return { + rootPath: `/workspace/${input.projectId}`, + kind: "non_git", + displayName: input.projectId, + customName: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + ...input, + } satisfies PersistedProjectRecord; + } + + test("surfaces a project with no active workspaces as an empty project", async () => { + const directory = makeDirectory({ + projects: [project({ projectId: "empty", customName: "Renamed" })], + workspaces: [], + }); + + const result = await directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "r1", + }); + + expect(result.entries).toEqual([]); + expect(result.emptyProjects).toEqual([ + { + projectId: "empty", + projectDisplayName: "Renamed", + projectCustomName: "Renamed", + projectRootPath: "/workspace/empty", + projectKind: "non_git", + }, + ]); + }); + + test("excludes projects that still have an active workspace", async () => { + const directory = makeDirectory({ + projects: [project({ projectId: "with-ws" }), project({ projectId: "empty" })], + workspaces: [ + { + workspaceId: "ws-1", + projectId: "with-ws", + cwd: "/workspace/with-ws", + kind: "directory", + displayName: "main", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }, + ], + }); + + const result = await directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "r1", + }); + + expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]); + }); +}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 411430747..85fb837ea 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -15,7 +15,11 @@ import { import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; import { SortablePager } from "./pagination/sortable-pager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; -import { resolveActiveWorkspaceRecordForCwd } from "./workspace-registry-model.js"; +import { resolveProjectDisplayName } from "./workspace-registry.js"; +import { + resolveActiveWorkspaceRecordForCwd, + resolveWorkspaceIdForRecord, +} from "./workspace-registry-model.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; type WorkspaceIdResolver = (cwd: string) => string | undefined; @@ -52,6 +56,7 @@ type FetchWorkspacesResponsePayload = Extract< >["payload"]; type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number]; type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"]; +type WorkspaceProjectDescriptor = FetchWorkspacesResponsePayload["emptyProjects"][number]; export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter; @@ -92,7 +97,7 @@ export interface WorkspaceDirectoryDeps { }; listAgentPayloads(): Promise; listTerminalActivityContributions(): Promise< - Array<{ cwd: string; activity: TerminalActivity | null }> + Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> >; isProviderVisibleToClient(provider: string): boolean; buildWorkspaceDescriptor(input: { @@ -210,9 +215,7 @@ export class WorkspaceDirectory { ); const descriptorsByWorkspaceId = new Map(); const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; - const workspaceIdsByDirectory = new Map( - activeRecords.map((workspace) => [resolve(workspace.cwd), workspace.workspaceId] as const), - ); + const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId)); const resolveActiveWorkspaceIdForCwd: WorkspaceIdResolver = (cwd) => resolveActiveWorkspaceRecordForCwd(cwd, activeRecords)?.workspaceId; @@ -239,6 +242,62 @@ export class WorkspaceDirectory { const activeAgents = agents.filter( (agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider), ); + this.applyAgentBucketContributions({ + activeAgents, + activeRecords, + activeWorkspaceIds, + descriptorsByWorkspaceId, + }); + + // Terminal activity contributions: working terminal → running bucket. + const terminalEntriesByWorkspaceId = this.applyTerminalContributions( + terminalContributions, + activeRecords, + resolveActiveWorkspaceIdForCwd, + descriptorsByWorkspaceId, + ); + + const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId( + activeAgents, + activeRecords, + activeWorkspaceIds, + ); + + // Resolve the workspace-level `statusEnteredAt` (see aggregate semantics + // on `resolveStatusEnteredAt`). + const nowIso = new Date().toISOString(); + for (const [workspaceId, descriptor] of descriptorsByWorkspaceId) { + const contributingAgents = contributingAgentsByWorkspaceId.get(workspaceId) ?? []; + const terminalEntries = terminalEntriesByWorkspaceId.get(workspaceId) ?? []; + const result = this.resolveStatusEnteredAt({ + workspaceId, + winningBucket: descriptor.status, + contributingAgents, + terminalEntries, + previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null, + nowIso, + }); + descriptor.statusEnteredAt = result.statusEnteredAt; + if (result.recordUpdate) { + this.bucketHistoryByWorkspaceId.set(workspaceId, result.recordUpdate); + } else if (result.recordDelete) { + this.bucketHistoryByWorkspaceId.delete(workspaceId); + } + } + + return descriptorsByWorkspaceId; + } + + // Aggregate each agent's state bucket into its owning workspace descriptor, + // keeping the highest-priority bucket. Delegated agents contribute to their + // delegation root's workspace; their own status is ignored unless running. + private applyAgentBucketContributions(params: { + activeAgents: AgentSnapshotPayload[]; + activeRecords: PersistedWorkspaceRecord[]; + activeWorkspaceIds: ReadonlySet; + descriptorsByWorkspaceId: Map; + }): void { + const { activeAgents, activeRecords, activeWorkspaceIds, descriptorsByWorkspaceId } = params; const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent] as const)); for (const agent of activeAgents) { @@ -263,8 +322,8 @@ export class WorkspaceDirectory { }); } - const workspaceId = workspaceIdsByDirectory.get(resolve(workspaceAgent.cwd)); - if (workspaceId === undefined) { + const workspaceId = resolveWorkspaceIdForRecord(workspaceAgent, activeRecords); + if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) { continue; } const existing = descriptorsByWorkspaceId.get(workspaceId); @@ -278,48 +337,17 @@ export class WorkspaceDirectory { existing.status = bucket; } } - - // Terminal activity contributions: working terminal → running bucket. - const terminalEntriesByWorkspaceId = this.applyTerminalContributions( - terminalContributions, - resolveActiveWorkspaceIdForCwd, - descriptorsByWorkspaceId, - ); - - // Resolve the workspace-level `statusEnteredAt` (see aggregate semantics - // on `resolveStatusEnteredAt`). - const nowIso = new Date().toISOString(); - for (const [workspaceId, descriptor] of descriptorsByWorkspaceId) { - const contributingAgents = agents.filter( - (agent) => - !agent.archivedAt && - this.deps.isProviderVisibleToClient(agent.provider) && - workspaceIdsByDirectory.get(resolve(agent.cwd)) === workspaceId, - ); - const terminalEntries = terminalEntriesByWorkspaceId.get(workspaceId) ?? []; - const result = this.resolveStatusEnteredAt({ - workspaceId, - winningBucket: descriptor.status, - contributingAgents, - terminalEntries, - previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null, - nowIso, - }); - descriptor.statusEnteredAt = result.statusEnteredAt; - if (result.recordUpdate) { - this.bucketHistoryByWorkspaceId.set(workspaceId, result.recordUpdate); - } else if (result.recordDelete) { - this.bucketHistoryByWorkspaceId.delete(workspaceId); - } - } - - return descriptorsByWorkspaceId; } // Apply working terminal contributions to descriptor statuses and build a map // of terminal timestamp entries per workspace for use in `resolveStatusEnteredAt`. private applyTerminalContributions( - terminalContributions: Array<{ cwd: string; activity: TerminalActivity | null }>, + terminalContributions: Array<{ + cwd: string; + workspaceId?: string; + activity: TerminalActivity | null; + }>, + activeRecords: PersistedWorkspaceRecord[], resolveWorkspaceIdForCwd: WorkspaceIdResolver, descriptorsByWorkspaceId: Map, ): Map> { @@ -327,7 +355,7 @@ export class WorkspaceDirectory { string, Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }> >(); - for (const { cwd, activity } of terminalContributions) { + for (const { cwd, workspaceId: contributedWorkspaceId, activity } of terminalContributions) { if (!activity) { continue; } @@ -339,8 +367,10 @@ export class WorkspaceDirectory { } else { continue; } - const workspaceId = resolveWorkspaceIdForCwd(cwd); - if (workspaceId === undefined) { + const workspaceId = + resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ?? + resolveWorkspaceIdForCwd(cwd); + if (workspaceId == null) { continue; } const existing = descriptorsByWorkspaceId.get(workspaceId); @@ -468,6 +498,32 @@ export class WorkspaceDirectory { return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); } + // Project parents that have no active workspaces. These persist as first-class + // empty projects so the sidebar can render an empty project row with a + // "+ New workspace" affordance. + async listEmptyProjects(): Promise { + const [persistedWorkspaces, persistedProjects] = await Promise.all([ + this.deps.workspaceRegistry.list(), + this.deps.projectRegistry.list(), + ]); + const projectIdsWithActiveWorkspaces = new Set( + persistedWorkspaces + .filter((workspace) => !workspace.archivedAt) + .map((workspace) => workspace.projectId), + ); + return persistedProjects + .filter( + (project) => !project.archivedAt && !projectIdsWithActiveWorkspaces.has(project.projectId), + ) + .map((project) => ({ + projectId: project.projectId, + projectDisplayName: resolveProjectDisplayName(project), + projectCustomName: project.customName ?? null, + projectRootPath: project.rootPath, + projectKind: project.kind, + })); + } + async listDescriptors(): Promise { return Array.from( ( @@ -506,6 +562,7 @@ export class WorkspaceDirectory { async listFetchEntries(request: FetchWorkspacesRequestMessage): Promise<{ entries: FetchWorkspacesResponseEntry[]; + emptyProjects: WorkspaceProjectDescriptor[]; pageInfo: FetchWorkspacesResponsePageInfo; }> { const filter = request.filter; @@ -532,6 +589,15 @@ export class WorkspaceDirectory { ? this.pager.encode(pagedEntries[pagedEntries.length - 1], sort) : null; + // Empty project parents ride only on the first page so the sidebar can render + // them without them being duplicated across pagination. + const projectIdFilter = filter?.projectId?.trim(); + const emptyProjects = cursorToken + ? [] + : (await this.listEmptyProjects()).filter( + (project) => !projectIdFilter || project.projectId === projectIdFilter, + ); + this.deps.logger.debug( { requestId: request.requestId, @@ -549,6 +615,7 @@ export class WorkspaceDirectory { return { entries: pagedEntries, + emptyProjects, pageInfo: { nextCursor, prevCursor: request.page?.cursor ?? null, @@ -558,6 +625,24 @@ export class WorkspaceDirectory { } } +function groupAgentsByWorkspaceId( + agents: AgentSnapshotPayload[], + activeRecords: PersistedWorkspaceRecord[], + activeWorkspaceIds: ReadonlySet, +): Map { + const byWorkspaceId = new Map(); + for (const agent of agents) { + const workspaceId = resolveWorkspaceIdForRecord(agent, activeRecords); + if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) { + continue; + } + const entries = byWorkspaceId.get(workspaceId) ?? []; + entries.push(agent); + byWorkspaceId.set(workspaceId, entries); + } + return byWorkspaceId; +} + function resolveDelegationRootAgent( agent: AgentSnapshotPayload, activeAgentsById: ReadonlyMap, diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 8fa782dc1..9777853d4 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -209,7 +209,7 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get("w1")!.archivedAt).toBeTruthy(); }); - test("archives orphaned projects after all workspaces are archived", async () => { + test("keeps a project active after all its workspaces are archived", async () => { const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); projects.set( @@ -245,8 +245,8 @@ describe("WorkspaceReconciliationService", () => { const result = await service.runOnce(); const projChange = result.changesApplied.find((c) => c.kind === "project_archived"); - expect(projChange).toBeDefined(); - expect(projects.get("p1")!.archivedAt).toBeTruthy(); + expect(projChange).toBeUndefined(); + expect(projects.get("p1")!.archivedAt).toBeFalsy(); }); test("updates project kind when a directory becomes a git repo", async () => { @@ -456,7 +456,7 @@ describe("WorkspaceReconciliationService", () => { expect.objectContaining({ kind: "project_archived", projectId: repoDir, - reason: "no_active_workspaces", + reason: "merged_duplicate", }), ]), ); @@ -753,7 +753,7 @@ describe("WorkspaceReconciliationService", () => { { message: "Workspace reconciliation applied changes", payload: expect.objectContaining({ - changeCount: 2, + changeCount: 1, changes: expect.arrayContaining([ { kind: "workspace_archived", @@ -761,17 +761,12 @@ describe("WorkspaceReconciliationService", () => { directory: "/tmp/does-not-exist-log-test", reason: "directory_missing", }, - { - kind: "project_archived", - projectId: "p1", - directory: "/tmp/does-not-exist-log-test", - reason: "no_active_workspaces", - }, ]), durationMs: expect.any(Number), }), }, ]); + expect(projects.get("p1")!.archivedAt).toBeFalsy(); }); test("does not log reconciliation when no changes are applied", async () => { diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 26d0e1966..dea5243fc 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -159,29 +159,19 @@ export class WorkspaceReconciliationService { // 2. Merge duplicate active project records that point at the same repo root. await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes); - // 3. Archive orphaned projects (all workspaces archived/removed) - const orphanedProjects = activeProjects.filter((project) => { - const siblings = workspacesByProject.get(project.projectId) ?? []; - return siblings.length === 0; - }); - await Promise.all( - orphanedProjects.map(async (project) => { - const timestamp = new Date().toISOString(); - await this.projectRegistry.archive(project.projectId, timestamp); - changes.push({ - kind: "project_archived", - projectId: project.projectId, - directory: project.rootPath, - reason: "no_active_workspaces", - }); - }), + // 3. Reconcile git metadata for active projects whose directories still exist. + // A project with zero active workspaces is a first-class empty project — it + // persists until explicitly removed and still reconciles its own metadata. + // Skip projects archived earlier in this pass (e.g. merged duplicates) so we + // don't resurrect them by upserting a stale, non-archived copy. + const archivedProjectIds = new Set( + changes + .filter((change) => change.kind === "project_archived") + .map((change) => change.projectId), ); - - // 4. Reconcile git metadata for active projects whose directories still exist const projectsToReconcile = activeProjects.filter((project) => { if (project.archivedAt) return false; - const siblings = workspacesByProject.get(project.projectId) ?? []; - if (siblings.length === 0) return false; + if (archivedProjectIds.has(project.projectId)) return false; if (!existsSync(project.rootPath)) return false; return true; }); @@ -263,6 +253,14 @@ export class WorkspaceReconciliationService { for (const project of duplicateProjects) { workspacesByProject.set(project.projectId, []); + const timestamp = new Date().toISOString(); + await this.projectRegistry.archive(project.projectId, timestamp); + changes.push({ + kind: "project_archived", + projectId: project.projectId, + directory: project.rootPath, + reason: "merged_duplicate", + }); } } } diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 7bbded841..a47d2a4ef 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -9,18 +9,24 @@ import { deriveWorkspaceKind, detectStaleWorkspaces, generateWorkspaceId, + resolveWorkspaceIdForRecord, } from "./workspace-registry-model.js"; import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; -function createWorkspaceRecord(cwd: string, workspaceId: string) { +function createWorkspaceRecord( + cwd: string, + workspaceId: string, + overrides?: { createdAt?: string; archivedAt?: string }, +) { return createPersistedWorkspaceRecord({ workspaceId, projectId: workspaceId, cwd, kind: "directory", displayName: basename(cwd) || cwd, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", + createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", + updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", + archivedAt: overrides?.archivedAt ?? null, }); } @@ -226,3 +232,65 @@ describe("git worktree grouping", () => { ).toBe("worktree"); }); }); + +describe("resolveWorkspaceIdForRecord", () => { + test("resolves a stamped record to its workspaceId, ignoring cwd matches", () => { + const workspaces = [ + createWorkspaceRecord("/tmp/repo", "ws-a"), + createWorkspaceRecord("/tmp/repo", "ws-b"), + ]; + + const resolved = resolveWorkspaceIdForRecord( + { workspaceId: "ws-b", cwd: "/tmp/repo" }, + workspaces, + ); + + expect(resolved).toBe("ws-b"); + }); + + test("falls back to the single cwd match for a legacy record without workspaceId", () => { + const workspaces = [ + createWorkspaceRecord("/tmp/repo", "ws-only"), + createWorkspaceRecord("/tmp/other", "ws-other"), + ]; + + const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces); + + expect(resolved).toBe("ws-only"); + }); + + test("resolves a legacy record with multiple cwd matches to the deterministic oldest", () => { + const workspaces = [ + createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }), + createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }), + ]; + + const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces); + + expect(resolved).toBe("ws-older"); + }); + + test("returns null for a legacy record with no cwd match", () => { + const workspaces = [createWorkspaceRecord("/tmp/other", "ws-other")]; + + const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces); + + expect(resolved).toBeNull(); + }); + + test("falls back to cwd when the stamped workspaceId points at an archived workspace", () => { + const workspaces = [ + createWorkspaceRecord("/tmp/repo", "ws-archived", { + archivedAt: "2026-03-05T00:00:00.000Z", + }), + createWorkspaceRecord("/tmp/repo", "ws-live"), + ]; + + const resolved = resolveWorkspaceIdForRecord( + { workspaceId: "ws-archived", cwd: "/tmp/repo" }, + workspaces, + ); + + expect(resolved).toBe("ws-live"); + }); +}); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 39e83d59c..ef1084223 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -34,6 +34,42 @@ export function generateWorkspaceId(): string { return `wks_${randomBytes(8).toString("hex")}`; } +// COMPAT(workspaceOwnership): added in v0.1.97, drop the gate when floor >= v0.1.97. +// Resolves the owning workspace for a record (agent/terminal) that may predate +// workspaceId stamping. New records always carry workspaceId and hit the first +// branch; legacy records fall back to cwd matching. When several active +// workspaces share the same cwd, duplicate-cwd disambiguation is impossible from +// cwd alone, so we pick the deterministic oldest one. +export function resolveWorkspaceIdForRecord( + record: { workspaceId?: string; cwd: string }, + activeWorkspaces: Iterable, +): string | null { + const workspaces = Array.from(activeWorkspaces); + if (record.workspaceId) { + const exact = workspaces.find( + (workspace) => !workspace.archivedAt && workspace.workspaceId === record.workspaceId, + ); + if (exact) { + return exact.workspaceId; + } + } + + const resolvedCwd = resolve(record.cwd); + const cwdMatches = workspaces.filter( + (workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd, + ); + if (cwdMatches.length === 1) { + return cwdMatches[0].workspaceId; + } + if (cwdMatches.length > 1) { + return cwdMatches.reduce((oldest, candidate) => + candidate.createdAt < oldest.createdAt ? candidate : oldest, + ).workspaceId; + } + + return null; +} + export function resolveActiveWorkspaceRecordForCwd( cwd: string, workspaces: Iterable, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index f9b8b9158..246befc74 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -10,8 +10,39 @@ import { createPersistedWorkspaceRecord, FileBackedProjectRegistry, FileBackedWorkspaceRegistry, + resolveWorkspaceDisplayName, + resolveWorkspaceName, } from "./workspace-registry.js"; +describe("resolveWorkspaceName", () => { + test("prefers the user-set title over the derived display name", () => { + expect( + resolveWorkspaceName({ title: "Payments work", derivedDisplayName: "feature/payments" }), + ).toBe("Payments work"); + }); + + test("falls back to the derived display name when there is no title", () => { + expect(resolveWorkspaceName({ title: null, derivedDisplayName: "feature/payments" })).toBe( + "feature/payments", + ); + }); + + test("resolveWorkspaceDisplayName applies the same rule over the persisted record", () => { + const record = createPersistedWorkspaceRecord({ + workspaceId: "ws-1", + projectId: "proj-1", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + title: "Renamed", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(resolveWorkspaceDisplayName(record)).toBe("Renamed"); + expect(resolveWorkspaceDisplayName({ ...record, title: null })).toBe("main"); + }); +}); + describe("workspace registries", () => { let tmpDir: string; let projectRegistry: FileBackedProjectRegistry; diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 0b2139061..3890e93b8 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -29,6 +29,14 @@ const PersistedWorkspaceRecordSchema = z.object({ cwd: z.string(), kind: z.enum(["local_checkout", "worktree", "directory"]), displayName: z.string(), + // User-set title layered over the derived displayName. In Model B the title is + // the workspace identity; branch/directory are backing metadata. Reconciliation + // never touches this. Null means "use the derived displayName". + title: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -227,12 +235,28 @@ export function createPersistedWorkspaceRecord(input: { cwd: string; kind: PersistedWorkspaceKind; displayName: string; + title?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; }): PersistedWorkspaceRecord { return PersistedWorkspaceRecordSchema.parse({ ...input, + title: input.title ?? null, archivedAt: input.archivedAt ?? null, }); } + +// The single workspace-name rule: the user-set title always wins; otherwise fall +// back to the freshest available derived display name (a live branch snapshot when +// the caller has one, the persisted displayName otherwise). +export function resolveWorkspaceName(input: { + title: string | null; + derivedDisplayName: string; +}): string { + return input.title ?? input.derivedDisplayName; +} + +export function resolveWorkspaceDisplayName(record: PersistedWorkspaceRecord): string { + return resolveWorkspaceName({ title: record.title, derivedDisplayName: record.displayName }); +} diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts new file mode 100644 index 000000000..709420a11 --- /dev/null +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -0,0 +1,195 @@ +import { test, expect } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { DaemonClient } from "./test-utils/index.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { getAskModeConfig } from "./daemon-e2e/agent-configs.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, +} from "./workspace-registry.js"; + +const WORKSPACE_A = "wks_same_cwd_a"; +const WORKSPACE_B = "wks_same_cwd_b"; + +// Seed two active workspaces that share one cwd, so we can prove Phase 1 +// attributes agents, terminals, and status by workspaceId rather than by +// directory. Both registry files must exist on disk before the daemon starts: +// bootstrapWorkspaceRegistries skips materialization when both files are +// present, leaving these seeded records untouched. +function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } { + const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-same-cwd-home-")); + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-same-cwd-dir-")); + const projectsDir = path.join(paseoHomeRoot, ".paseo", "projects"); + mkdirSync(projectsDir, { recursive: true }); + + const project = createPersistedProjectRecord({ + projectId: "prj_same_cwd", + rootPath: cwd, + kind: "non_git", + displayName: path.basename(cwd), + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + const workspaceA = createPersistedWorkspaceRecord({ + workspaceId: WORKSPACE_A, + projectId: project.projectId, + cwd, + kind: "directory", + displayName: "workspace-a", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + // Created later so the deterministic-oldest cwd fallback would never pick B: + // any correct attribution to B must follow the stamped workspaceId. + const workspaceB = createPersistedWorkspaceRecord({ + workspaceId: WORKSPACE_B, + projectId: project.projectId, + cwd, + kind: "directory", + displayName: "workspace-b", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + }); + + writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project])); + writeFileSync( + path.join(projectsDir, "workspaces.json"), + JSON.stringify([workspaceA, workspaceB]), + ); + + return { paseoHomeRoot, cwd }; +} + +async function statusByWorkspaceId(client: DaemonClient): Promise> { + const workspaces = await client.fetchWorkspaces(); + return new Map(workspaces.entries.map((entry) => [entry.id, entry.status])); +} + +test("two workspaces sharing one cwd stay isolated by workspaceId", async () => { + const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces(); + const daemon = await createTestPaseoDaemon({ paseoHomeRoot }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + // Both seeded workspaces are visible and start with no contributing agents. + expect(await statusByWorkspaceId(client)).toEqual( + new Map([ + [WORKSPACE_A, "done"], + [WORKSPACE_B, "done"], + ]), + ); + + // 1. Agent created in workspace A carries workspaceId A and only moves A's + // status. Ask mode + a write parks the agent on a pending permission, + // which is the deterministic "needs_input" signal for A. + const agentA = await client.createAgent({ + ...getAskModeConfig("codex"), + cwd, + workspaceId: WORKSPACE_A, + title: "Workspace A agent", + }); + expect(agentA.workspaceId).toBe(WORKSPACE_A); + + await client.sendMessage( + agentA.id, + 'Use your shell tool to run: `printf "ok" > a.txt`. Request permission and wait.', + ); + const parkedA = await client.waitForFinish(agentA.id, 60000); + expect(parkedA.status).toBe("permission"); + + const fetchedA = await client.fetchAgent(agentA.id); + expect(fetchedA?.agent.workspaceId).toBe(WORKSPACE_A); + + expect(await statusByWorkspaceId(client)).toEqual( + new Map([ + [WORKSPACE_A, "needs_input"], + [WORKSPACE_B, "done"], + ]), + ); + + // 2. Terminal created in A is delivered to A's directory subscription and to + // A's list, but never to B's — exercises the workspaceId terminal filter. + const terminalSnapshotsByWorkspace = new Map>(); + const unsubscribeTerminals = client.on("terminals_changed", (message) => { + if (message.type !== "terminals_changed") { + return; + } + for (const terminal of message.payload.terminals) { + const seen = terminalSnapshotsByWorkspace.get(terminal.workspaceId) ?? new Set(); + seen.add(terminal.id); + terminalSnapshotsByWorkspace.set(terminal.workspaceId, seen); + } + }); + + client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_A }); + client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_B }); + + const createdTerminal = await client.createTerminal(cwd, "A terminal", undefined, { + workspaceId: WORKSPACE_A, + }); + expect(createdTerminal.terminal?.workspaceId).toBe(WORKSPACE_A); + const terminalId = createdTerminal.terminal?.id; + if (!terminalId) { + throw new Error("Expected a created terminal id"); + } + + // A's directory subscription receives a snapshot attributing the terminal + // to A. Poll the observed snapshot state instead of sleeping for a fixed + // window, so the assertion never races the daemon's snapshot push. + await expect + .poll(() => terminalSnapshotsByWorkspace.get(WORKSPACE_A)?.has(terminalId) ?? false) + .toBe(true); + unsubscribeTerminals(); + + // No snapshot ever attributed the terminal to B (the only other same-cwd + // workspace). + expect(terminalSnapshotsByWorkspace.get(WORKSPACE_B)?.has(terminalId) ?? false).toBe(false); + + const listForA = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_A }); + expect(listForA.terminals.some((terminal) => terminal.id === terminalId)).toBe(true); + + const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B }); + expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false); + + // 3. Agent created in workspace B carries workspaceId B and moves only B's + // status. A keeps its own pending-permission state independently. + const agentB = await client.createAgent({ + ...getAskModeConfig("codex"), + cwd, + workspaceId: WORKSPACE_B, + title: "Workspace B agent", + }); + expect(agentB.workspaceId).toBe(WORKSPACE_B); + + await client.sendMessage( + agentB.id, + 'Use your shell tool to run: `printf "ok" > b.txt`. Request permission and wait.', + ); + const parkedB = await client.waitForFinish(agentB.id, 60000); + expect(parkedB.status).toBe("permission"); + + const fetchedB = await client.fetchAgent(agentB.id); + expect(fetchedB?.agent.workspaceId).toBe(WORKSPACE_B); + + expect(await statusByWorkspaceId(client)).toEqual( + new Map([ + [WORKSPACE_A, "needs_input"], + [WORKSPACE_B, "needs_input"], + ]), + ); + + await client.killTerminal(terminalId); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 180000); diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts index a30aa14e2..a0a796c83 100644 --- a/packages/server/src/server/worktree-bootstrap.posix.test.ts +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -157,6 +157,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { await runAsyncWorktreeBootstrap({ agentId: "agent-test", + workspaceId: "ws-agent-test", worktree: worktreeBootstrap.worktree, shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: null, @@ -271,6 +272,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { const persisted: AgentTimelineItem[] = []; await runAsyncWorktreeBootstrap({ agentId: "agent-carriage-return", + workspaceId: "ws-carriage-return", worktree: worktreeBootstrap.worktree, shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: null, @@ -331,9 +333,11 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { const registeredEnvs: Array<{ cwd: string; env: Record }> = []; const createTerminalEnvs: Record[] = []; + const createTerminalWorkspaceIds: Array = []; const persisted: AgentTimelineItem[] = []; await runAsyncWorktreeBootstrap({ agentId: "agent-shared-runtime-port", + workspaceId: "ws-shared-runtime-port", worktree: worktreeBootstrap.worktree, shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: { @@ -342,6 +346,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { }, async createTerminal(options) { createTerminalEnvs.push(options.env ?? {}); + createTerminalWorkspaceIds.push(options.workspaceId); return { id: "term-1", name: options.name ?? "Terminal", @@ -398,6 +403,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort); expect(createTerminalEnvs.length).toBeGreaterThan(0); expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort); + expect(createTerminalWorkspaceIds).toEqual(["ws-shared-runtime-port"]); const terminalToolCall = persisted.find( (item): item is Extract => diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index b2624d173..c878ef138 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -119,6 +119,7 @@ describe("runAsyncWorktreeBootstrap", () => { await expect( runAsyncWorktreeBootstrap({ agentId: "agent-live-failure", + workspaceId: "ws-live-failure", worktree: worktreeBootstrap.worktree, shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: null, @@ -169,6 +170,7 @@ describe("runAsyncWorktreeBootstrap", () => { const persisted: AgentTimelineItem[] = []; await runAsyncWorktreeBootstrap({ agentId: "agent-large-output", + workspaceId: "ws-large-output", worktree: worktreeBootstrap.worktree, shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: null, @@ -238,6 +240,7 @@ describe("runAsyncWorktreeBootstrap", () => { await runAsyncWorktreeBootstrap({ agentId: "agent-terminal-readiness", + workspaceId: "ws-terminal-readiness", worktree: worktreeBootstrap.worktree, shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: { diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 35da53ee7..8dd453dc4 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -40,6 +40,9 @@ export interface WorktreeBootstrapTerminalResult { export interface RunAsyncWorktreeBootstrapOptions { agentId: string; + // Workspace the bootstrapped terminals belong to. Stamping it lets + // workspaceId-scoped archive tear these terminals down. + workspaceId: string; worktree: WorktreeConfig; shouldBootstrap?: boolean; terminalManager: TerminalManager | null; @@ -540,6 +543,7 @@ async function runWorktreeTerminalBootstrap( cwd: options.worktree.worktreePath, name: spec.name, env: runtimeEnv, + workspaceId: options.workspaceId, }); await waitForTerminalBootstrapReadiness(terminal); terminal.send({ diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 587e0e4bf..1fdbfa8e5 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -14,7 +14,10 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import pino, { type Logger } from "pino"; import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js"; -import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js"; +import { + archivePaseoWorktree, + killTerminalsForWorkspace as killWorkspaceTerminals, +} from "./paseo-worktree-archive-service.js"; import { buildAgentSessionConfig, createPaseoWorktreeWorkflow, @@ -298,12 +301,17 @@ function createPaseoWorktreeForTest(options: { }; } -function createManagedAgentForArchive(input: { id: string; cwd: string }): ManagedAgent { +function createManagedAgentForArchive(input: { + id: string; + cwd: string; + workspaceId?: string; +}): ManagedAgent { const now = new Date(); return { id: input.id, provider: "codex", cwd: input.cwd, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), capabilities: { supportsStreaming: false, supportsSessionPersistence: false, @@ -489,6 +497,7 @@ function createAgentStorageStub(): Pick { function createWorkspaceArchivingDeps() { return { resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"), + listActiveWorkspaces: vi.fn(async () => []), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), markWorkspaceArchiving: vi.fn(), clearWorkspaceArchiving: vi.fn(), @@ -1708,18 +1717,59 @@ describe("archivePaseoWorktree", () => { } }); - function createIsPathWithinRoot() { - return (rootPath: string, candidatePath: string) => { - const normalizedRoot = path.resolve(rootPath); - const normalizedCandidate = path.resolve(candidatePath); - return ( - normalizedCandidate === normalizedRoot || - normalizedCandidate.startsWith(normalizedRoot + path.sep) - ); + function createWorkspaceRecord(input: { + workspaceId: string; + cwd: string; + repoDir: string; + displayName: string; + }): PersistedWorkspaceRecord { + return { + workspaceId: input.workspaceId, + projectId: input.repoDir, + cwd: input.cwd, + kind: "worktree", + displayName: input.displayName, + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + archivedAt: null, }; } - test("runs agent close and terminal teardown concurrently and removes the worktree", async () => { + function createTerminalSessionStub(input: { + id: string; + cwd: string; + workspaceId?: string; + }): TerminalSession { + return { + id: input.id, + cwd: input.cwd, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + } as unknown as TerminalSession; + } + + // Minimal terminal manager that only supports the queries archive performs: + // enumerate directories and fetch terminals filtered by owning workspaceId. + function createArchiveTerminalManagerStub( + sessions: TerminalSession[], + killed: string[], + ): TerminalManager { + return { + listDirectories: () => Array.from(new Set(sessions.map((session) => session.cwd))), + getTerminals: async (cwd: string, options?: { workspaceId?: string }) => + sessions.filter( + (session) => + session.cwd === cwd && + (options?.workspaceId === undefined || + session.workspaceId === undefined || + session.workspaceId === options.workspaceId), + ), + killTerminalAndWait: async (terminalId: string) => { + killed.push(terminalId); + }, + } as unknown as TerminalManager; + } + + test("runs agent close and terminal teardown concurrently, scoped to the workspace", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -1732,6 +1782,7 @@ describe("archivePaseoWorktree", () => { runSetup: false, paseoHome, }); + const workspaceId = "ws-archive-parallel"; const teardownStartTimes: Record = {}; const teardownEndTimes: Record = {}; const archiveAgentSpy = vi.fn(async (agentId: string) => { @@ -1743,7 +1794,7 @@ describe("archivePaseoWorktree", () => { const archiveSnapshotSpy = vi.fn(async () => { throw new Error("not expected for live agents"); }); - const killTerminalsUnderPath = vi.fn(async () => { + const killTerminalsForWorkspace = vi.fn(async () => { teardownStartTimes.__terminals = Date.now(); await new Promise((resolve) => setTimeout(resolve, 100)); teardownEndTimes.__terminals = Date.now(); @@ -1755,8 +1806,8 @@ describe("archivePaseoWorktree", () => { github: createGitHubServiceStub(), agentManager: { listAgents: () => [ - createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath }), - createManagedAgentForArchive({ id: "agent-2", cwd: created.worktreePath }), + createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath, workspaceId }), + createManagedAgentForArchive({ id: "agent-2", cwd: created.worktreePath, workspaceId }), ], archiveAgent: archiveAgentSpy, archiveSnapshot: archiveSnapshotSpy, @@ -1764,8 +1815,10 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), ...createWorkspaceArchivingDeps(), - isPathWithinRoot: createIsPathWithinRoot(), - killTerminalsUnderPath, + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === created.worktreePath ? workspaceId : null, + ), + killTerminalsForWorkspace, sessionLogger: createLogger(), }, { @@ -1776,10 +1829,12 @@ describe("archivePaseoWorktree", () => { ); expect(archivedAgents).toEqual(expect.arrayContaining(["agent-1", "agent-2"])); - expect(existsSync(created.worktreePath)).toBe(false); expect(archiveAgentSpy).toHaveBeenCalledTimes(2); expect(archiveSnapshotSpy).not.toHaveBeenCalled(); - expect(killTerminalsUnderPath).toHaveBeenCalledWith(created.worktreePath); + expect(killTerminalsForWorkspace).toHaveBeenCalledWith(workspaceId); + + // Archiving must never delete the worktree from disk. + expect(existsSync(created.worktreePath)).toBe(true); // All teardown work must overlap — sequential would take ~300ms, parallel ~100ms. const starts = Object.values(teardownStartTimes); @@ -1789,6 +1844,112 @@ describe("archivePaseoWorktree", () => { expect(maxEnd - minStart).toBeLessThan(220); }); + test("tears down only the target workspace; a sibling sharing the cwd survives", async () => { + const { tempDir, repoDir } = createGitRepo(); + cleanupPaths.push(tempDir); + + const paseoHome = path.join(tempDir, ".paseo"); + const created = await createLegacyWorktreeForTest({ + branchName: "archive-siblings", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "archive-siblings", + runSetup: false, + paseoHome, + }); + const sharedCwd = created.worktreePath; + const workspaceA = "ws-A"; + const workspaceB = "ws-B"; + + const liveAgents = [ + createManagedAgentForArchive({ id: "agent-A", cwd: sharedCwd, workspaceId: workspaceA }), + createManagedAgentForArchive({ id: "agent-B", cwd: sharedCwd, workspaceId: workspaceB }), + ]; + const storedRecords: StoredAgentRecord[] = [ + { + id: "snapshot-A", + provider: "codex", + cwd: sharedCwd, + workspaceId: workspaceA, + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + labels: {}, + lastStatus: "closed", + config: null, + } as unknown as StoredAgentRecord, + { + id: "snapshot-B", + provider: "codex", + cwd: sharedCwd, + workspaceId: workspaceB, + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + labels: {}, + lastStatus: "closed", + config: null, + } as unknown as StoredAgentRecord, + ]; + + const killedTerminals: string[] = []; + const terminalManager = createArchiveTerminalManagerStub( + [ + createTerminalSessionStub({ id: "term-A", cwd: sharedCwd, workspaceId: workspaceA }), + createTerminalSessionStub({ id: "term-B", cwd: sharedCwd, workspaceId: workspaceB }), + ], + killedTerminals, + ); + + const archivedAgentIds: string[] = []; + const archivedSnapshotIds: string[] = []; + const archivedWorkspaceRecords: string[] = []; + + const archivedAgents = await archivePaseoWorktree( + { + paseoHome, + github: createGitHubServiceStub(), + agentManager: { + listAgents: () => liveAgents, + archiveAgent: async (agentId: string) => { + archivedAgentIds.push(agentId); + return { archivedAt: new Date().toISOString() }; + }, + archiveSnapshot: async (agentId: string) => { + archivedSnapshotIds.push(agentId); + return storedRecords.find((record) => record.id === agentId)!; + }, + }, + agentStorage: { list: async () => storedRecords }, + archiveWorkspaceRecord: async (id: string) => { + archivedWorkspaceRecords.push(id); + }, + ...createWorkspaceArchivingDeps(), + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === sharedCwd ? workspaceA : null, + ), + killTerminalsForWorkspace: (workspaceId) => + killWorkspaceTerminals({ terminalManager, sessionLogger: createLogger() }, workspaceId), + sessionLogger: createLogger(), + }, + { + targetPath: sharedCwd, + repoRoot: repoDir, + requestId: "req-archive-siblings", + }, + ); + + // Only workspace A's agents, snapshots, terminals, and record are torn down. + expect(archivedAgents).toEqual(["agent-A", "snapshot-A"]); + expect(archivedAgentIds).toEqual(["agent-A"]); + expect(archivedSnapshotIds).toEqual(["snapshot-A"]); + expect(archivedWorkspaceRecords).toEqual([workspaceA]); + expect(killedTerminals).toEqual(["term-A"]); + + // Sibling workspace B is untouched, and the shared directory survives. + expect(archivedAgentIds).not.toContain("agent-B"); + expect(killedTerminals).not.toContain("term-B"); + expect(existsSync(sharedCwd)).toBe(true); + }); + test("emits archiving upserts during worktree archive request until final remove", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -1807,17 +1968,14 @@ describe("archivePaseoWorktree", () => { const liveAgent = createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath, - }); - const workspaceRecord: PersistedWorkspaceRecord = { workspaceId, - projectId: repoDir, + }); + const workspaceRecord = createWorkspaceRecord({ + workspaceId, cwd: created.worktreePath, - kind: "worktree", + repoDir, displayName: "archive-marked-during-close", - createdAt: "2026-04-30T00:00:00.000Z", - updatedAt: "2026-04-30T00:00:00.000Z", - archivedAt: null, - }; + }); const emitted: SessionOutboundMessage[] = []; const events: string[] = []; const archivedWorkspaceIds = new Set(); @@ -1874,6 +2032,7 @@ describe("archivePaseoWorktree", () => { resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === created.worktreePath ? workspaceId : null, ), + listActiveWorkspaces: vi.fn(async () => []), archiveWorkspaceRecord: vi.fn(async (archivedWorkspaceId: string) => { archivedWorkspaceIds.add(archivedWorkspaceId); }), @@ -1891,8 +2050,7 @@ describe("archivePaseoWorktree", () => { archivingByWorkspaceId.delete(clearedWorkspaceId); } }, - isPathWithinRoot: createIsPathWithinRoot(), - killTerminalsUnderPath: vi.fn(async () => {}), + killTerminalsForWorkspace: vi.fn(async () => {}), sessionLogger: createLogger(), }, { @@ -1945,141 +2103,27 @@ describe("archivePaseoWorktree", () => { }); }); - test("archives the workspace record even when the teardown script fails", async () => { - const teardownLogPath = isPlatform("win32") - ? 'Set-Content -Path (Join-Path $env:PASEO_SOURCE_CHECKOUT_PATH "teardown-start.log") -Value "started"' - : 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"'; - const failingTeardownCommand = isPlatform("win32") - ? 'Write-Error "boom"; exit 9' - : "echo boom 1>&2; exit 9"; - const { tempDir, repoDir } = createGitRepo({ - paseoConfig: { - worktree: { - teardown: [teardownLogPath, failingTeardownCommand], - }, - }, - }); - cleanupPaths.push(tempDir); - - const paseoHome = path.join(tempDir, ".paseo"); - const created = await createLegacyWorktreeForTest({ - branchName: "archive-delete-fails", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "archive-delete-fails", - runSetup: false, - paseoHome, - }); - const workspaceId = "ws-archive-delete-fails"; - const archivingByWorkspaceId = new Map(); - const archivedWorkspaceIds = new Set(); - const emittedUpdates: Array< - | { - kind: "upsert"; - workspaceId: string; - archivingAt: string | null; - } - | { - kind: "remove"; - workspaceId: string; - } - > = []; - const archiveWorkspaceRecord = vi.fn(async (archivedWorkspaceId: string) => { - archivedWorkspaceIds.add(archivedWorkspaceId); - }); - - await expect( - archivePaseoWorktree( - { - paseoHome, - github: createGitHubServiceStub(), - workspaceGitService: { getSnapshot: vi.fn(async () => null) }, - agentManager: { - listAgents: () => [], - archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), - archiveSnapshot: vi.fn(async () => { - throw new Error("not expected for empty agent list"); - }), - }, - agentStorage: createAgentStorageStub(), - resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => - cwd === created.worktreePath ? workspaceId : null, - ), - archiveWorkspaceRecord, - emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async (workspaceIds: Iterable) => { - for (const emittedWorkspaceId of workspaceIds) { - if (archivedWorkspaceIds.has(emittedWorkspaceId)) { - emittedUpdates.push({ - kind: "remove", - workspaceId: emittedWorkspaceId, - }); - continue; - } - emittedUpdates.push({ - kind: "upsert", - workspaceId: emittedWorkspaceId, - archivingAt: archivingByWorkspaceId.get(emittedWorkspaceId) ?? null, - }); - } - }), - markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => { - for (const markedWorkspaceId of workspaceIds) { - archivingByWorkspaceId.set(markedWorkspaceId, archivingAt); - } - }, - clearWorkspaceArchiving: (workspaceIds: Iterable) => { - for (const clearedWorkspaceId of workspaceIds) { - archivingByWorkspaceId.delete(clearedWorkspaceId); - } - }, - isPathWithinRoot: createIsPathWithinRoot(), - killTerminalsUnderPath: vi.fn(async () => {}), - sessionLogger: createLogger(), - }, - { - targetPath: created.worktreePath, - repoRoot: repoDir, - requestId: "req-archive-delete-fails", - }, - ), - ).rejects.toThrow("Worktree teardown command failed"); - - expect(existsSync(created.worktreePath)).toBe(true); - expect(existsSync(path.join(repoDir, "teardown-start.log"))).toBe(true); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); - expect(emittedUpdates[0]).toEqual({ - kind: "upsert", - workspaceId, - archivingAt: expect.any(String), - }); - expect(emittedUpdates.at(-1)).toEqual({ - kind: "remove", - workspaceId, - }); - }); - - test("proceeds to FS delete even when terminal teardown rejects", async () => { + test("archives the workspace record but leaves the worktree on disk", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); const created = await createLegacyWorktreeForTest({ - branchName: "archive-terminal-throws", + branchName: "archive-keeps-disk", cwd: repoDir, baseBranch: "main", - worktreeSlug: "archive-terminal-throws", + worktreeSlug: "archive-keeps-disk", runSetup: false, paseoHome, }); - - const killTerminalsUnderPath = vi.fn(async () => { - throw new Error("simulated terminal teardown failure"); - }); + const workspaceId = "ws-archive-keeps-disk"; + const archiveWorkspaceRecord = vi.fn(async () => {}); await archivePaseoWorktree( { paseoHome, github: createGitHubServiceStub(), + workspaceGitService: { getSnapshot: vi.fn(async () => null) }, agentManager: { listAgents: () => [], archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), @@ -2088,24 +2132,26 @@ describe("archivePaseoWorktree", () => { }), }, agentStorage: createAgentStorageStub(), - archiveWorkspaceRecord: vi.fn(async () => {}), + archiveWorkspaceRecord, ...createWorkspaceArchivingDeps(), - isPathWithinRoot: createIsPathWithinRoot(), - killTerminalsUnderPath, + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === created.worktreePath ? workspaceId : null, + ), + killTerminalsForWorkspace: vi.fn(async () => {}), sessionLogger: createLogger(), }, { targetPath: created.worktreePath, repoRoot: repoDir, - requestId: "req-archive-terminal-throws", + requestId: "req-archive-keeps-disk", }, ); - expect(killTerminalsUnderPath).toHaveBeenCalledTimes(1); - expect(existsSync(created.worktreePath)).toBe(false); + expect(existsSync(created.worktreePath)).toBe(true); + expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); }); - test("forces a workspace git snapshot refresh after archive deletes a worktree", async () => { + test("forces a workspace git snapshot refresh after archive", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -2137,8 +2183,10 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), ...createWorkspaceArchivingDeps(), - isPathWithinRoot: createIsPathWithinRoot(), - killTerminalsUnderPath: vi.fn(async () => {}), + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === created.worktreePath ? "ws-archive-refresh" : null, + ), + killTerminalsForWorkspace: vi.fn(async () => {}), sessionLogger: createLogger(), }, { @@ -2154,32 +2202,26 @@ describe("archivePaseoWorktree", () => { }); }); - test("succeeds when git has forgotten about the worktree (no repoRoot)", async () => { + test("deletes the worktree from disk when asked and it is the last reference", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); const created = await createLegacyWorktreeForTest({ - branchName: "archive-orphan", + branchName: "archive-delete-last-ref", cwd: repoDir, baseBranch: "main", - worktreeSlug: "archive-orphan", + worktreeSlug: "archive-delete-last-ref", runSetup: false, paseoHome, }); + const workspaceId = "ws-delete-last-ref"; - // Simulate a prior failed archive that stripped git's admin dir. - rmSync(path.join(repoDir, ".git", "worktrees", "archive-orphan"), { - recursive: true, - force: true, - }); - expect(existsSync(created.worktreePath)).toBe(true); - - const emitted: SessionOutboundMessage[] = []; - await handlePaseoWorktreeArchiveRequest( + await archivePaseoWorktree( { paseoHome, github: createGitHubServiceStub(), + workspaceGitService: { getSnapshot: vi.fn(async () => null) }, agentManager: { listAgents: () => [], archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), @@ -2189,27 +2231,127 @@ describe("archivePaseoWorktree", () => { }, agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), - emit: (msg) => emitted.push(msg), ...createWorkspaceArchivingDeps(), - isPathWithinRoot: createIsPathWithinRoot(), - killTerminalsUnderPath: vi.fn(async () => {}), + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === created.worktreePath ? workspaceId : null, + ), + // No other active workspace references the worktree dir. + listActiveWorkspaces: vi.fn(async () => []), + killTerminalsForWorkspace: vi.fn(async () => {}), sessionLogger: createLogger(), }, { - type: "paseo_worktree_archive_request", - requestId: "req-archive-orphan", - worktreePath: created.worktreePath, + targetPath: created.worktreePath, + repoRoot: repoDir, + worktreesBaseRoot: path.join(paseoHome, "worktrees"), + deleteWorktreeFromDisk: true, + requestId: "req-delete-last-ref", }, ); - const response = emitted.find( - ( - message, - ): message is Extract => - message.type === "paseo_worktree_archive_response", - ); - expect(response?.payload.success).toBe(true); - expect(response?.payload.error).toBeNull(); expect(existsSync(created.worktreePath)).toBe(false); }); + + test("keeps the worktree on disk when a sibling workspace still references it", async () => { + const { tempDir, repoDir } = createGitRepo(); + cleanupPaths.push(tempDir); + + const paseoHome = path.join(tempDir, ".paseo"); + const created = await createLegacyWorktreeForTest({ + branchName: "archive-delete-sibling", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "archive-delete-sibling", + runSetup: false, + paseoHome, + }); + const sharedCwd = created.worktreePath; + const workspaceA = "ws-delete-A"; + const workspaceB = "ws-delete-B"; + + await archivePaseoWorktree( + { + paseoHome, + github: createGitHubServiceStub(), + workspaceGitService: { getSnapshot: vi.fn(async () => null) }, + agentManager: { + listAgents: () => [], + archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), + archiveSnapshot: vi.fn(async () => { + throw new Error("not expected for empty agent list"); + }), + }, + agentStorage: createAgentStorageStub(), + archiveWorkspaceRecord: vi.fn(async () => {}), + ...createWorkspaceArchivingDeps(), + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === sharedCwd ? workspaceA : null, + ), + // Sibling workspace B still references the same worktree directory. + listActiveWorkspaces: vi.fn(async () => [{ workspaceId: workspaceB, cwd: sharedCwd }]), + killTerminalsForWorkspace: vi.fn(async () => {}), + sessionLogger: createLogger(), + }, + { + targetPath: sharedCwd, + repoRoot: repoDir, + worktreesBaseRoot: path.join(paseoHome, "worktrees"), + deleteWorktreeFromDisk: true, + requestId: "req-delete-sibling", + }, + ); + + expect(existsSync(sharedCwd)).toBe(true); + }); + + test("keeps the worktree on disk when deleteWorktreeFromDisk is not requested", async () => { + const { tempDir, repoDir } = createGitRepo(); + cleanupPaths.push(tempDir); + + const paseoHome = path.join(tempDir, ".paseo"); + const created = await createLegacyWorktreeForTest({ + branchName: "archive-no-delete-flag", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "archive-no-delete-flag", + runSetup: false, + paseoHome, + }); + const workspaceId = "ws-no-delete-flag"; + const listActiveWorkspaces = vi.fn(async () => []); + + await archivePaseoWorktree( + { + paseoHome, + github: createGitHubServiceStub(), + workspaceGitService: { getSnapshot: vi.fn(async () => null) }, + agentManager: { + listAgents: () => [], + archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), + archiveSnapshot: vi.fn(async () => { + throw new Error("not expected for empty agent list"); + }), + }, + agentStorage: createAgentStorageStub(), + archiveWorkspaceRecord: vi.fn(async () => {}), + ...createWorkspaceArchivingDeps(), + resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) => + cwd === created.worktreePath ? workspaceId : null, + ), + listActiveWorkspaces, + killTerminalsForWorkspace: vi.fn(async () => {}), + sessionLogger: createLogger(), + }, + { + targetPath: created.worktreePath, + repoRoot: repoDir, + worktreesBaseRoot: path.join(paseoHome, "worktrees"), + requestId: "req-no-delete-flag", + }, + ); + + // Default (false): the directory survives. The explicit workspaceId is + // resolved by path, so no last-reference deletion runs. + expect(existsSync(created.worktreePath)).toBe(true); + }); }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index bfb63ebb6..df59d4c85 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -198,10 +198,12 @@ export async function buildAgentSessionConfig( ): Promise<{ sessionConfig: AgentSessionConfig; setupContinuation?: AgentWorktreeSetupContinuation; + createdWorkspaceId?: string; }> { let cwd = expandTilde(config.cwd); const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName); let setupContinuation: AgentWorktreeSetupContinuation | undefined; + let createdWorkspaceId: string | undefined; if (!normalized) { return { @@ -243,6 +245,7 @@ export async function buildAgentSessionConfig( ); cwd = createdWorktree.worktree.worktreePath; setupContinuation = createdWorktree.setupContinuation; + createdWorkspaceId = createdWorktree.workspace.workspaceId; } else if (normalized.createNewBranch) { const baseBranch = normalized.baseBranch ?? @@ -268,6 +271,7 @@ export async function buildAgentSessionConfig( cwd, }, setupContinuation, + createdWorkspaceId, }; } @@ -449,6 +453,8 @@ export async function handlePaseoWorktreeArchiveRequest( worktreePath: msg.worktreePath, repoRoot: msg.repoRoot, branchName: msg.branchName, + workspaceId: msg.workspaceId, + deleteWorktreeFromDisk: msg.deleteWorktreeFromDisk, }); if (!result.ok) { dependencies.emit({ @@ -622,6 +628,7 @@ export async function createPaseoWorktreeWorkflow( startAfterAgentCreate: ({ agentId }) => { void runAsyncWorktreeBootstrap({ agentId, + workspaceId: workspace.workspaceId, worktree: createdWorktree.worktree, shouldBootstrap: createdWorktree.created, terminalManager: setupContinuation.terminalManager, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index c0370692e..9cda1dbe2 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -100,6 +100,8 @@ export interface ArchivePaseoWorktreeCommandInput { worktreePath?: string; worktreeSlug?: string; branchName?: string; + workspaceId?: string; + deleteWorktreeFromDisk?: boolean; } export type ArchivePaseoWorktreeCommandResult = @@ -139,6 +141,8 @@ export async function archivePaseoWorktreeCommand( repoRoot, worktreesRoot: ownership.worktreeRoot, worktreesBaseRoot: dependencies.worktreesRoot, + workspaceId: input.workspaceId, + deleteWorktreeFromDisk: input.deleteWorktreeFromDisk, requestId: input.requestId, }); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 1f3264bc1..612691fa8 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -15,6 +15,7 @@ export interface TerminalListItem { id: string; name: string; cwd: string; + workspaceId?: string; title?: string; activity: TerminalActivity | null; } @@ -37,10 +38,11 @@ export interface TerminalActivityTransitionEvent { export type TerminalActivityListener = (event: TerminalActivityTransitionEvent) => void; export interface TerminalManager { - getTerminals(cwd: string): Promise; + getTerminals(cwd: string, options?: { workspaceId?: string }): Promise; createTerminal(options: { id?: string; cwd: string; + workspaceId?: string; name?: string; title?: string; env?: Record; @@ -184,6 +186,7 @@ export function createTerminalManager( id: input.session.id, name: input.session.name, cwd: input.session.cwd, + ...(input.session.workspaceId ? { workspaceId: input.session.workspaceId } : {}), title: input.session.getTitle(), activity: input.session.getActivity(), }; @@ -235,7 +238,10 @@ export function createTerminalManager( } return { - async getTerminals(cwd: string): Promise { + async getTerminals( + cwd: string, + options?: { workspaceId?: string }, + ): Promise { assertAbsolutePath(cwd); // Terminals are bucketed by exact cwd, but an agent can open a terminal in @@ -247,12 +253,24 @@ export function createTerminalManager( sessions.push(...bucketSessions); } } + + // When the query carries a workspaceId, two workspaces sharing a cwd must + // not see each other's terminals. Exclude sessions owned by a different + // workspace; keep sessions without an owner (COMPAT: created by clients + // that predate terminal workspace ownership). + if (options?.workspaceId !== undefined) { + return sessions.filter( + (session) => + session.workspaceId === undefined || session.workspaceId === options.workspaceId, + ); + } return sessions; }, async createTerminal(options: { id?: string; cwd: string; + workspaceId?: string; name?: string; title?: string; env?: Record; @@ -286,6 +304,7 @@ export function createTerminalManager( await createTerminal({ id: terminalId, cwd: options.cwd, + ...(options.workspaceId ? { workspaceId: options.workspaceId } : {}), name: options.name ?? defaultName, ...(options.title ? { title: options.title } : {}), ...(options.command ? { command: options.command } : {}), diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 777e3ee3b..40e72ca1e 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -415,6 +415,76 @@ describe("terminal-session-controller subdirectory aggregation", () => { }); }); +describe("terminal-session-controller workspace-scoped subscriptions", () => { + test("two workspaces sharing a cwd subscribe and unsubscribe independently", async () => { + const cwd = "/work/shared"; + const terminalA: TerminalSession = { + ...listSession({ id: "a", name: "A", cwd }), + workspaceId: "ws-a", + }; + const terminalB: TerminalSession = { + ...listSession({ id: "b", name: "B", cwd }), + workspaceId: "ws-b", + }; + + let changedListener: ((event: TerminalsChangedEvent) => void) | null = null; + const terminalManager: TerminalManager = { + getTerminals: vi.fn(async (_cwd: string, options?: { workspaceId?: string }) => + options?.workspaceId === "ws-b" ? [terminalB] : [terminalA], + ), + createTerminal: vi.fn(), + registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), + getTerminal: vi.fn(), + getTerminalState: vi.fn(), + setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), + killTerminal: vi.fn(), + killTerminalAndWait: vi.fn(), + captureTerminal: vi.fn(), + listDirectories: vi.fn(() => [cwd]), + killAll: vi.fn(), + subscribeTerminalsChanged: vi.fn((listener) => { + changedListener = listener; + return vi.fn(); + }), + subscribeTerminalActivity: vi.fn(() => vi.fn()), + }; + + const outboundMessages: SessionOutboundMessage[] = []; + const controller = new TerminalSessionController({ + terminalManager, + emit: (message) => outboundMessages.push(message), + emitBinary: vi.fn(), + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + }); + controller.start(); + + controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-a" }); + controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-b" }); + await flushMicrotasks(); + outboundMessages.length = 0; + + // Tearing down workspace B must not drop workspace A's live subscription. + controller.dispatch({ type: "unsubscribe_terminals_request", cwd, workspaceId: "ws-b" }); + + changedListener?.({ cwd, terminals: [{ id: "a", name: "A", cwd, workspaceId: "ws-a" }] }); + await flushMicrotasks(); + + expect(outboundMessages).toEqual([ + { + type: "terminals_changed", + payload: { + cwd, + terminals: [{ id: "a", name: "A", workspaceId: "ws-a", activity: null }], + }, + }, + ]); + }); +}); + describe("terminal-session-controller backpressure snapshot fallback", () => { async function setup(getClientBufferedAmount: () => number | null): Promise<{ pushOutput: (data: string) => void; diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index cddf6e2d1..ffb2df11a 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -13,7 +13,7 @@ import type { UnsubscribeTerminalRequest, UnsubscribeTerminalsRequest, } from "../server/messages.js"; -import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "../server/paseo-worktree-archive-service.js"; +import { killTerminalsForWorkspace as killWorkspaceTerminals } from "../server/paseo-worktree-archive-service.js"; import { TerminalStreamOpcode, decodeTerminalResizePayload, @@ -34,6 +34,7 @@ import { import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; +import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; const MAX_TERMINAL_STREAM_SLOTS = 256; @@ -123,7 +124,14 @@ export class TerminalSessionController { private readonly clientSupportsWrapReflow: () => boolean; private readonly getClientBufferedAmount: () => number | null; - private readonly subscribedDirectories = new Set(); + // A subscription is scoped to a (cwd, workspaceId) pair, keyed by + // terminalSubscriptionKey: two workspaces sharing a cwd subscribe and unsub + // independently, and each only receives its own workspace's terminals. The + // workspaceId is absent for old clients, which key to the cwd alone. + private readonly subscribedDirectories = new Map< + string, + { cwd: string; workspaceId: string | undefined } + >(); private unsubscribeTerminalsChanged: (() => void) | null = null; private readonly exitSubscriptions = new Map void>(); private readonly activeStreams = new Map(); @@ -238,17 +246,14 @@ export class TerminalSessionController { return { terminalId, success: true }; } - async killTerminalsUnderPath(rootPath: string): Promise { - return killWorktreeTerminalsUnderPath( + async killTerminalsForWorkspace(workspaceId: string): Promise { + return killWorkspaceTerminals( { - isPathWithinRoot: (pathRoot, candidatePath) => - this.isPathWithinRoot(pathRoot, candidatePath), - killTrackedTerminal: (terminalId, options) => this.killTracked(terminalId, options), detachTerminalStream: (terminalId, options) => void this.detachStream(terminalId, options), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, }, - rootPath, + workspaceId, ); } @@ -293,6 +298,7 @@ export class TerminalSessionController { terminals: Array<{ id: string; name: string; + workspaceId?: string; title?: string; activity: TerminalActivity | null; }>; @@ -307,10 +313,11 @@ export class TerminalSessionController { } private toTerminalInfo( - terminal: Pick, + terminal: Pick, ): { id: string; name: string; + workspaceId?: string; title?: string; activity: TerminalActivity | null; } { @@ -319,6 +326,7 @@ export class TerminalSessionController { return { id: terminal.id, name: terminal.name, + ...(terminal.workspaceId ? { workspaceId: terminal.workspaceId } : {}), ...(title ? { title } : {}), activity, }; @@ -330,41 +338,52 @@ export class TerminalSessionController { // or above the terminal's cwd, keyed by that root, carrying the full // aggregated list — so the client's cache replacement doesn't drop the // terminals that live directly at the root. - const matchingRoots = Array.from(this.subscribedDirectories).filter((root) => - this.isPathWithinRoot(root, event.cwd), + const matchingSubscriptions = Array.from(this.subscribedDirectories.values()).filter( + (subscription) => this.isPathWithinRoot(subscription.cwd, event.cwd), ); - for (const root of matchingRoots) { - await this.emitTerminalsSnapshotForRoot(root); + for (const subscription of matchingSubscriptions) { + await this.emitTerminalsSnapshotForSubscription(subscription); } } private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void { - this.subscribedDirectories.add(msg.cwd); - void this.emitTerminalsSnapshotForRoot(msg.cwd); + const subscription = { cwd: msg.cwd, workspaceId: msg.workspaceId }; + this.subscribedDirectories.set(terminalSubscriptionKey(msg.cwd, msg.workspaceId), subscription); + void this.emitTerminalsSnapshotForSubscription(subscription); } private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void { - this.subscribedDirectories.delete(msg.cwd); + this.subscribedDirectories.delete(terminalSubscriptionKey(msg.cwd, msg.workspaceId)); } - private async emitTerminalsSnapshotForRoot(cwd: string): Promise { - if (!this.terminalManager || !this.subscribedDirectories.has(cwd)) { + private async emitTerminalsSnapshotForSubscription(subscription: { + cwd: string; + workspaceId: string | undefined; + }): Promise { + const key = terminalSubscriptionKey(subscription.cwd, subscription.workspaceId); + if (!this.terminalManager || !this.subscribedDirectories.has(key)) { return; } try { - const terminals = await this.getTerminalsForWorkspaceRoot(cwd); + const terminals = await this.getTerminalsForWorkspaceRoot( + subscription.cwd, + subscription.workspaceId, + ); for (const terminal of terminals) { this.ensureExitSubscription(terminal); } - if (!this.subscribedDirectories.has(cwd)) { + if (!this.subscribedDirectories.has(key)) { return; } this.emitTerminalsChangedSnapshot({ - cwd, + cwd: subscription.cwd, terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), }); } catch (error) { - this.sessionLogger.warn({ err: error, cwd }, "Failed to emit initial terminal snapshot"); + this.sessionLogger.warn( + { err: error, cwd: subscription.cwd }, + "Failed to emit initial terminal snapshot", + ); } } @@ -384,7 +403,7 @@ export class TerminalSessionController { try { const terminals = typeof msg.cwd === "string" - ? await this.getTerminalsForWorkspaceRoot(msg.cwd) + ? await this.getTerminalsForWorkspaceRoot(msg.cwd, msg.workspaceId) : await this.getAllTerminalSessions(); for (const terminal of terminals) { this.ensureExitSubscription(terminal); @@ -422,12 +441,15 @@ export class TerminalSessionController { return terminalsByDirectory.flat(); } - private async getTerminalsForWorkspaceRoot(cwd: string): Promise { + private async getTerminalsForWorkspaceRoot( + cwd: string, + workspaceId?: string, + ): Promise { if (!this.terminalManager) { return []; } - const terminals = await this.terminalManager.getTerminals(cwd); + const terminals = await this.terminalManager.getTerminals(cwd, { workspaceId }); const workspaceRoots = await this.listTerminalWorkspaceRoots(); if (workspaceRoots.length === 0) { return terminals; @@ -500,6 +522,7 @@ export class TerminalSessionController { const session = await this.terminalManager.createTerminal({ cwd: msg.cwd, + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), name: msg.name, command: msg.command, args: msg.args, @@ -512,6 +535,7 @@ export class TerminalSessionController { id: session.id, name: session.name, cwd: session.cwd, + ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), ...(session.getTitle() ? { title: session.getTitle() } : {}), activity: session.getActivity(), }, diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 7699f78b4..165f4bd67 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -66,6 +66,7 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo { id: session.id, name: session.name, cwd: session.cwd, + ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), ...(session.getTitle() ? { title: session.getTitle() } : {}), activity: session.getActivity(), }; @@ -241,7 +242,9 @@ async function handleCreateTerminalRequest(message: TerminalCreateRequest): Prom async function handleRequest(message: TerminalWorkerRequest): Promise { switch (message.type) { case "getTerminals": { - const terminals = await manager.getTerminals(message.cwd); + const terminals = await manager.getTerminals(message.cwd, { + workspaceId: message.workspaceId, + }); sendToParent({ type: "response", requestId: message.requestId, diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index a9c087afb..c4589d99d 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -13,6 +13,7 @@ export interface WorkerTerminalInfo { id: string; name: string; cwd: string; + workspaceId?: string; title?: string; activity: TerminalActivity | null; } @@ -20,6 +21,7 @@ export interface WorkerTerminalInfo { export interface WorkerCreateTerminalOptions { id?: string; cwd: string; + workspaceId?: string; name?: string; title?: string; env?: Record; @@ -39,6 +41,7 @@ export type TerminalWorkerRequest = type: "getTerminals"; requestId: string; cwd: string; + workspaceId?: string; } | { type: "createTerminal"; diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 13594cf4c..032849ff8 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -77,6 +77,7 @@ export interface TerminalSession { id: string; name: string; cwd: string; + workspaceId?: string; send(msg: ClientMessage): void; subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void; onExit(listener: (info: TerminalExitInfo) => void): () => void; @@ -117,6 +118,7 @@ function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | nu export interface CreateTerminalOptions { id?: string; cwd: string; + workspaceId?: string; shell?: string; env?: Record; activityEnv?: Record; @@ -792,6 +794,7 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] { export async function createTerminal(options: CreateTerminalOptions): Promise { const { cwd, + workspaceId, shell, env = {}, activityEnv = {}, @@ -852,7 +855,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { - const result = (await sendRequest({ type: "getTerminals", cwd })) as WorkerTerminalInfo[]; + async getTerminals( + cwd: string, + options?: { workspaceId?: string }, + ): Promise { + const result = (await sendRequest({ + type: "getTerminals", + cwd, + ...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), + })) as WorkerTerminalInfo[]; return toSessions(result); }, diff --git a/packages/server/src/utils/tree-kill.ts b/packages/server/src/utils/tree-kill.ts index 255e3b1c2..8101be987 100644 --- a/packages/server/src/utils/tree-kill.ts +++ b/packages/server/src/utils/tree-kill.ts @@ -8,7 +8,7 @@ export interface TreeKillTarget { once?(event: "exit", listener: () => void): unknown; } -interface TerminateWithTreeKillOptions { +export interface TerminateWithTreeKillOptions { gracefulSignal?: NodeJS.Signals; forceSignal?: NodeJS.Signals; gracefulTimeoutMs: number; @@ -22,6 +22,13 @@ export type TerminateWithTreeKillResult = | "killed" | "kill-timeout"; +// Injection seam: production wires terminateWithTreeKill; tests wire a fake that +// records which children were terminated as observable state. +export type ProcessTerminator = ( + child: TreeKillTarget, + options: TerminateWithTreeKillOptions, +) => Promise; + export async function terminateWithTreeKill( child: TreeKillTarget, options: TerminateWithTreeKillOptions,