Run multiple independent workspaces per directory (#1539)

* feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1)

Model B EX-Phase 1: the binding layer. Records gain an additive
workspaceId that answers "which workspace does this belong to" without
replacing the existing required cwd.

- protocol: add optional workspaceId to agent/terminal records and a
  workspaceOwnership capability under server_info.features.*
- server: resolve and persist workspaceId on agent and terminal
  creation; carry it through agent-manager, projections, storage,
  loading, sessions, and the terminal manager/worker pipeline
- workspace-directory / workspace-registry-model: derive and expose
  workspace ownership; bind status to workspaceId
- client: surface workspaceId via daemon-client
- app: filter terminals and agent visibility by workspaceId, thread it
  through workspace screen / terminal panel / session store
- tests: cover workspaceId binding across create, agent-manager,
  mcp-server, workspace-directory, workspace-registry-model, and
  agent-visibility

cwd stays required on every record; workspaceId is purely additive and
back-compatible (optional/defaulted), so old and new peers still parse
each other's messages.

Known follow-ups: none.

* feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2)

- Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage
- Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key)
- Add editable workspace title, decoupled from the backing directory/branch
- Persist empty projects (no workspaces yet) across daemon restarts
- Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename

Known follow-ups: none

* feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3)

- workspace.create RPC always creates a new workspace record; no directory dedup
- dropped directory dedup in open_project and createPaseoWorktree paths
- non-git directories are first-class: local-checkout workspaces no longer require a repo
- service-proxy collision resolved by defaulting to the owning workspace instead of failing
- new creation UI: choose backing directory (local checkout or worktree) per workspace
- e2e coverage for creating multiple workspaces over the same directory

Known follow-ups:
- COMPAT cwd->workspaceId resolver remains until client floor advances
- prune any remaining workspace==directory assumptions surfaced by usage

* feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4)

- A workspace is now the explicit home of a `paseo run`: run.ts resolves a
  workspace before creating any agent, then stamps the agent with that
  workspaceId — no run leans on createAgent's legacy cwd->workspace fallback.
- Precedence: --workspace <id> > $PASEO_WORKSPACE_ID > --worktree > bare run.
  --worktree mints its own workspace and overrides the ambient
  PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream.
- New-workspace-per-bare-run: every bare run mints a fresh local-backed
  workspace for its cwd rather than reattaching to an existing one in that dir.
- Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an
  existing workspace, same as --workspace.
- Help text: new --workspace option documents the default (new workspace per
  run) and the env fallback; created runs print the workspace id plus a tip.
- e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd,
  --workspace attach, and env attach against an isolated daemon.

Known follow-ups:
- createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no
  longer relies on it but it is not yet removable.

* feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5)

- Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId.
- Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory.
- Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted.
- Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory.
- Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved).
- Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt.

Known follow-ups:
- Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today).
- COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97.

* feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6)

- P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference.
- Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent.
- Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind.
- Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update.
- The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar.
- Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants.

Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row.

* fix(protocol): use literal :: separator in terminal subscription key

The committed blob contained a NUL byte as the separator instead of the
:: the comment documents, which broke text diffs and review tooling
(git flagged the file as binary). Functionally the key is an in-memory
Map key only, but source must stay text.

* feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7)

- Pin and document the directory-backed vs workspace-owned right-sidebar boundary:
  same-cwd workspaces share directory-keyed git/PR/file surfaces but never share
  workspace-owned drafts, attachments, or file-explorer state. New docs section in
  architecture.md plus data-model.md keying convention and two glossary terms.
- Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the
  right sidebar is shared (a directory change appears in both same-dir workspaces)
  while tabs/terminals stay independent per workspace.
- Accumulated follow-up fixes:
  - MCP child agents created in a new worktree are stamped with the new worktree's
    workspaceId (not the parent's, not unstamped), mirroring the session path so
    workspaceId-scoped archive can find and tear them down.
  - session-store mergeWorkspaces prunes a stale empty-project descriptor when a
    workspace lands in that project, so it stops governing the project's metadata.
  - CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just
    --workspace), so worktree resolution never races an existing-workspace select.
    New run.test.ts pins the three validation outcomes.
- Full-branch reshape outcomes:
  - Consolidate the four placeholder server_info.features flags (workspaceOwnership,
    workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single
    workspaceMultiplicity capability gate.
  - Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit
    creation always mints a new same-cwd record via createLocalCheckoutWorkspace.
  - Resolve every Model B COMPAT marker from v0.1.X to v0.1.97.
  - Drop the obsolete workspaceOwnership feature assertion from the same-cwd
    isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds.
  - Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch.
- typecheck, lint, format all green.

Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders
(out of Model B scope, left untouched).

* test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions)

Reshape Model B test changes flagged by audit-tests so they exercise real
behavior and observable state instead of mock scaffolding.

Categories fixed:

- Mocks -> real dependencies + persisted state: create.test.ts and
  mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects
  and mock.calls[...] assertions, now run the real AgentManager/AgentStorage
  with a fake agent client and assert on the stored agent record (workspaceId,
  parent label).

