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,