- Module-internal spying -> injected seam: acp-agent stopped spying on the
  tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator
  injection seam to ACPAgentClient/ACPAgentSession (production defaults to
  terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the
  recorded children plus observable stream .destroyed state.

- Poking internals -> public API: acp close()/killTerminal/releaseTerminal
  tests now create terminals through the public createTerminal API instead of
  mutating internals.terminalEntries.

- Reimplemented production logic deleted: cli-run-workspace-precedence dropped
  its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation
  it then asserted against) and now proves only the daemon behaviors the CLI
  builds on; flag precedence stays covered in the CLI's own run.test.ts.

- Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with
  expect.poll on the observed snapshot state to remove the race.

- Internal protocol-frame assertions removed: sidebar-workspace-rename dropped
  the captureWsSessionFrames workspace.title.set.request assertions, relying on
  the user-visible rename + reload checks already present.

* fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects

Source regression:
- packages/app/src/screens/new-workspace-screen.tsx
- packages/app/src/screens/new-workspace-empty.ts
  When a new workspace is created with an initial agent, optimistically merge it
  with status "running" (statusEnteredAt now) so it lands in the "Working" bucket
  instead of defaulting into the wrong bucket. The empty-workspace path passes
  withInitialAgent: false so a bare workspace keeps its descriptor status.

Test-drift (intentional P3 flow change — backing picker now required):
- packages/app/e2e/new-workspace.spec.ts
- packages/app/e2e/helpers/new-workspace.ts
  The reshaped creation flow requires choosing a backing ("New worktree") before
  the branch / starting-ref picker is reachable. Branch-picker specs now call
  selectWorkspaceBacking(page, "worktree") first, and the helper waits for the
  worktree control to drop aria-disabled (it stays disabled until the checkout
  status query confirms the project is a git repo) before clicking.

Test-drift (protocol field added — sdk emptyProjects):
- packages/client/src/index.test.ts
  fetch_workspaces response now carries emptyProjects; the toEqual expectation
  includes emptyProjects: [].

Flake hardening:
- packages/app/e2e/same-directory-workspaces.spec.ts
- packages/app/e2e/helpers/seed-client.ts
  Out-of-band working-tree writes raced the daemon's debounced filesystem
  watcher, so the UI could subscribe before the new file was in the git snapshot.
  Force a checkout refresh (same path as the UI's manual refresh) to make the
  write authoritative before asserting, removing the timing dependency.

* test(app): expect withInitialAgent:false in empty-workspace create call

The withInitialAgent flag (fix for the new-workspace Done-bucket regression)
added a field to the ensureWorkspace call; the empty-path unit test pinned the
exact args. Test-drift, not behavior — update the expectation.

* feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance

Restructure the new-workspace creation screen into a vertical stack of
Project / Isolation / Base parameters instead of the previous mixed
layout.

- Isolation/backing is now a dropdown (Local vs New worktree) matching the
  app's existing dropdown/combobox primitives, replacing the inline
  segmented switcher.
- The Base (starting ref) row now reserves its space even when the backing
  is Local and the picker is hidden, so switching backing no longer causes
  the form to shift vertically.
- The form avoids the keyboard so the title input and submit stay visible
  while typing on native and web.
- Updated the e2e new-workspace helper to drive the dropdown-based
  backing selector and the reserved Base row.

* fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns

- Render the three rows (formStack) at the top, under the "New workspace"
  heading and above the composer input, not in the composer footer.
- Drop the card/surface/border chrome; rows sit on the plain background.
- Stack Project, Isolation (multiplicity only), and Base; remove the title
  field — the title is set server-side and create works with no user title.
- Each row is a Label immediately followed by its dropdown control; no
  columns, fixed label widths, or reserved horizontal space, with the label
  glyph aligned to the heading's text x.
- All three triggers share the ghost badge style of the Project control
  (ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger +
  Combobox); keep the workspace-create-backing-* test IDs.
- Omit "New worktree" from Isolation entirely when the project is non-git.
- Reserve the Base row height always but render nothing on Local backing,
  showing the Base label + ref picker only for New worktree.
- Keep keyboard avoidance on the centered content.
- E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and
  remove the now-gone workspace-create-title-input usage and title field.

* feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select

- Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice.
- Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate.
- Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon.
- Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm.
- e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row.

* fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles

- Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars).
- Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator.
- Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE.
- Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant.

* fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing

- Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift.
- Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider.

* feat(app): rename Sessions to History (clock icon, Agent history header)

- Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare)
- Sessions screen header now reads "Agent history"
- Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es)
- Route and testIDs unchanged (sidebar-sessions, /sessions)

* fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved

The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched.

* fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title)

- Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null)
- Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded)
- Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title
- Header branch-switcher left untouched; diff-pane re-home decision still pending

* feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence)

- branch switcher now lives in the diff panel Changes header on desktop+mobile
- workspace header title is a plain static title; branch removed from it
- no new workspace-screen header row
- git diff-stat unchanged, stays where it is
- no duplicate git actions
- unified descriptor name fallbacks via resolveWorkspaceName
- rename-then-switch e2e proves header title and real branch stay independent
This commit is contained in:
Mohamed Boudra
2026-06-15 14:23:02 +08:00
committed by GitHub
parent eb94b70848
commit 71b5c35d9b
129 changed files with 6252 additions and 1818 deletions

View File

@@ -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/`.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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<void> {
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();
}
});
});

View File

@@ -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<void> {
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();
}
});
});

View File

@@ -212,7 +212,7 @@ export async function openSessions(page: Page): Promise<void> {
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,
});
}

View File

@@ -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: <name>. 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: <name>. 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<void> {
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<void> {
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<void> {
@@ -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<void> {
await expect(page.getByTestId("workspace-header-branch-switcher")).toHaveCount(0);
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });

View File

@@ -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,

View File

@@ -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<void> {
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(

View File

@@ -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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
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();
}
});
});

View File

@@ -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);

View File

@@ -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<void> {
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<string> {
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();
}
});
});

View File

@@ -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<string> {
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();
}
});
});

View File

@@ -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();

View File

@@ -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<void> {
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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
},
): 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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
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();
}
});
});

View File

@@ -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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
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);
});
});

View File

@@ -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<View>(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 = (
<View style={styles.titleRow}>
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
</View>
);
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(
() => <GitBranch size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
[],
);
const renderBranchOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
@@ -86,20 +82,23 @@ export function BranchSwitcher({
);
if (!currentBranchName) {
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
return null;
}
return (
<View ref={anchorRef} collapsable={false}>
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
<Pressable
testID="workspace-header-branch-switcher"
testID={testID}
onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
>
{titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
<ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} numberOfLines={1}>
{currentBranchName}
</Text>
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
<Combobox
options={branchOptions}
@@ -123,31 +122,28 @@ export function BranchSwitcher({
}
const styles = StyleSheet.create((theme) => ({
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,
},
}));

View File

@@ -520,7 +520,6 @@ function SidebarContent({
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
hideHeaderRow={!isMobile}
enabled={isOpen}
/>
)}

View File

@@ -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({
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeaderRow}>
<View style={styles.sidebarHeaderGroup}>
<SidebarHeaderRow
icon={MessagesSquare}
icon={Plus}
label={labels.newWorkspace}
onPress={handleNewWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
/>
<SidebarHeaderRow
icon={Clock}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
</View>
<WorkspacesSectionHeader
@@ -973,13 +983,21 @@ function DesktopSidebar({
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<View style={styles.sidebarHeaderRow}>
<View style={styles.sidebarHeaderGroup}>
<SidebarHeaderRow
icon={MessagesSquare}
icon={Plus}
label={labels.newWorkspace}
onPress={handleNewWorkspaceNavigate}
testID="sidebar-global-new-workspace"
variant="compact"
/>
<SidebarHeaderRow
icon={Clock}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
</View>
</View>
@@ -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",

View File

@@ -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, unknown>) => 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}
/>
<WorktreeDeletePrompt
visible={archiveController.deletePromptOpen}
workspaceName={workspace.name}
onKeep={archiveController.confirmKeepOnDisk}
onDelete={archiveController.confirmDeleteFromDisk}
onCancel={archiveController.cancelDeletePrompt}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.name}
placeholder="branch-name"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
@@ -1800,242 +1644,6 @@ function WorkspaceRowWithMenu({
);
}
function NonGitProjectRowWithMenuContent({
project,
displayName,
iconDataUri,
workspace,
selected,
onPress,
shortcutNumber,
showShortcutBadge,
drag,
isDragging,
dragHandleProps,
}: {
project: SidebarProjectEntry;
displayName: string;
iconDataUri: string | null;
workspace: SidebarWorkspaceEntry;
selected: boolean;
onPress: () => 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 (
<>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={selected}
chevron={null}
onPress={onPress}
serverId={null}
canCreateWorktree={false}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
isArchiving={isArchivingWorkspace}
menuController={contextMenu}
dragHandleProps={dragHandleProps}
/>
<ContextMenuContent
align="start"
width={220}
mobileMode="sheet"
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
>
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={isArchivingWorkspace ? "pending" : "idle"}
pendingLabel={t("sidebar.workspace.actions.hiding")}
destructive
onSelect={handleArchiveWorkspace}
>
{t("sidebar.workspace.actions.hideFromSidebar")}
</ContextMenuItem>
</ContextMenuContent>
</>
);
}
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 (
<ContextMenu>
<NonGitProjectRowWithMenuContent {...props} />
</ContextMenu>
);
}
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<ReturnType<typeof buildSidebarProjectRowModel>, { 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 (
<NonGitProjectRowWithMenu
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={selected}
onPress={onPress}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
);
}
return (
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={selected}
chevron={rowModel.chevron}
onPress={onPress}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
menuController={null}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
dragHandleProps={dragHandleProps}
/>
);
}
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 (
<View style={styles.projectBlock}>
{rowModel.kind === "workspace_link" ? (
<FlattenedProjectRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
rowModel={rowModel}
onPress={handleFlattenedRowPress}
serverId={serverId}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
isProjectActive={active}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
/>
) : (
<>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={null}
selected={false}
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps}
/>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={null}
selected={false}
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps}
/>
{!collapsed ? (
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
) : null}
</>
)}
{!collapsed && project.workspaces.length > 0 ? (
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
) : null}
</View>
);
}

View File

@@ -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 size={theme.iconSize.md} color={iconColor} />
<ThemedIcon
size={ICON_SIZE.sm}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
/>
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
</>
);
},
[
Icon,
isActive,
label,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.iconSize.md,
],
[ThemedIcon, isActive, label],
);
return (
<View style={styles.container}>
<View style={containerStyle}>
<Pressable
onPress={onPress}
testID={testID}
@@ -103,10 +112,18 @@ const styles = StyleSheet.create((theme) => ({
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,
},

View File

@@ -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({
<View key={group.bucket} style={styles.statusGroupBlock}>
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
{!collapsedStatusGroupKeys.has(group.bucket) ? (
<View style={styles.statusWorkspaceListContainer}>
<View
style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.bucket}`}
>
{group.rows.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
@@ -348,14 +348,12 @@ function StatusWorkspaceRowWithMenu({
}) {
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,
@@ -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}
/>
<WorktreeDeletePrompt
visible={archiveController.deletePromptOpen}
workspaceName={workspace.name}
onKeep={archiveController.confirmKeepOnDisk}
onDelete={archiveController.confirmDeleteFromDisk}
onCancel={archiveController.cancelDeletePrompt}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.name}
placeholder="branch-name"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}

View File

@@ -12,7 +12,7 @@ import { Dimensions, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
import { CircleCheck, CircleDot, CircleX, ExternalLink, GitBranch } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
@@ -286,6 +286,18 @@ function WorkspaceHoverCardContent({
{workspace.name}
</Text>
</View>
{workspace.currentBranch && workspace.currentBranch !== workspace.name ? (
<View style={styles.cardBranchRow}>
<ThemedGitBranch size={12} uniProps={foregroundMutedColorMapping} />
<Text
style={styles.cardBranchText}
numberOfLines={1}
testID="hover-card-workspace-branch"
>
{workspace.currentBranch}
</Text>
</View>
) : null}
{prHint || workspace.diffStat ? (
<View style={styles.cardMetaRow}>
{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,

View File

@@ -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}

View File

@@ -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<typeof TooltipTrigger>["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}
>
<SendButtonContent
@@ -1132,6 +1137,7 @@ interface ResolvedMessageInputProps {
hasExternalContent: boolean;
allowEmptySubmit: boolean;
submitButtonAccessibilityLabel: string | undefined;
submitButtonTestID: string | undefined;
submitIcon: "arrow" | "return";
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
@@ -1172,6 +1178,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
hasExternalContent: props.hasExternalContent ?? false,
allowEmptySubmit: props.allowEmptySubmit ?? false,
submitButtonAccessibilityLabel: props.submitButtonAccessibilityLabel,
submitButtonTestID: props.submitButtonTestID,
submitIcon: props.submitIcon ?? "arrow",
isSubmitDisabled: props.isSubmitDisabled ?? false,
isSubmitLoading: props.isSubmitLoading ?? false,
@@ -1220,6 +1227,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
hasExternalContent,
allowEmptySubmit,
submitButtonAccessibilityLabel,
submitButtonTestID,
submitIcon,
isSubmitDisabled,
isSubmitLoading,
@@ -1872,6 +1880,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel}

View File

@@ -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<string, WorkspaceDescriptor>();
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
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,

View File

@@ -57,7 +57,11 @@ function GitActionMenuItem({
<View>
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
testID={
action.id === "archive-worktree"
? "workspace-archive-action"
: `changes-menu-${action.id}`
}
leading={action.icon}
trailing={trailing}
disabled={action.disabled}

View File

@@ -258,6 +258,8 @@ interface CheckoutGitActionsStoreState {
serverId: string;
cwd: string;
worktreePath: string;
workspaceId?: string;
deleteWorktreeFromDisk?: boolean;
}) => Promise<void>;
}
@@ -497,7 +499,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
archiveWorktree: async ({ serverId, cwd, worktreePath }) => {
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId, deleteWorktreeFromDisk }) => {
await runCheckoutAction({
serverId,
cwd,
@@ -519,7 +521,11 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}
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);
}

View File

@@ -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 (
<View style={styles.container}>
{!hideHeaderRow ? (
{isGit && (currentBranchName || isMobile) ? (
<View style={styles.header} testID="changes-header">
<View style={styles.headerLeft}>
<ThemedGitBranch size={16} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel}
</Text>
</View>
{isGit ? <GitActionsSplitButton gitActions={gitActions} /> : null}
<BranchSwitcher
currentBranchName={currentBranchName}
serverId={serverId}
workspaceId={workspaceId ?? cwd}
workspaceDirectory={cwd}
isGitCheckout={isGit}
testID="changes-branch-switcher"
/>
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
{worktreeDeletePrompt}
</View>
) : 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,

View File

@@ -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 = (
<WorktreeDeletePrompt
visible={state !== null}
workspaceName={state?.workspaceName ?? ""}
onKeep={onKeep}
onDelete={onDelete}
onCancel={onCancel}
/>
);
return { open, element };
}
type PrStatusValue = NonNullable<CheckoutPrStatusPayload["status"]> | 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(

View File

@@ -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 <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
return (
<>
<GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />
{worktreeDeletePrompt}
</>
);
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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}}",

View File

@@ -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: "فشل في إنشاء شجرة العمل",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: "Не удалось создать рабочее дерево.",

View File

@@ -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 失败",

View File

@@ -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<ListTerminalsPayload> => {
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,
},

View File

@@ -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<WorkspaceDescriptor>;
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}): 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) ??

View File

@@ -37,6 +37,7 @@ describe("runCreateEmptyWorkspace", () => {
cwd: "/sample/repo",
prompt: "",
attachments: [],
withInitialAgent: false,
});
expect(recorded).toEqual([{ serverId: "server-abc", workspaceId: "workspace-123" }]);
});

View File

@@ -12,6 +12,7 @@ export interface CreateEmptyWorkspaceInput {
cwd: string;
prompt: string;
attachments: AgentAttachment[];
withInitialAgent: boolean;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
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);
}

View File

@@ -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}
/>
) : (
<Folder size={iconSize} color={iconColor} />
@@ -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(
() => (
<View style={styles.rowIconBox}>
{optionId === "worktree" ? (
<GitBranch size={iconSize} color={iconColor} />
) : (
<Folder size={iconSize} color={iconColor} />
)}
</View>
),
[optionId, iconSize, iconColor],
);
return (
<ComboboxItem
testID={`workspace-create-backing-${optionId}`}
label={label}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
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}
/>
</View>
),
@@ -477,6 +530,7 @@ function useNewWorkspaceProjectPicker({
sourceDirectory,
projectId,
displayName: displayNameProp,
allowAllProjects,
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
const [manualProjectKey, setManualProjectKey] = useState<string | null>(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<View | null>;
onPress: () => void;
disabled: boolean;
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
backing: "local" | "worktree";
label: string;
iconColor: string;
iconSize: number;
}) {
return (
<Pressable
ref={pickerAnchorRef}
testID="workspace-create-backing-trigger"
onPress={onPress}
disabled={disabled}
style={badgePressableStyle}
accessibilityRole="button"
accessibilityLabel="Workspace isolation"
>
<View style={styles.badgeIconBox}>
{backing === "worktree" ? (
<GitBranch size={iconSize} color={iconColor} />
) : (
<Folder size={iconSize} color={iconColor} />
)}
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{label}
</Text>
<ChevronDown size={iconSize} color={iconColor} />
</Pressable>
);
}
// 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 (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
{children}
</View>
);
}
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<ReturnType<typeof useHostRuntimeClient>>;
backing: "local" | "worktree";
project: HostProjectListItem;
selectedItem: PickerItem | null;
currentBranch: string | null;
withInitialAgent: boolean;
mergeWorkspaces: (
serverId: string,
workspaces: ReturnType<typeof normalizeWorkspaceDescriptor>[],
) => void;
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
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<typeof useAgentInputDraft>["composerState"];
@@ -617,6 +815,7 @@ interface CreateChatAgentInput {
cwd: string;
prompt: string;
attachments: AgentAttachment[];
withInitialAgent: boolean;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string;
draftKey: string;
@@ -641,6 +840,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
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<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
@@ -784,10 +988,12 @@ export function NewWorkspaceScreen({
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(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<View>(null);
const projectPickerAnchorRef = useRef<View>(null);
const backingPickerAnchorRef = useRef<View>(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<ComboboxOptionType[]>(() => {
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 (
<BackingOptionItem
optionId={option.id}
label={option.label}
selected={selected}
active={active}
disabled={isPending}
onPress={onPress}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
);
},
[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(
() => (
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
<LabeledRow label={t("newWorkspace.fields.project")}>
<View>
<ProjectPickerTrigger
pickerAnchorRef={projectPickerAnchorRef}
onPress={openProjectPicker}
disabled={isPending || projectPickerOptions.length === 0}
badgePressableStyle={badgePressableStyle}
label={projectTriggerLabel}
projectKey={selectedProject?.projectKey ?? null}
iconDataUri={
selectedProject
? (projectIconDataByProjectKey.get(selectedProject.projectKey) ?? null)
: null
}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={projectPickerOptions}
value={selectedProjectOptionId}
onSelect={handleSelectProjectOption}
searchable
searchPlaceholder="Search projects"
title="Project"
open={projectPickerOpen}
onOpenChange={handleProjectPickerOpenChange}
desktopPlacement="bottom-start"
anchorRef={projectPickerAnchorRef}
emptyText="No projects available."
renderOption={renderProjectOption}
/>
</View>
</LabeledRow>
{/* 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 ? (
<LabeledRow label={t("newWorkspace.backing.label")}>
<View>
<IsolationPickerTrigger
pickerAnchorRef={backingPickerAnchorRef}
onPress={openBackingPicker}
disabled={isPending}
badgePressableStyle={badgePressableStyle}
backing={effectiveBacking}
label={backingTriggerLabel}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={backingOptions}
value={effectiveBacking}
onSelect={handleSelectBackingOption}
title={t("newWorkspace.backing.label")}
open={backingPickerOpen}
onOpenChange={handleBackingPickerOpenChange}
desktopPlacement="bottom-start"
anchorRef={backingPickerAnchorRef}
renderOption={renderBackingOption}
/>
</View>
</LabeledRow>
) : (
<View style={styles.baseSpacer} />
)}
{/* 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 ? (
<LabeledRow label={t("newWorkspace.fields.base")}>
<View>
<RefPickerTrigger
pickerAnchorRef={pickerAnchorRef}
onPress={openPicker}
disabled={isPending || !selectedSourceDirectory}
badgePressableStyle={badgePressableStyle}
selectedItem={selectedItem}
triggerLabel={triggerLabel}
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
tooltipLabel={t("newWorkspace.refPicker.chooseStart")}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={options}
value={selectedOptionId}
onSelect={handleSelectOption}
searchable
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
title={t("newWorkspace.refPicker.title")}
open={pickerOpen}
onOpenChange={handlePickerOpenChange}
onSearchQueryChange={setPickerSearchQuery}
desktopPlacement="bottom-start"
anchorRef={pickerAnchorRef}
emptyText={pickerEmptyText}
renderOption={renderPickerOption}
/>
</View>
</LabeledRow>
) : (
<View style={styles.baseSpacer} />
)}
</View>
),
[
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(
() => (
<View testID="new-workspace-ref-picker-row" style={styles.optionsRow}>
<View>
<ProjectPickerTrigger
pickerAnchorRef={projectPickerAnchorRef}
onPress={openProjectPicker}
disabled={isPending || projectPickerOptions.length === 0}
badgePressableStyle={badgePressableStyle}
label={projectTriggerLabel}
projectKey={selectedProject?.projectKey ?? null}
iconDataUri={
selectedProject
? (projectIconDataByProjectKey.get(selectedProject.projectKey) ?? null)
: null
}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={projectPickerOptions}
value={selectedProjectOptionId}
onSelect={handleSelectProjectOption}
searchable
searchPlaceholder="Search projects"
title="Project"
open={projectPickerOpen}
onOpenChange={handleProjectPickerOpenChange}
desktopPlacement="bottom-start"
anchorRef={projectPickerAnchorRef}
emptyText="No projects available."
renderOption={renderProjectOption}
/>
</View>
<View>
<RefPickerTrigger
pickerAnchorRef={pickerAnchorRef}
onPress={openPicker}
disabled={isPending || !selectedSourceDirectory}
badgePressableStyle={badgePressableStyle}
selectedItem={selectedItem}
triggerLabel={triggerLabel}
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
tooltipLabel={t("newWorkspace.refPicker.chooseStart")}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={options}
value={selectedOptionId}
onSelect={handleSelectOption}
searchable
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
title={t("newWorkspace.refPicker.title")}
open={pickerOpen}
onOpenChange={handlePickerOpenChange}
onSearchQueryChange={setPickerSearchQuery}
desktopPlacement="bottom-start"
anchorRef={pickerAnchorRef}
emptyText={pickerEmptyText}
renderOption={renderPickerOption}
/>
</View>
<>
{agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : null}
@@ -1294,40 +1682,16 @@ export function NewWorkspaceScreen({
iconSize={theme.iconSize.sm}
/>
) : null}
</View>
</>
),
[
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(() => <SidebarMenuToggle />, []);
@@ -1342,6 +1706,7 @@ export function NewWorkspaceScreen({
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
</View>
{formStack}
<Composer
agentId={draftKey}
serverId={serverId}
@@ -1349,6 +1714,7 @@ export function NewWorkspaceScreen({
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={pendingAction !== null}
submitBehavior="preserve-and-lock"
@@ -1410,16 +1776,30 @@ const styles = StyleSheet.create((theme) => ({
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",
},
}));

View File

@@ -7,8 +7,12 @@ export type ListTerminalsPayload = ListTerminalsResponse["payload"];
type TerminalEntry = ListTerminalsPayload["terminals"][number];
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
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: {

View File

@@ -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<ListTerminalsPayload>(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) {

View File

@@ -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({
</View>
) : (
<View style={styles.headerTitleTextGroup}>
<BranchSwitcher
currentBranchName={currentBranchName}
title={title}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isGitCheckout={isGitCheckout}
/>
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
{showSubtitle ? (
<Text
testID="workspace-header-subtitle"
@@ -1798,6 +1790,7 @@ function WorkspaceScreenContent({
deriveWorkspaceAgentVisibility({
sessionAgents: state.sessions[normalizedServerId]?.agents,
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
workspaceId: normalizedWorkspaceId,
workspaceDirectory,
}),
workspaceAgentVisibilityEqual,
@@ -3637,7 +3630,6 @@ function WorkspaceScreenContent({
subtitle={workspaceHeaderSubtitle}
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
currentBranchName={currentBranchName}
isGitCheckout={isGitCheckout}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
workspaceScripts={workspaceScripts}

View File

@@ -47,6 +47,45 @@ describe("workspace source of truth consumption", () => {
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({

View File

@@ -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",

View File

@@ -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<string, WorkspaceDescriptor> }
{
hasHydratedWorkspaces?: boolean;
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects?: Map<string, EmptyProjectDescriptor>;
}
>;
}
@@ -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[] {

View File

@@ -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<string, Agent>;
agentDetails: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
// Project parents with no active workspaces, keyed by projectId. Rendered as
// empty project rows in the sidebar.
emptyProjects: Map<string, EmptyProjectDescriptor>;
// Permissions
pendingPermissions: Map<string, PendingPermission>;
@@ -426,6 +453,8 @@ interface SessionStoreActions {
) => void;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
removeWorkspace: (serverId: string, workspaceId: string) => void;
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => 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<SessionStore>()(
});
},
setEmptyProjects: (serverId, emptyProjects) => {
const next = new Map<string, EmptyProjectDescriptor>();
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<SessionStore>()(
}
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<SessionStore>()(
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, workspaces: next },
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
},
};
});

View File

@@ -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,

View File

@@ -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<SidebarWorkspaceEntry> = {}): 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<SidebarProjectEntry> = {}): 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",
});
});
});

View File

@@ -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",
};
}

View File

@@ -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<string>(["p1"]),
projects: [gitProject, directoryProject],
collapsedProjectKeys: new Set<string>(["p1", "p2"]),
});
expect(model.shortcutTargets).toEqual([]);

View File

@@ -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<string, number>();
for (const project of input.projects) {
if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
if (input.collapsedProjectKeys.has(project.projectKey)) {
continue;
}

View File

@@ -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<string, Agent>([
[
"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<string, Agent>([
[
"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<string>());
expect(result.knownAgentIds).toEqual(new Set<string>());
});
it("falls back to cwd matching for legacy agents without a workspaceId", () => {
const sessionAgents = new Map<string, Agent>([
["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"]),

View File

@@ -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<string>;
}
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<string, Agent> | undefined;
agentDetails?: Map<string, Agent> | 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<string>(),
autoOpenAgentIds: new Set<string>(),
@@ -32,7 +49,7 @@ export function deriveWorkspaceAgentVisibility(input: {
const autoOpenAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
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);

View File

@@ -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,
};
}

View File

@@ -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 (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onCancel}
scrollable={false}
desktopMaxWidth={420}
testID="worktree-delete-confirm"
>
<View style={styles.body}>
<Text style={styles.message}>
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.message", {
workspaceName,
})}
</Text>
<View style={styles.actions}>
<Button variant="default" onPress={onKeep} testID="worktree-delete-confirm-keep">
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.keep")}
</Button>
<Button variant="destructive" onPress={onDelete} testID="worktree-delete-confirm-delete">
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.delete")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
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],
},
}));

View File

@@ -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" });
});
});

View File

@@ -36,6 +36,10 @@ export function addRunOptions(cmd: Command): Command {
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--worktree <name>", "Create agent in a new git worktree")
.option("--base <branch>", "Base branch for worktree (default: current branch)")
.option(
"--workspace <id>",
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
)
.option(
"--image <path>",
"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 <id> -> run in that existing workspace
// 2. $PASEO_WORKSPACE_ID -> exported by workspace terminals
// 3. --worktree <name> -> 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<RunWorkspace> {
// 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 <id> (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,
});

View File

@@ -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<FileExplorerPayload["directory"]>;
type LegacyFileExplorerFilePayload = NonNullable<FileExplorerPayload["file"]>;
@@ -879,7 +884,7 @@ export class DaemonClient {
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>();
private terminalDirectorySubscriptions = new Set<string>();
private terminalDirectorySubscriptions = new Map<string, { cwd: string; workspaceId?: string }>();
private readonly terminalStreams = new TerminalStreamRouter();
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
@@ -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<AgentSessionConfig>,
@@ -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<PaseoWorktreeArchivePayload> {
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<WorkspaceCreatePayload> {
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<ListTerminalsPayload> {
async listTerminals(
cwd?: string,
requestId?: string,
options?: { workspaceId?: string },
): Promise<ListTerminalsPayload> {
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<CreateTerminalPayload> {
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({

View File

@@ -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,

View File

@@ -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<typeof ProjectCheckoutLitePaylo
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
export type WorkspaceProjectDescriptorPayload = z.infer<
typeof WorkspaceProjectDescriptorPayloadSchema
>;
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
@@ -4020,6 +4126,12 @@ export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureRespo
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
export type WorkspaceTitleSetResponse = z.infer<typeof WorkspaceTitleSetResponseSchema>;
export type WorkspaceTitleSetResponsePayload = z.infer<
typeof WorkspaceTitleSetResponsePayloadSchema
>;
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
@@ -4136,6 +4248,7 @@ export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessage
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;

View File

@@ -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_<hex>`.
export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string {
return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`;
}

View File

@@ -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");
}

View File

@@ -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");

View File

@@ -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<string, string>;
persistSession?: boolean;
initialTitle?: string | null;
workspaceId?: string;
},
): Promise<ManagedAgent> {
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<string, string>;
workspaceId?: string;
},
): Promise<ManagedAgent> {
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<ManagedAgent> {
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,

View File

@@ -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({

View File

@@ -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(),

View File

@@ -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<unknown>;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => void;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
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,
},
{

View File

@@ -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<CreatePaseoWorktreeWorkflowResult> =>
({
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 });
}
});

View File

@@ -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<string, string>;
initialTitle?: string | null;
workspaceId?: string;
}
export async function createAgentCommand(
@@ -184,7 +188,7 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> {
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,
};
}

View File

@@ -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({

View File

@@ -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,
};

View File

@@ -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<void>;
}
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<void>((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<typeof createTestLogger> = 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<string, { child: ChildProcess; exit: unknown }>;
subscribers: Map<unknown, unknown>;
activeForegroundTurnId: string | null;
}
async function startTerminal(
session: ACPAgentSession,
child: ChildProcess,
command = "sleep",
): Promise<string> {
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<ACPCloseInternals>(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<ACPCloseInternals>(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<ACPCloseInternals>(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<ACPCloseInternals>(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<SpawnedACPProcess> {
@@ -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);
});
});

View File

@@ -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<string, string>;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
terminateProcess?: ProcessTerminator;
}
export interface SpawnedACPProcess {
@@ -606,9 +609,11 @@ export class ACPAgentClient implements AgentClient {
) => Promise<void>;
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<void> {
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<Record<string, never>> {
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<void> {
try {
await terminateWithTreeKill(child, { gracefulTimeoutMs: timeoutMs, forceTimeoutMs: timeoutMs });
await terminate(child, { gracefulTimeoutMs: timeoutMs, forceTimeoutMs: timeoutMs });
} finally {
child.stdin.destroy();
child.stdout.destroy();

View File

@@ -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<string>();
@@ -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",
},
);

View File

@@ -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<string | null>;
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => 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",
},
);

View File

@@ -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<string | null> => {
return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list());
};
const listActiveWorkspacesExternal = async (): Promise<ActiveWorkspaceRef[]> => {
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<string>, 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,

View File

@@ -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<Set<string>> {
const workspaces = await client.fetchWorkspaces();
return new Set(workspaces.entries.map((entry) => entry.id));
}
async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise<string> {
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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<string>();
const cleanupDaemons = new Set<TestPaseoDaemon>();
const cleanupClients = new Set<DaemonClient>();
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);

View File

@@ -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);

View File

@@ -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<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
// 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<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
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<string[]> {
@@ -55,84 +77,31 @@ export async function archivePaseoWorktree(
targetPath = resolvedWorktree.worktreePath;
}
const archivedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([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<string>();
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<string>,
): Promise<string[]> {
const workspaceIds = new Set<string>();
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<string | null> {
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<Set<string>> {
const archivedAgents = new Set<string>();
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<void> {
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<void> {
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);
}
}
}

View File

@@ -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<ProjectRegistry, "get" | "list" | "upsert">;
projects: Map<string, PersistedProjectRecord>;
workspaces: Map<string, PersistedWorkspaceRecord>;
}
@@ -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 {

View File

@@ -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<ProjectRegistry, "get" | "list" | "upsert">;
workspaceRegistry: Pick<WorkspaceRegistry, "list" | "upsert">;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
}
// 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<PersistedWorkspaceRecord> {
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<typeof classifyDirectoryForProjectMembership>;
timestamp: string;
projectRegistry: Pick<ProjectRegistry, "get" | "list">;
}) {
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<WorkspaceRegistry, "list">,
): Promise<PersistedWorkspaceRecord | null> {
const workspaces = await workspaceRegistry.list();
return workspaces.find((workspace) => workspace.cwd === cwd) ?? null;
}

View File

@@ -98,12 +98,14 @@ export function extractTimestamps(record: StoredAgentRecord): {
updatedAt: Date;
lastUserMessageAt: Date | null;
labels?: Record<string, string>;
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,
};
}

View File

@@ -3,5 +3,6 @@ export {
createScriptProxyUpgradeHandler,
findFreePort,
ScriptRouteStore,
ServiceProxyRouteCollisionError,
} from "./service-proxy.js";
export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js";

View File

@@ -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,

View File

@@ -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 });

View File

@@ -360,7 +360,7 @@ export class ServiceProxyRouteCollisionError extends Error {
public readonly incoming: Pick<ServiceProxyRouteEntry, "workspaceId" | "scriptName">,
) {
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";
}

View File

@@ -64,18 +64,6 @@ async function expectActiveAgentListEmpty(): Promise<void> {
expect(active.entries).toEqual([]);
}
async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise<void> {
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<void> {
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 () => {

Some files were not shown because too many files have changed in this diff Show More