diff --git a/.adr/0032-a-worktree-is-opened-by-the-multiplexer-not-by-git.md b/.adr/0032-a-worktree-is-opened-by-the-multiplexer-not-by-git.md new file mode 100644 index 00000000..78168656 --- /dev/null +++ b/.adr/0032-a-worktree-is-opened-by-the-multiplexer-not-by-git.md @@ -0,0 +1,91 @@ +# 0032 — A worktree is opened by the multiplexer, not by Git + +Status: **Accepted** (2026-08-28) + +Contract: [`MUX_CONTRACT.md`](../MUX_CONTRACT.md) · Code: [`bridge/mux/`](../bridge/mux/) · +Related: [ADR 0022](./0022-the-mux-seam-is-a-port-collie-owns.md) (the port these capabilities join) · +[ADR 0011](./0011-the-pack-protocol-is-the-mux-driver-seam.md) (a mux adapter is host-local) + +## Context + +Starting fresh work from the phone means starting it in whatever checkout the pane already sits in +([#133](https://github.com/AltanS/collie/issues/133)). Branching off and putting an agent on it is a +walk back to the desk — the one thing Collie exists to remove. + +There is a cheaper route than the one below, it is obvious, and it will be proposed again. **Collie +already runs on the host; `git worktree add` is one command; `createSpace` already opens a space at a +path.** Two calls Collie can make today, no port change, no capability, and it works on every adapter +that can make a space. It is the right instinct — the mux port must not grow a vendor's feature list +— and it is why this decision needs writing down rather than merely making. + +It fails on what happens next, and the failure is not visible on the day it ships. + +**Herdr does not merely run `git worktree add`; it keeps a record.** Probed 2026-08-28 against herdr +0.8.2: `worktree.list` answers with `open_workspace_id` per checkout, and the session snapshot carries +a `worktree` block on every workspace (`repo_key`, `repo_root`, `checkout_path`, +`is_linked_worktree`). That record is the whole feature: it is how the phone knows which checkouts +exist, which of them a space is already showing, and which space to nest under which repo. A checkout +made behind Herdr's back has none of it — Herdr's desktop cannot show it as open, and Collie could +only list it by doing its own Git work and then reconciling two answers about the same directory. + +Removal makes the point sharper still, and it is worth stating even though this decision does not +implement it: Herdr's `worktree.remove` is addressed **by workspace**, with no path-addressed form at +all. So a checkout created outside Herdr's bookkeeping could never be removed through the socket — +the verb would have nothing to name. The cheap route does not produce a worse worktree; it produces +one nothing can clean up. + +The second problem is placement. Herdr puts linked worktrees under `~/.herdr/worktrees//` +and the operator's desktop finds them there. A Collie that chose its own directory would scatter +checkouts a running Herdr already had opinions about. + +So the question is not "may the port know about Git". It is: **who owns the mapping from a checkout +to the space showing it?** Whoever owns it must do the creating, because the record is made at +creation time and cannot be reconstructed afterwards. + +## Decision + +**A worktree verb asks the multiplexer, never Git. Three declared capabilities — +`listWorktrees`, `createWorktree`, `openWorktree` — with three routes behind them.** + +- **The port speaks checkouts and spaces, not Git.** `MuxWorktree` carries a path, a branch, and the + space showing it; there is no ref, no remote, no status. What the port asks for is the act that + ends in a space appearing, moving or going away — which is a multiplexer's job — and the Git + underneath is the adapter's business. +- **Removal is NOT in this decision.** Collie has no `closeSpace` capability, and Herdr's + `worktree.remove` closes the space with the checkout — so shipping it here would hand Collie its + first space-destroying verb through a side door. `MuxWorktree.openSpaceId` is carried anyway, + because it is what tells the phone which checkouts are already spaces; removal can be added later + against that same field, deliberately, as its own argument. +- **`focus` is never sent.** Creating from the phone must not move the operator's own screen; the + phone navigates itself. Changing what the terminal shows stays `setFocus`, which is its own + capability for exactly this reason. +- **Herdr declares all three; tmux and zellij decline them, with reasons.** tmux could shell out to + Git, but it keeps no record tying a checkout to the session showing it, so what it produced could + not be listed or removed again. Declining is the honest answer until an adapter keeps that mapping + itself and a probe proves it. + +## Alternatives rejected + +- **`git worktree add` + `createSpace` (the cheap route).** Rejected above: it creates checkouts the + multiplexer cannot remove, in a directory it did not choose. Its one real advantage — working on + tmux too — buys a worse feature on the adapter every current operator runs. +- **One umbrella `worktrees` capability.** Rejected because the repo splits by the control that + appears or does not (`createTab`/`renameTab`/`closeTab`), and these are three separate affordances. + An umbrella would also force an adapter that could only create to declare it could also list. +- **Version-gating the declaration on Herdr's protocol number.** Rejected as a change of mechanism + smuggled in with a feature: no capability is gated that way today, and an old Herdr already answers + an unknown method with a refusal the phone can show. + +## Consequences + +- **Three rows in the matrix, three columns each, and every Herdr cell owes a probe.** Paid: the + cells cite a first-hand probe of herdr 0.8.2 on 2026-08-28. +- **`createWorktree` is not atomic and callers must treat it as two steps.** Probed: in a session + with no window server the checkout was created and the open failed with `worktree_open_failed` — + the branch exists and nothing shows it. Recovery is `openWorktree`, never a second create, which + answers `worktree_create_failed` because the path is taken. The UI says so. +- **Nothing is removed from the phone at all yet.** A worktree can be listed, created and opened; a + checkout that has outlived its use is still cleaned up at the desk. +- **What would justify revisiting this.** A second multiplexer growing real worktree bookkeeping (so + the capability stops being one vendor's), or Collie deciding what it means to destroy a space — + which is the argument `removeWorktree` is waiting on, not a missing line of code. diff --git a/MUX_CONTRACT.md b/MUX_CONTRACT.md index 611f95c3..7d98cbf2 100644 --- a/MUX_CONTRACT.md +++ b/MUX_CONTRACT.md @@ -18,6 +18,7 @@ Sources, once: | **T** | First-hand probe of **tmux 3.6b** on a throwaway server — [M10/04 Ground Truth](./.tracker/M10-mux-drivers/04-the-tmux-adapter.md) | | **Z** | First-hand probe of **zellij 0.44.2** — [M10/05 Ground Truth](./.tracker/M10-mux-drivers/05-the-zellij-adapter.md) | | **L** | First-hand probe of this host's **live test instances** — 2026-08-25, tmux socket `/run/user/1000/collie-tmux.sock` and zellij session `collie-zellij` | +| **H8** | First-hand probe of **herdr 0.8.2** (protocol 20) on this host — 2026-08-28, an isolated `herdr --session wtprobe` over a throwaway repo, worktree verbs only | | **?** | Not probed yet. The adapter's spec probes it and fills the cell in; **an unprobed cell is never declared supported.** | ## The floor — not capabilities @@ -86,6 +87,9 @@ ceiling: attention is something the bridge observes, never something a caller ca | `closeTab` | `POST /api/tab/:id/close` | `tab.close` — a bulk pane-close (**API** § Close) | `kill-window` (**T**) — and it ends the session too when it was the last window, exactly as tmux itself does | `close-tab-by-id ` (**Z**) — and zellij closes a tab whose last pane goes away, exactly as it does by hand | | `setFocus` | `POST /api/pane/:id/focus` — the pane sheet's "Show in terminal" row | `pane.focus {pane_id}`, ONE call: probed 2026-08-25 against the `collie-demo` sandbox session, the reply was `pane_info` and the next snapshot moved `focused_pane_id`, `focused_tab_id` AND `focused_workspace_id` together, so `tab.focus`/`workspace.focus` are never called. A pane that has gone answers `pane_not_found` (probed read-only against the live server) → `gone` | `select-window -t ; select-pane -t ` — one invocation, both levels, because a screen showing the right window and the wrong pane is a half-kept promise (**T**, probed 2026-08-25 on the test server: `window_active` and `pane_active` moved together) — **plus `; switch-client -c -t ` for every attached non-control client sitting on another session**, because tmux's current window belongs to the SESSION and those two commands move nothing on a terminal that is showing a different one (**T**, probed 2026-08-25 on the two-session test server: focusing a `collie-tmux` pane answered `{ok:true}` while the client on `ss-wp` stayed put; with the switch-client leg the client moved and `#{session_name} #{pane_id}` followed). No client attached ⇒ nothing to switch, and the window/pane selection alone is right: the next attach lands there. The window id, the session id and the clients all come out of the same listing the snapshot uses, so a stale pane id is `gone` before anything is spawned; `can't find window:` / `can't find pane:` classify as `gone` | **no** (**Z**) — `action focus-pane-id` exists on 0.44.2 and does NOTHING: probed 2026-08-25 with a client attached, both `focus-pane-id terminal_3` and the bare `focus-pane-id 3` exited 0 while `list-clients` still reported the client on `terminal_0`, and `focus-next-pane` moved it, so the session was live. `go-to-tab ` DOES work (1-based over tab position, probed) — and a tab-level approximation is deliberately declined: the promise is "this pane is in front", and showing a tab whose focus sits on a neighbour is the quiet lie conformance exists to catch | | `createSpace` | `POST /api/workspace` | `workspace.create` (**API**) | `new-session -d -P -F` (**T**) — claimed: it is one verb, it is detached, and a duplicate name comes back as `refused` with tmux's own sentence. It carries the same `window-size manual` refusal as `createTab`: `new-session` spawns a window too, so tmux #4849 kills the server here as well | **no** (**Z**) — `zellij attach --create-background` does make a detached session, but every zellij verb is scoped to ONE session, so a session created here would be invisible to the adapter that made it | +| `listWorktrees` | `GET /api/workspace/:id/worktrees` | `worktree.list {cwd}` — answers for a repo NOTHING has open, so the sheet can list a checkout before there is a space to name it by (**H8**). The repo's own checkout comes back too, with `is_linked_worktree: false` | **no** (**T**) — tmux has no Git vocabulary; a `git worktree list` here would answer about the host, not about anything tmux knows | **no** (**Z**) — same, and see `createSpace` | +| `createWorktree` | `POST /api/workspace/:id/worktree` | `worktree.create {cwd, branch, focus:false}` → the new workspace and its root pane, ready to navigate to (**H8**). **NOT ATOMIC:** probed in a session with no window server, the checkout was created and the open failed `worktree_open_failed` — the branch exists, nothing shows it, and a retry answers `worktree_create_failed` because the path is taken. Recovery is `openWorktree` | **no** (**T**) — `git worktree add` would run, but tmux keeps no record tying the checkout to the session showing it, so what it made could not be listed or removed again (ADR 0032) | **no** (**Z**) — same, plus one session means no second space to open it as | +| `openWorktree` | `POST /api/workspace/:id/worktree/open` | `worktree.open {cwd, path, focus:false}` → `already_open` plus the space showing it; asking for one already up is an ANSWER, not a refusal (**H8**). `path` alone answers `not_git_worktree` — the repo must come with it | **no** (**T**) | **no** (**Z**) | | `pushTopologyEvents` | `bridge/event-poker.ts` | full event catalog: workspace/tab/pane created, closed, renamed (**API** § Event stream) | control mode pushes `%window-add`, `%session-changed` (**T**) | **no** (**Z**) — no CLI verb announces one. `zellij watch` is a read-only attach, `zellij pipe` needs a WASM plugin on the other end, and `action --help` has no event verb. The adapter censuses `list-panes` instead: 3 s after any change, doubling to 12 s while nothing moves | | `pushPaneEvents` | `bridge/event-poker.ts` | `pane.agent_status_changed`, pane-scoped (**API** § Event stream) | `%output`, but only for the panes of the session a control client is ATTACHED to (**T**) — so the adapter attaches one per watched session, capped, and a 5-second listing is the floor | `subscribe --ansi --format json --pane-id …` — several panes per stream, newline-delimited JSON `pane_update` frames (**Z**). It also emits one `pane_closed`, the single topology fact zellij does push, which shortens the census rather than replacing it | diff --git a/bridge/beacon/decorate.test.ts b/bridge/beacon/decorate.test.ts index 2303f108..0580626a 100644 --- a/bridge/beacon/decorate.test.ts +++ b/bridge/beacon/decorate.test.ts @@ -17,6 +17,10 @@ import { type MuxSubscription, type MuxTabRequest, type MuxWatchOptions, + type MuxWorktree, + type MuxWorktreeCreateRequest, + type MuxWorktreeOpenRequest, + type MuxWorktreeScope, } from "../mux/types.ts"; // THE DECORATOR'S OWN TESTS — the join, the two-way capability lift, and the pass-through. @@ -133,6 +137,22 @@ class StubAdapter implements MuxAdapter { return Promise.resolve(muxOk({ paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" })); } + listWorktrees(scope: MuxWorktreeScope) { + this.note("listWorktrees", scope); + return Promise.resolve(muxOk([])); + } + + createWorktree(request: MuxWorktreeCreateRequest) { + this.note("createWorktree", request); + return Promise.resolve(muxOk({ paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" })); + } + + openWorktree(request: MuxWorktreeOpenRequest) { + this.note("openWorktree", request); + return Promise.resolve(muxOk({ pane: { paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" }, alreadyOpen: false })); + } + + watch(options: MuxWatchOptions): MuxSubscription { this.note("watch", options); return this.subscription; @@ -238,6 +258,9 @@ describe("a decorator preserves the adapter's whole surface", () => { renameTab: true, closeTab: true, createSpace: true, + listWorktrees: true, + createWorktree: true, + openWorktree: true, watch: true, } satisfies Record, true>; diff --git a/bridge/beacon/decorate.ts b/bridge/beacon/decorate.ts index 64ff9d4a..e374acd6 100644 --- a/bridge/beacon/decorate.ts +++ b/bridge/beacon/decorate.ts @@ -298,6 +298,9 @@ export function withAgentBeacons( renameTab: (tabId, label) => adapter.renameTab(tabId, label), closeTab: (tabId) => adapter.closeTab(tabId), createSpace: (request) => adapter.createSpace(request), + listWorktrees: (scope) => adapter.listWorktrees(scope), + createWorktree: (request) => adapter.createWorktree(request), + openWorktree: (request) => adapter.openWorktree(request), // Untouched, and the header says why a beacon change fires nothing here. watch: (options) => adapter.watch(options), }; diff --git a/bridge/beacon/hint.test.ts b/bridge/beacon/hint.test.ts index f760932b..03b22804 100644 --- a/bridge/beacon/hint.test.ts +++ b/bridge/beacon/hint.test.ts @@ -14,6 +14,10 @@ import { type MuxSubscription, type MuxTabRequest, type MuxWatchOptions, + type MuxWorktree, + type MuxWorktreeCreateRequest, + type MuxWorktreeOpenRequest, + type MuxWorktreeScope, } from "../mux/types.ts"; // THE HINT'S OWN TESTS — the three suppression rules, and the promise that a hint changes nothing @@ -184,6 +188,22 @@ class StubAdapter implements MuxAdapter { return Promise.resolve(muxOk({ paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" })); } + listWorktrees(_scope: MuxWorktreeScope) { + this.calls.push("listWorktrees"); + return Promise.resolve(muxOk([])); + } + + createWorktree(_request: MuxWorktreeCreateRequest) { + this.calls.push("createWorktree"); + return Promise.resolve(muxOk({ paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" })); + } + + openWorktree(_request: MuxWorktreeOpenRequest) { + this.calls.push("openWorktree"); + return Promise.resolve(muxOk({ pane: { paneId: "%9", spaceId: "space", spaceLabel: "space", tabId: "tab", cwd: "/tmp" }, alreadyOpen: false })); + } + + watch(_options: MuxWatchOptions): MuxSubscription { this.calls.push("watch"); return this.subscription; diff --git a/bridge/beacon/hint.ts b/bridge/beacon/hint.ts index bed993bf..f6ba3ffa 100644 --- a/bridge/beacon/hint.ts +++ b/bridge/beacon/hint.ts @@ -36,6 +36,11 @@ import type { MuxPane, MuxSnapshot, MuxSpaceRequest, + MuxWorktree, + MuxWorktreeCreateRequest, + MuxWorktreeOpenRequest, + MuxWorktreeOpened, + MuxWorktreeScope, MuxSubscription, MuxTabRequest, MuxWatchOptions, @@ -168,6 +173,12 @@ export function withAgentHints(adapter: MuxAdapter, deps: AgentHintDeps): MuxAda renameTab: (tabId: string, label: string): Promise => adapter.renameTab(tabId, label), closeTab: (tabId: string): Promise => adapter.closeTab(tabId), createSpace: (request: MuxSpaceRequest): Promise> => adapter.createSpace(request), + listWorktrees: (scope: MuxWorktreeScope): Promise> => + adapter.listWorktrees(scope), + createWorktree: (request: MuxWorktreeCreateRequest): Promise> => + adapter.createWorktree(request), + openWorktree: (request: MuxWorktreeOpenRequest): Promise> => + adapter.openWorktree(request), watch: (options: MuxWatchOptions): MuxSubscription => adapter.watch(options), }; } diff --git a/bridge/error-codes.ts b/bridge/error-codes.ts index 156a6155..393b8f2c 100644 --- a/bridge/error-codes.ts +++ b/bridge/error-codes.ts @@ -78,6 +78,27 @@ export const ERROR_CODES = { "tab.workspace_required": "workspaceId required", "workspace.create_failed": "{reason}", + // ── Worktrees: /api/workspace/:id/worktree[s|/open|/remove] (ADR 0032) ───────────── + /** The list could not be read — the space is not in a Git work tree, or the mux refused. */ + "worktree.list_failed": "{reason}", + /** Creation refused. `{reason}` is the multiplexer's own words, Git's sentence included. */ + "worktree.create_failed": "{reason}", + /** + * The checkout was made and could not be shown — the branch EXISTS and nothing displays it. + * Distinct from `create_failed` because the recovery is opposite: open it, never create it again + * (a second create answers `create_failed`, the path being taken). Probed on herdr 0.8.2. + */ + "worktree.created_not_opened": "the worktree was created but could not be opened: {reason}", + "worktree.open_failed": "{reason}", + /** Another worktree operation is still running — herdr serialises them. Try again in a moment. */ + "worktree.busy": "{reason}", + /** The branch name matched more than one thing, so the multiplexer would not guess. */ + "worktree.ambiguous_branch": "{reason}", + /** The request named no branch, or named one that is only whitespace. */ + "worktree.branch_required": "branch required", + /** This space is not in a Git work tree, so it has no worktrees to show. */ + "worktree.not_a_repo": "{reason}", + // ── Image upload: POST /api/pane/:id/upload → UploadResponse ─────────────────────── /** Refused on the declared Content-Length (413) or on the decoded size (200 + ok:false). */ "upload.too_large": "image too large (max 10 MB)", diff --git a/bridge/mux/capabilities.ts b/bridge/mux/capabilities.ts index 4cc1b602..c285ab89 100644 --- a/bridge/mux/capabilities.ts +++ b/bridge/mux/capabilities.ts @@ -37,6 +37,9 @@ export const MUX_CAPABILITIES = [ "renameTab", "closeTab", "createSpace", + "listWorktrees", + "createWorktree", + "openWorktree", "pushTopologyEvents", "pushPaneEvents", ] as const; @@ -90,6 +93,12 @@ export const MUX_CAPABILITY_ROUTES = { renameTab: "POST /api/tab/:id/rename.", closeTab: "POST /api/tab/:id/close — a bulk pane-close.", createSpace: "POST /api/workspace — a new space, opening a fresh shell.", + listWorktrees: + "GET /api/workspace/:id/worktrees — the worktrees of the repo that space sits in, so the sheet can show them. Absent ⇒ no worktree section at all, which is the honest degrade: without the list there is nothing to open and nothing to remove.", + createWorktree: + "POST /api/workspace/:id/worktree — a new branch in a new worktree, opened as its own space.", + openWorktree: + "POST /api/workspace/:id/worktree/open — show a worktree that already exists on disk. Idempotent: a worktree already open answers with the space showing it rather than refusing.", pushTopologyEvents: "bridge/event-poker.ts — panes/tabs/spaces appearing, closing or being renamed arrive as a push, so the snapshot poll can idle. Absent ⇒ the adapter polls to keep the same promise, and the poker learns nothing.", pushPaneEvents: @@ -194,6 +203,9 @@ export function declareCapabilities(input: MuxCapabilityInput): MuxCapabilityDec renameTab: claimed.has("renameTab"), closeTab: claimed.has("closeTab"), createSpace: claimed.has("createSpace"), + listWorktrees: claimed.has("listWorktrees"), + createWorktree: claimed.has("createWorktree"), + openWorktree: claimed.has("openWorktree"), pushTopologyEvents: claimed.has("pushTopologyEvents"), pushPaneEvents: claimed.has("pushPaneEvents"), } satisfies Record; diff --git a/bridge/mux/conformance.ts b/bridge/mux/conformance.ts index 9705926d..a0778304 100644 --- a/bridge/mux/conformance.ts +++ b/bridge/mux/conformance.ts @@ -230,6 +230,17 @@ function callTargets(snapshot: MuxSnapshot): CallTargets { }; } +/** The repo the worktree calls aim at. A path, because that is what the port asks for. */ +const CONFORMANCE_REPO = "/tmp"; + +let probeBranchCounter = 0; + +/** A branch nothing has taken, so a second run in the same world is not "already exists". */ +function nextProbeBranch(): string { + probeBranchCounter += 1; + return `collie/conformance-${String(probeBranchCounter)}`; +} + function capabilityCalls(adapter: MuxAdapter, targets: CallTargets): CapabilityCall[] { return [ { @@ -281,6 +292,46 @@ function capabilityCalls(adapter: MuxAdapter, targets: CallTargets): CapabilityC writes: true, run: async () => refusalOf(await adapter.createSpace({ cwd: "/tmp" })), }, + // ── Worktrees ──────────────────────────────────────────────────────────── + // + // Two directions, one table, and these three need the split spelled out. An adapter that DOES + // NOT declare them must refuse before it looks anything up, so a synthetic target is exactly + // right. An adapter that DOES declare them is being asked to prove the verb works — and + // "open this" and "remove that" cannot be proven against a checkout nothing made. So the + // declared side makes one first, through the adapter's own `createWorktree`, and aims at that. + { + capability: "listWorktrees", + // A read: it asks Git through the multiplexer and changes nothing. + writes: false, + run: async () => refusalOf(await adapter.listWorktrees({ repoRoot: CONFORMANCE_REPO })), + }, + { + capability: "createWorktree", + writes: true, + run: async () => + refusalOf( + await adapter.createWorktree({ repoRoot: CONFORMANCE_REPO, branch: nextProbeBranch() }), + ), + }, + { + capability: "openWorktree", + writes: true, + run: async () => { + if (!declares(adapter, "openWorktree")) { + return refusalOf( + await adapter.openWorktree({ repoRoot: CONFORMANCE_REPO, path: "/tmp/collie-no-worktree" }), + ); + } + const made = await adapter.createWorktree({ + repoRoot: CONFORMANCE_REPO, + branch: nextProbeBranch(), + }); + if (!made.ok) return made; + return refusalOf( + await adapter.openWorktree({ repoRoot: CONFORMANCE_REPO, path: made.value.cwd }), + ); + }, + }, { capability: "setFocus", // It moves the OPERATOR's screen, so it is a write in the sense that matters here: the live diff --git a/bridge/mux/herdr/adapter.ts b/bridge/mux/herdr/adapter.ts index fee9fb5f..90339f5b 100644 --- a/bridge/mux/herdr/adapter.ts +++ b/bridge/mux/herdr/adapter.ts @@ -48,6 +48,11 @@ import { type MuxTab, type MuxTabRequest, type MuxWatchOptions, + type MuxWorktree, + type MuxWorktreeCreateRequest, + type MuxWorktreeOpenRequest, + type MuxWorktreeOpened, + type MuxWorktreeScope, } from "../types.ts"; import { DEFAULT_TIMEOUT_MS, @@ -58,6 +63,7 @@ import { type WirePane, type WireTab, type WireWorkspace, + type WireWorktree, } from "./client.ts"; import { buildSubscriptions, changedPaneId } from "./events.ts"; import { HERDR_UNSENDABLE_KEYS, toHerdrKey } from "./keys.ts"; @@ -89,6 +95,9 @@ const HERDR_CAPABILITIES = declareCapabilities({ "renameTab", "closeTab", "createSpace", + "listWorktrees", + "createWorktree", + "openWorktree", "pushTopologyEvents", "pushPaneEvents", ], @@ -139,6 +148,57 @@ function transportRefusal(err: T): MuxRefusalOutcome { return GONE_CODES.some((code) => detail.includes(code)) ? muxGone(detail) : muxUnreachable(detail); } +/** + * The Herdr worktree codes that are the OPERATOR'S problem, not the transport's. + * + * Matched on the message for the same reason {@link GONE_CODES} is: `client.ts` folds Herdr's + * `{code, message}` into one Error whose text opens with the code. Every code below was seen + * first-hand on herdr 0.8.2 (2026-08-28) except the three marked, which come from herdr's own + * source (`src/app/api/worktrees.rs`) — they are classified, never declared, so an unprobed one + * still lands as a refusal the operator can read rather than a retry that cannot help. + */ +const WORKTREE_REFUSAL_CODES: readonly string[] = [ + "dirty_worktree_requires_force", + "not_git_worktree", + "worktree_create_failed", + "worktree_open_failed", + "worktree_list_failed", + "worktree_remove_failed", + "ambiguous_worktree_branch", // source-only + "not_linked_worktree", // source-only + "worktree_operation_in_progress", // source-only + "stale_worktree_operation", // source-only +]; + +/** A checkout that is not there any more is `gone`, exactly like a pane that is not. */ +const WORKTREE_GONE_CODES: readonly string[] = ["worktree_not_found"]; + +/** + * Which refusal a worktree exception is. + * + * Three buckets, and the split matters: `gone` says re-read, `refused` says read the sentence, + * `unreachable` says try again. Folding the middle one into `unreachable` would invite a retry of + * something a retry cannot fix — a dirty checkout stays dirty. + */ +function worktreeRefusal(err: T): MuxRefusalOutcome { + const detail = reason(err); + if (WORKTREE_GONE_CODES.some((code) => detail.includes(code))) return muxGone(detail); + if (GONE_CODES.some((code) => detail.includes(code))) return muxGone(detail); + if (WORKTREE_REFUSAL_CODES.some((code) => detail.includes(code))) return muxRefused(detail); + return muxUnreachable(detail); +} + +/** One Herdr worktree record in the port's words. */ +function toMuxWorktree(raw: WireWorktree): MuxWorktree { + return { + path: raw.path, + branch: raw.branch ?? null, + openSpaceId: raw.open_workspace_id ?? null, + linked: raw.is_linked_worktree, + prunable: raw.is_prunable, + }; +} + export class HerdrMux implements MuxAdapter { readonly mux = HERDR_MUX; readonly capabilities = HERDR_CAPABILITIES; @@ -319,6 +379,40 @@ export class HerdrMux implements MuxAdapter { } } + async listWorktrees(scope: MuxWorktreeScope): Promise> { + try { + const raw = await this.client.listWorktrees(scope.repoRoot); + return muxOk(raw.map(toMuxWorktree)); + } catch (err) { + return worktreeRefusal(err); + } + } + + async createWorktree(request: MuxWorktreeCreateRequest): Promise> { + try { + const created = await this.client.createWorktree({ + cwd: request.repoRoot, + branch: request.branch, + }); + return muxOk(toCreatedPane(created)); + } catch (err) { + return worktreeRefusal(err); + } + } + + async openWorktree(request: MuxWorktreeOpenRequest): Promise> { + try { + const opened = await this.client.openWorktree({ + cwd: request.repoRoot, + path: request.path, + }); + return muxOk({ pane: toCreatedPane(opened.shell), alreadyOpen: opened.alreadyOpen }); + } catch (err) { + return worktreeRefusal(err); + } + } + + /** * The contract's watch over Herdr's one `events.subscribe` stream. * @@ -433,7 +527,7 @@ function toMuxPane( /** One Herdr workspace as a {@link MuxSpace}. `agent_status` is carried on the wire and unused. */ function toMuxSpace(raw: WireWorkspace): MuxSpace { - return { + const space: MutableMuxSpace = { spaceId: raw.workspace_id, number: raw.number, label: raw.label, @@ -442,8 +536,20 @@ function toMuxSpace(raw: WireWorkspace): MuxSpace { tabCount: raw.tab_count, paneCount: raw.pane_count, }; + // Herdr carries the repo on the workspace itself (`worktree.repo_root`), so no extra call and no + // filesystem walk — see MuxSpace.repoRoot. OMITTED, never set to undefined, when there is none. + if (raw.worktree?.repo_root !== undefined) { + space.repoRoot = raw.worktree.repo_root; + // Herdr's own word for it. `is_linked_worktree` is false for the repo's checkout and true for + // every worktree of it — probed 2026-08-28, and the pair is what lets a list nest one under the + // other without a second call. + space.isWorktree = raw.worktree.is_linked_worktree === true; + } + return space; } +type MutableMuxSpace = { -readonly [K in keyof MuxSpace]: MuxSpace[K] }; + /** One Herdr tab as a {@link MuxTab}. */ function toMuxTab(raw: WireTab): MuxTab { return { diff --git a/bridge/mux/herdr/client.ts b/bridge/mux/herdr/client.ts index de05a684..8196661b 100644 --- a/bridge/mux/herdr/client.ts +++ b/bridge/mux/herdr/client.ts @@ -15,6 +15,15 @@ import { decodeReplyLine, decodeStreamLine, type EventData } from "../../wire.ts // ───────────────────────────────────────────────────────────────────────────── /** Raw wire shape of a workspace from `workspace.list`. */ +/** The repo a workspace sits in, as Herdr reports it on the workspace record itself. */ +export interface WireWorkspaceWorktree { + repo_root: string; + repo_name?: string; + repo_key?: string; + checkout_path?: string; + is_linked_worktree?: boolean; +} + export interface WireWorkspace { workspace_id: string; number: number; @@ -24,6 +33,25 @@ export interface WireWorkspace { tab_count: number; active_tab_id: string; agent_status: AgentStatus; + /** Present when the workspace sits in a Git work tree — probed 2026-08-28 on herdr 0.8.2. */ + worktree?: WireWorkspaceWorktree | null; +} + +/** + * Raw wire shape of a worktree from `worktree.list`. + * + * `open_workspace_id` is absent (not null) when nothing shows the checkout — probed 2026-08-28 + * against herdr 0.8.2, where closing the workspace dropped the key entirely. + */ +export interface WireWorktree { + path: string; + branch?: string | null; + label?: string; + is_linked_worktree: boolean; + is_prunable: boolean; + is_bare: boolean; + is_detached: boolean; + open_workspace_id?: string | null; } /** Raw wire shape of a tab from `tab.list`. */ @@ -198,6 +226,9 @@ export type HerdrRpc = Pick< | "renameTab" | "closeTab" | "createWorkspace" + | "listWorktrees" + | "createWorktree" + | "openWorktree" | "subscribeEvents" >; @@ -501,6 +532,73 @@ export class HerdrClient { }; } + /** + * The worktrees of the repo at `cwd` — including the repo's own checkout, which comes back with + * `is_linked_worktree: false`. + * + * Takes a PATH, not a workspace: probed 2026-08-28 against herdr 0.8.2, `worktree.list --cwd` + * answers for a repo nothing has open, which is what lets the sheet list a worktree before there + * is any space to name it by. + */ + async listWorktrees(cwd: string): Promise { + const r = await this.request<{ worktrees: WireWorktree[] }>("worktree.list", { cwd }); + return r.worktrees; + } + + /** + * Create a worktree on a new branch and open it as its own workspace. `focus:false`, so the + * operator's own screen stays where they left it (the phone navigates itself). + * + * NOT ATOMIC, and the caller must know: probed 2026-08-28 against herdr 0.8.2 in a session with no + * window server, the checkout was created and the OPEN failed with `worktree_open_failed` — the + * branch exists, nothing shows it. A retry then fails as `worktree_create_failed` (the path is + * taken), so the recovery is to open it, never to create it again. + */ + async createWorktree(opts: { cwd: string; branch: string }): Promise { + const r = await this.request<{ workspace: WireWorkspace; root_pane: WirePane }>( + "worktree.create", + { cwd: opts.cwd, branch: opts.branch, focus: false }, + ); + const p = r.root_pane; + return { + paneId: p.pane_id, + workspaceId: p.workspace_id, + workspaceLabel: r.workspace.label, + tabId: p.tab_id, + cwd: p.cwd, + }; + } + + /** + * Show a worktree that already exists on disk, by its checkout path. + * + * `cwd` (the repo) is required alongside it: probed 2026-08-28, `worktree.open` with `path` alone + * answers `not_git_worktree`. A worktree already open answers `already_open: true` with the space + * showing it — an answer, not a refusal. + */ + async openWorktree(opts: { + cwd: string; + path: string; + }): Promise<{ shell: CreatedShell; alreadyOpen: boolean }> { + const r = await this.request<{ + workspace: WireWorkspace; + root_pane: WirePane; + already_open?: boolean; + }>("worktree.open", { cwd: opts.cwd, path: opts.path, focus: false }); + const p = r.root_pane; + return { + shell: { + paneId: p.pane_id, + workspaceId: p.workspace_id, + workspaceLabel: r.workspace.label, + tabId: p.tab_id, + cwd: p.cwd, + }, + alreadyOpen: r.already_open === true, + }; + } + + async readPane( paneId: string, source: ReadSource, diff --git a/bridge/mux/herdr/fixture.ts b/bridge/mux/herdr/fixture.ts index ea42da26..9b96184b 100644 --- a/bridge/mux/herdr/fixture.ts +++ b/bridge/mux/herdr/fixture.ts @@ -36,6 +36,7 @@ import type { WireSnapshot, WireTab, WireWorkspace, + WireWorktree, } from "./client.ts"; const IDLE: AgentStatus = "idle"; @@ -77,6 +78,8 @@ export class FakeHerdr implements HerdrRpc { private readonly screens = new Map(); private readonly subscribers = new Set(); private readonly recorded: MuxWrite[] = []; + /** Worktrees per repo root — the bookkeeping herdr keeps and a plain `git worktree add` does not. */ + private readonly worktreesByRepo = new Map(); /** Never decreases, and never reused — so a fresh pane can never land on a dead pane's id. */ private minted = 0; /** False while the adapter's connection is down: every RPC rejects, as a closed socket does. */ @@ -298,6 +301,83 @@ export class FakeHerdr implements HerdrRpc { }; } + // ── Worktrees ────────────────────────────────────────────────────────────── + // + // Modelled, not stubbed, because conformance EXERCISES every declared capability: the fake keeps a + // per-repo list, so create → list → open → remove tells the same story the real socket told when + // it was probed (2026-08-28, herdr 0.8.2). Two behaviours are copied deliberately because the + // adapter's contract leans on them: opening what is already open answers `already_open` instead of + // refusing, and removal is addressed by WORKSPACE, so a checkout nothing shows cannot be removed. + + async listWorktrees(cwd: string): Promise { + this.assertConnected("worktree.list"); + const repo = this.worktreesByRepo.get(cwd) ?? []; + return [ + { + path: cwd, + branch: "main", + is_linked_worktree: false, + is_prunable: false, + is_bare: false, + is_detached: false, + }, + ...repo, + ]; + } + + async createWorktree(opts: { cwd: string; branch: string }): Promise { + this.assertConnected("worktree.create"); + const repo = this.worktreesByRepo.get(opts.cwd) ?? []; + const path = `${opts.cwd}/.worktrees/${opts.branch.replace(/\//g, "-")}`; + if (repo.some((w) => w.path === path)) { + throw new Error(`herdr worktree.create: worktree_create_failed: '${path}' already exists`); + } + const shell = await this.createWorkspace({ cwd: path, label: opts.branch }); + repo.push({ + path, + branch: opts.branch, + is_linked_worktree: true, + is_prunable: false, + is_bare: false, + is_detached: false, + open_workspace_id: shell.workspaceId, + }); + this.worktreesByRepo.set(opts.cwd, repo); + return shell; + } + + async openWorktree(opts: { + cwd: string; + path: string; + }): Promise<{ shell: CreatedShell; alreadyOpen: boolean }> { + this.assertConnected("worktree.open"); + const repo = this.worktreesByRepo.get(opts.cwd) ?? []; + const found = repo.find((w) => w.path === opts.path); + if (found === undefined) { + throw new Error(`herdr worktree.open: worktree_not_found: ${opts.path}`); + } + if (found.open_workspace_id != null) { + const pane = this.panes.find((candidate) => candidate.workspace_id === found.open_workspace_id); + const workspace = this.workspaces.find((w) => w.workspace_id === found.open_workspace_id); + if (pane !== undefined && workspace !== undefined) { + return { + shell: { + paneId: pane.pane_id, + workspaceId: workspace.workspace_id, + workspaceLabel: workspace.label, + tabId: pane.tab_id, + cwd: pane.cwd, + }, + alreadyOpen: true, + }; + } + } + const shell = await this.createWorkspace({ cwd: found.path, label: found.branch ?? "worktree" }); + found.open_workspace_id = shell.workspaceId; + return { shell, alreadyOpen: false }; + } + + subscribeEvents(opts: SubscribeOptions): EventStream { const subscriber: Subscriber = { opts, down: false }; this.subscribers.add(subscriber); diff --git a/bridge/mux/registry.test.ts b/bridge/mux/registry.test.ts index 7fae3ac7..71b6f7a8 100644 --- a/bridge/mux/registry.test.ts +++ b/bridge/mux/registry.test.ts @@ -30,6 +30,9 @@ function stubAdapter(mux: string, target: MuxTarget): MuxAdapter { renamePane: () => Promise.resolve(muxUnsupported("renamePane", "stub")), closePane: () => Promise.resolve(muxUnsupported("closePane", "stub")), setFocus: () => Promise.resolve(muxUnsupported("setFocus", "stub")), + listWorktrees: () => Promise.resolve(muxUnsupported("listWorktrees", "stub")), + createWorktree: () => Promise.resolve(muxUnsupported("createWorktree", "stub")), + openWorktree: () => Promise.resolve(muxUnsupported("openWorktree", "stub")), createTab: () => Promise.resolve(muxUnsupported("createTab", "stub")), renameTab: () => Promise.resolve(muxUnsupported("renameTab", "stub")), closeTab: () => Promise.resolve(muxUnsupported("closeTab", "stub")), diff --git a/bridge/mux/tmux/adapter.ts b/bridge/mux/tmux/adapter.ts index a0f13018..a06ad611 100644 --- a/bridge/mux/tmux/adapter.ts +++ b/bridge/mux/tmux/adapter.ts @@ -55,6 +55,7 @@ import { muxGone, muxOk, muxRefused, + muxUnsupported, muxUnreachable, type MuxAck, type MuxAdapter, @@ -70,6 +71,11 @@ import { type MuxSubscription, type MuxTab, type MuxTabRequest, + type MuxWorktree, + type MuxWorktreeCreateRequest, + type MuxWorktreeOpenRequest, + type MuxWorktreeOpened, + type MuxWorktreeScope, type MuxWatchOptions, } from "../types.ts"; import { @@ -482,6 +488,28 @@ export class TmuxMux implements MuxAdapter { return this.created(args); } + // ── Worktrees: declined, and why ─────────────────────────────────────────── + // + // tmux knows nothing about Git. `git worktree add` would run fine on this host — but these verbs + // do not promise a checkout, they promise a checkout WITH the session showing it, and removal is + // addressed by space precisely because the multiplexer must close what it opened. tmux keeps no + // such record, so a `createWorktree` here could not answer `openSpaceId`, and `removeWorktree` + // would have nothing to name. Declining is the honest answer until an adapter keeps that mapping + // itself and a probe proves it (MUX_CONTRIBUTING.md, "probe first, declare second"). + + listWorktrees(_scope: MuxWorktreeScope): Promise> { + return Promise.resolve(muxUnsupported("listWorktrees", "tmux keeps no record tying a Git checkout to the session showing it, so a worktree opened here could not be found, listed or removed again")); + } + + createWorktree(_request: MuxWorktreeCreateRequest): Promise> { + return Promise.resolve(muxUnsupported("createWorktree", "tmux keeps no record tying a Git checkout to the session showing it, so a worktree opened here could not be found, listed or removed again")); + } + + openWorktree(_request: MuxWorktreeOpenRequest): Promise> { + return Promise.resolve(muxUnsupported("openWorktree", "tmux keeps no record tying a Git checkout to the session showing it, so a worktree opened here could not be found, listed or removed again")); + } + + /** The contract's watch over control mode plus a bounded listing. All of it lives in watch.ts. */ watch(options: MuxWatchOptions): MuxSubscription { const subscription = new TmuxWatch(this.exec, options); diff --git a/bridge/mux/types.ts b/bridge/mux/types.ts index cea7286f..db06c23f 100644 --- a/bridge/mux/types.ts +++ b/bridge/mux/types.ts @@ -234,6 +234,23 @@ export interface MuxSpace { readonly activeTabId: string; readonly tabCount: number; readonly paneCount: number; + /** + * The root of the Git repo this space sits in, when the multiplexer knows one. + * + * A FACT, declared like {@link MuxCapabilityDeclaration.spaces} and for the same reason: it + * answers "which repo", never "can you". An adapter that keeps no repo mapping omits it, and + * omission is the honest fail-closed direction — no repo, no worktree rows, and nothing had to + * guess. It is here rather than derived from a pane's cwd because deriving it would mean Collie + * walking the filesystem for `.git`, which is exactly the Git work ADR 0032 keeps out of the port. + */ + readonly repoRoot?: string; + /** + * Whether this space is a LINKED worktree of {@link repoRoot} rather than the repo's own checkout. + * + * Absent wherever `repoRoot` is: the pair travels together, and asking one without the other is + * always a bug. `false` means "this is the repo itself", which is what a worktree row nests under. + */ + readonly isWorktree?: boolean; } /** One tab within a space — a layout holding one or more panes. */ @@ -323,6 +340,53 @@ export interface MuxSpaceRequest { readonly label?: string; } +// ── Worktrees ───────────────────────────────────────────────────────────────── +// +// A worktree is Git's, not the multiplexer's — so why is it here? Because the ACT is the +// multiplexer's: every verb below ends in a space appearing, moving or going away, which is the one +// thing a mux adapter owns. What a multiplexer may not have is the BOOKKEEPING that ties a checkout +// to the space showing it; that is what these capabilities declare. See ADR 0032. + +/** A Git worktree of the repo a space sits in. */ +export interface MuxWorktree { + /** Absolute checkout path. The identity: a branch may be absent, and labels repeat. */ + readonly path: string; + /** The branch checked out there, or `null` for a detached head. */ + readonly branch: string | null; + /** The space showing it, or `null` when it exists on disk and nothing shows it. */ + readonly openSpaceId: string | null; + /** `false` for the repo's own checkout — listed for context, never removable. */ + readonly linked: boolean; + /** The checkout is gone and the administrative files could be pruned. */ + readonly prunable: boolean; +} + +/** Where a worktree question is asked from — the repo the asking space sits in. */ +export interface MuxWorktreeScope { + readonly repoRoot: string; +} + +/** What a new worktree asks for. */ +export interface MuxWorktreeCreateRequest extends MuxWorktreeScope { + readonly branch: string; +} + +/** Which existing worktree to show. */ +export interface MuxWorktreeOpenRequest extends MuxWorktreeScope { + readonly path: string; +} + +/** + * Opening one either made a space or found the space already showing it. + * + * `alreadyOpen` is not an error and must not be rendered as one: asking for a worktree that is + * already up is the operator saying "take me there", and the pane below is where to go. + */ +export interface MuxWorktreeOpened { + readonly pane: MuxCreatedPane; + readonly alreadyOpen: boolean; +} + // ── Learning that something changed ─────────────────────────────────────────── /** @@ -489,6 +553,15 @@ export interface MuxAdapter { /** New space, opening a fresh shell. Needs `createSpace`. */ createSpace(request: MuxSpaceRequest): Promise>; + /** The worktrees of the repo a space sits in. Needs `listWorktrees`. */ + listWorktrees(scope: MuxWorktreeScope): Promise>; + + /** New worktree on a new branch, opened as a space. Needs `createWorktree`. */ + createWorktree(request: MuxWorktreeCreateRequest): Promise>; + + /** Show an existing worktree as a space. Needs `openWorktree`. */ + openWorktree(request: MuxWorktreeOpenRequest): Promise>; + /** Watch for change. Always available — an adapter with no push satisfies it by polling. */ watch(options: MuxWatchOptions): MuxSubscription; } diff --git a/bridge/mux/zellij/adapter.ts b/bridge/mux/zellij/adapter.ts index d963f1e1..0c5c173a 100644 --- a/bridge/mux/zellij/adapter.ts +++ b/bridge/mux/zellij/adapter.ts @@ -83,6 +83,11 @@ import { type MuxSubscription, type MuxTab, type MuxTabRequest, + type MuxWorktree, + type MuxWorktreeCreateRequest, + type MuxWorktreeOpenRequest, + type MuxWorktreeOpened, + type MuxWorktreeScope, type MuxWatchOptions, } from "../types.ts"; import { resolveZellijBinary, SpawnZellijExec } from "./exec.ts"; @@ -482,6 +487,25 @@ export class ZellijMux implements MuxAdapter { ); } + // ── Worktrees: declined, and why ─────────────────────────────────────────── + // + // Two reasons, and the first alone settles it: a Collie on zellij drives exactly ONE session, so + // there is no second space for a worktree to be opened as — the same fact that declines + // `createSpace`. The second is tmux's: zellij keeps no record tying a checkout to what shows it. + + listWorktrees(_scope: MuxWorktreeScope): Promise> { + return Promise.resolve(muxUnsupported("listWorktrees", "a Collie on zellij drives exactly one session, so a worktree has no second space to be opened as")); + } + + createWorktree(_request: MuxWorktreeCreateRequest): Promise> { + return Promise.resolve(muxUnsupported("createWorktree", "a Collie on zellij drives exactly one session, so a worktree has no second space to be opened as")); + } + + openWorktree(_request: MuxWorktreeOpenRequest): Promise> { + return Promise.resolve(muxUnsupported("openWorktree", "a Collie on zellij drives exactly one session, so a worktree has no second space to be opened as")); + } + + /** The contract's watch over the pane stream plus a bounded census. All of it lives in watch.ts. */ watch(options: MuxWatchOptions): MuxSubscription { const subscription = new ZellijWatch(this.session, options); diff --git a/bridge/server.test.ts b/bridge/server.test.ts index 5017a250..bf298e80 100644 --- a/bridge/server.test.ts +++ b/bridge/server.test.ts @@ -1601,9 +1601,10 @@ describe("the host gate — `?host=` selects among enrolled members and nothing // The load-bearing claim: `?h=laptop` + `w1:p1` must never be served the DESK's `w1:p1`, and // pane ids collide across machines, so a fall-through here is a cross-host write. // - // All FIVE session-scoped routes (tab create, workspace create, tab action, the pane family, - // and "look now") reach their runtime through the caller's resolver and nothing else. - expect([...src.matchAll(/await caller\.resolve\(\);/g)]).toHaveLength(5); + // All SEVEN session-scoped routes (tab create, workspace create, tab action, the pane family, + // "look now", the worktree listing and the worktree actions) reach their runtime through the + // caller's resolver and nothing else. + expect([...src.matchAll(/await caller\.resolve\(\);/g)]).toHaveLength(7); // Exactly five `registry.get(` calls remain, and each is a sanctioned one, named here rather // than exempted: assembling THIS collie's own snapshot body; `localRuntime`, the single // "(session) → runtime, or 404" helper both callers share; `/api/config`, which reports THIS diff --git a/bridge/server.ts b/bridge/server.ts index b9c37480..902ae308 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -52,6 +52,8 @@ import type { AgentView, BridgeConfig, CreateResponse, + WorktreeListResponse, + WorktreeOpenResponse, DeviceAuth, OperatorCommand, MuxConfig, @@ -163,6 +165,15 @@ const MAX_HISTORY_LIMIT = 5000; // (create) is an exact match on `/api/tab`, so it never collides with this `/api/tab//`. const TAB_ACTION_ROUTE = /^\/api\/tab\/([^/]+)\/(rename|close)$/; +/** + * Worktree routes, all hung off the SPACE that asked (ADR 0032). + * + * The space is the repo context — its `repoRoot` comes off the snapshot Herdr already sends — so no + * route takes a path to a repo, only the checkout path inside one. + */ +const WORKTREE_LIST_ROUTE = /^\/api\/workspace\/([^/]+)\/worktrees$/; +const WORKTREE_ACTION_ROUTE = /^\/api\/workspace\/([^/]+)\/worktree(?:\/(open))?$/; + /** * Header the web app sets on its own pane reads, and the ONLY thing that lets a read mark a pane * seen. See {@link marksPaneSeen} for why a header, of all things, is the check. @@ -625,6 +636,28 @@ export function startServer(opts: { return createWorkspace(rt.herdr, rt.engine, req, caller.audit, caller.device(), rt.name); } + // ── Worktrees: list / create / open / remove, all scoped to a space (ADR 0032) ── + const worktreeListMatch = pathname.match(WORKTREE_LIST_ROUTE); + if (worktreeListMatch && req.method === "GET") { + const rt = await caller.resolve(); + if (rt instanceof Response) return rt; + return listWorktrees(rt.herdr, rt.engine, decodeURIComponent(worktreeListMatch[1]!), req); + } + const worktreeMatch = pathname.match(WORKTREE_ACTION_ROUTE); + if (worktreeMatch && req.method === "POST") { + const denied = caller.gate("write"); + if (denied) return denied; + const rt = await caller.resolve(); + if (rt instanceof Response) return rt; + const spaceId = decodeURIComponent(worktreeMatch[1]!); + const action = worktreeMatch[2]; + const device = caller.device(); + if (action === "open") { + return openWorktree(rt.herdr, rt.engine, spaceId, req, caller.audit, device, rt.name); + } + return createWorktree(rt.herdr, rt.engine, spaceId, req, caller.audit, device, rt.name); + } + // ── Tab actions: rename (set its label) / close (kill it + every pane in it) ── const tabMatch = pathname.match(TAB_ACTION_ROUTE); if (tabMatch && req.method === "POST") { @@ -1995,6 +2028,217 @@ async function createWorkspace( } satisfies CreateResponse, ae); } +// ── Worktrees ──────────────────────────────────────────────────────────────── +// +// Every route is scoped to a SPACE, and the space is how the repo is known: `repoRoot` rides on the +// snapshot Herdr already sends, so nothing here walks a filesystem looking for `.git` (ADR 0032). + +/** The repo a space sits in, or a 400 saying it sits in none. */ +function repoRootOf(engine: StateEngine, spaceId: string): string | null { + const space = engine.current().workspaces.find((w) => w.workspaceId === spaceId); + return space?.repoRoot ?? null; +} + +/** + * Which catalogued code a worktree refusal is. + * + * Only two refusals change what the phone DOES — a busy multiplexer means try again, an ambiguous + * branch means type a better one. Everything else is shown, so it shares one code per verb and + * carries the multiplexer's own sentence in `{reason}` (bridge/error-codes.ts: "a template that is + * only {reason} is not a mistake"). + */ +function worktreeCode(detail: string, fallback: ErrorCode): ErrorCode { + if (detail.includes("worktree_operation_in_progress")) return "worktree.busy"; + if (detail.includes("ambiguous_worktree_branch")) return "worktree.ambiguous_branch"; + if (detail.includes("not_git_worktree")) return "worktree.not_a_repo"; + return fallback; +} + +async function listWorktrees( + herdr: MuxAdapter, + engine: StateEngine, + spaceId: string, + req: Request, +): Promise { + const ae = req.headers.get("accept-encoding"); + const repoRoot = repoRootOf(engine, spaceId); + if (repoRoot === null) { + return json( + { + ok: false, + ...apiError("worktree.not_a_repo", { reason: "this space is not in a Git work tree" }), + } satisfies WorktreeListResponse, + ae, + ); + } + const outcome = await herdr.listWorktrees({ repoRoot }); + if (!outcome.ok) { + return json( + { + ok: false, + ...apiError(worktreeCode(outcome.detail, "worktree.list_failed"), { reason: outcome.detail }), + } satisfies WorktreeListResponse, + ae, + ); + } + return json( + { + ok: true, + worktrees: outcome.value.map((w) => ({ + path: w.path, + branch: w.branch, + openWorkspaceId: w.openSpaceId, + linked: w.linked, + prunable: w.prunable, + })), + } satisfies WorktreeListResponse, + ae, + ); +} + +async function createWorktree( + herdr: MuxAdapter, + engine: StateEngine, + spaceId: string, + req: Request, + audit: AuditLog, + device: string | null, + session: string, +): Promise { + const ae = req.headers.get("accept-encoding"); + let body: JsonValue; + try { + // SAFETY: `Request.json()` output IS a JsonValue by construction; every field is checked below. + body = (await req.json()) as JsonValue; + } catch { + return text("bad body", 400); + } + const fields = asJsonRecord(body) ?? {}; + const branch = typeof fields.branch === "string" ? fields.branch.trim() : ""; + if (branch === "") { + return json( + { ok: false, ...apiError("worktree.branch_required", {}) } satisfies WorktreeOpenResponse, + ae, + ); + } + const repoRoot = repoRootOf(engine, spaceId); + if (repoRoot === null) { + return json( + { + ok: false, + ...apiError("worktree.not_a_repo", { reason: "this space is not in a Git work tree" }), + } satisfies WorktreeOpenResponse, + ae, + ); + } + const outcome = await herdr.createWorktree({ repoRoot, branch }); + if (!outcome.ok) { + // The half-done case gets its OWN code, because the recovery is the opposite one: the branch is + // on disk and only the opening failed, so the phone must offer "open it", never "create it + // again" (a second create refuses — the path is taken). Probed on herdr 0.8.2, 2026-08-28. + const halfDone = outcome.detail.includes("worktree_open_failed"); + return json( + { + ok: false, + ...apiError( + halfDone ? "worktree.created_not_opened" : worktreeCode(outcome.detail, "worktree.create_failed"), + { reason: outcome.detail }, + ), + } satisfies WorktreeOpenResponse, + ae, + ); + } + const created = outcome.value; + audit.record({ + action: "worktree.create", + paneId: created.paneId, + session, + device, + detail: { branch, repoRoot }, + }); + await settleTopology(herdr, engine); + return json( + { + ok: true, + alreadyOpen: false, + pane: { + paneId: created.paneId, + workspaceId: created.spaceId, + workspaceLabel: created.spaceLabel, + tabId: created.tabId, + cwd: created.cwd, + }, + } satisfies WorktreeOpenResponse, + ae, + ); +} + +async function openWorktree( + herdr: MuxAdapter, + engine: StateEngine, + spaceId: string, + req: Request, + audit: AuditLog, + device: string | null, + session: string, +): Promise { + const ae = req.headers.get("accept-encoding"); + let body: JsonValue; + try { + // SAFETY: as createWorktree — checked below, never trusted as declared. + body = (await req.json()) as JsonValue; + } catch { + return text("bad body", 400); + } + const fields = asJsonRecord(body) ?? {}; + const path = typeof fields.path === "string" ? fields.path.trim() : ""; + if (path === "") return text("bad body", 400); + const repoRoot = repoRootOf(engine, spaceId); + if (repoRoot === null) { + return json( + { + ok: false, + ...apiError("worktree.not_a_repo", { reason: "this space is not in a Git work tree" }), + } satisfies WorktreeOpenResponse, + ae, + ); + } + const outcome = await herdr.openWorktree({ repoRoot, path }); + if (!outcome.ok) { + return json( + { + ok: false, + ...apiError(worktreeCode(outcome.detail, "worktree.open_failed"), { reason: outcome.detail }), + } satisfies WorktreeOpenResponse, + ae, + ); + } + const { pane, alreadyOpen } = outcome.value; + audit.record({ + action: "worktree.open", + paneId: pane.paneId, + session, + device, + detail: { path, alreadyOpen: String(alreadyOpen) }, + }); + await settleTopology(herdr, engine); + return json( + { + ok: true, + alreadyOpen, + pane: { + paneId: pane.paneId, + workspaceId: pane.spaceId, + workspaceLabel: pane.spaceLabel, + tabId: pane.tabId, + cwd: pane.cwd, + }, + } satisfies WorktreeOpenResponse, + ae, + ); +} + + // Save an uploaded image to a host file and return its absolute path. The client then references // that path in a message; Claude Code / Codex read images by path (the terminal can't take a // pasted image over the socket). Validated by MIME and size; the filename is server-generated. diff --git a/bridge/solo-baseline.test.ts b/bridge/solo-baseline.test.ts index 1055b425..02f16532 100644 --- a/bridge/solo-baseline.test.ts +++ b/bridge/solo-baseline.test.ts @@ -319,6 +319,8 @@ const WORKSPACE_KEYS = { activeTabId: true, tabCount: true, paneCount: true, + repoRoot: true, + isWorktree: true, } satisfies Record; const TAB_KEYS = { @@ -400,9 +402,11 @@ describe("solo zero-tax — wire shapes carry no pack dimension", () => { expect(Object.keys(WORKSPACE_KEYS).toSorted()).toEqual([ "activeTabId", "focused", + "isWorktree", "label", "number", "paneCount", + "repoRoot", "tabCount", "workspaceId", ]); @@ -507,6 +511,8 @@ describe("solo zero-tax — routes", () => { // the reason every other one is: a route arrives on purpose or it does not arrive. "/^\\/api\\/pane\\/([^/]+)(?:\\/(reply|keys|upload|close|rename|history|focus))?$/", "/^\\/api\\/tab\\/([^/]+)\\/(rename|close)$/", + "/^\\/api\\/workspace\\/([^/]+)\\/worktree(?:\\/(open))?$/", + "/^\\/api\\/workspace\\/([^/]+)\\/worktrees$/", "/api/config", // Device pairing (bridge/pairing.ts) — a SOLO feature that legitimately extends this list. // It is named here, not exempted: the guard's job is that a route arrives on purpose. diff --git a/bridge/state-engine.ts b/bridge/state-engine.ts index 0ae7824e..bca832a2 100644 --- a/bridge/state-engine.ts +++ b/bridge/state-engine.ts @@ -360,15 +360,25 @@ export class StateEngine { .toSorted((a, b) => a.workspaceNumber - b.workspaceNumber || a.paneId.localeCompare(b.paneId)); const workspaceViews: WorkspaceView[] = spaces - .map((s) => ({ - workspaceId: s.spaceId, - number: s.number, - label: s.label, - focused: s.focused, - activeTabId: s.activeTabId, - tabCount: s.tabCount, - paneCount: s.paneCount, - })) + .map((s) => { + const view: WorkspaceView = { + workspaceId: s.spaceId, + number: s.number, + label: s.label, + focused: s.focused, + activeTabId: s.activeTabId, + tabCount: s.tabCount, + paneCount: s.paneCount, + }; + // Assigned only when there is one, so a space outside a repo carries no key at all: adding + // `repoRoot` to every space would move every snapshot ETag once for nothing (the argument + // bridge/types.ts makes about `pack`, applied here). + if (s.repoRoot !== undefined) { + view.repoRoot = s.repoRoot; + view.isWorktree = s.isWorktree === true; + } + return view; + }) .toSorted((a, b) => a.number - b.number); const tabViews: TabView[] = tabs.map((t) => ({ diff --git a/bridge/types.ts b/bridge/types.ts index debddc74..545cb2ba 100644 --- a/bridge/types.ts +++ b/bridge/types.ts @@ -150,6 +150,20 @@ export interface WorkspaceView { activeTabId: string; tabCount: number; paneCount: number; + /** + * The Git repo this space sits in, when the multiplexer reports one (MuxSpace.repoRoot). + * + * Absent means "no repo here, or this multiplexer does not keep the mapping" — the phone reads + * absence as "no worktree rows", which is the fail-closed direction and needs no extra call. + */ + repoRoot?: string; + /** + * Whether this space is a linked worktree of `repoRoot` rather than the repo's own checkout. + * + * Travels with `repoRoot` and is absent wherever that is. It is what lets the spaces list nest a + * worktree under the space showing the repo itself. + */ + isWorktree?: boolean; } /** A tab within a workspace (a layout/view holding one or more panes). From `tab.list`. */ @@ -442,6 +456,41 @@ export type CreateResponse = | { ok: true; pane: CreatedPane } | { ok: false; error: string; code?: ErrorCode; detail?: ApiErrorDetail }; +/** One Git worktree of the repo a space sits in (ADR 0032). */ +export interface WorktreeView { + /** Absolute checkout path — the identity, and what `open` is asked with. */ + path: string; + /** The branch checked out there; `null` for a detached head. */ + branch: string | null; + /** + * The space showing it, or `null` when it exists on disk and nothing does. + * + * `null` is what hides the Remove row: removal is addressed by space, so a checkout nothing shows + * cannot be removed from the phone at all. + */ + openWorkspaceId: string | null; + /** `false` for the repo's own checkout, which is listed for context and is never removable. */ + linked: boolean; + /** The multiplexer believes the checkout is gone and its administrative files could be pruned. */ + prunable: boolean; +} + +/** GET /api/workspace/:id/worktrees — the worktrees of the repo that space sits in. */ +export type WorktreeListResponse = + | { ok: true; worktrees: WorktreeView[] } + | { ok: false; error: string; code?: ErrorCode; detail?: ApiErrorDetail }; + +/** + * POST /api/workspace/:id/worktree[/open] — the space now showing the checkout. + * + * `alreadyOpen` says the space was already there, which is an ANSWER and not a failure: the phone + * navigates to `pane` either way. + */ +export type WorktreeOpenResponse = + | { ok: true; pane: CreatedPane; alreadyOpen: boolean } + | { ok: false; error: string; code?: ErrorCode; detail?: ApiErrorDetail }; + + /** * Which role this collie plays in a pack (PACK_PROTOCOL.md §3). `solo` is a lead with zero peers — * today's Collie, exactly — and is the only mode that needs no configuration whatsoever. diff --git a/web/src/components/new-space-sheet.tsx b/web/src/components/new-space-sheet.tsx index 780ed2e3..14b371c7 100644 --- a/web/src/components/new-space-sheet.tsx +++ b/web/src/components/new-space-sheet.tsx @@ -1,24 +1,75 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { listWorktrees } from "@/lib/api"; +import type { Scope } from "@/lib/scope"; +import type { WorktreeView } from "@/lib/types"; import { BottomSheet } from "@/components/ui/sheet"; import { useHoldReload } from "@/lib/reload-guard"; import { t } from "@/lib/i18n"; import { useLocale } from "@/hooks/use-locale"; +/** A repo the sheet can branch a worktree from — one entry per repo, however many spaces show it. */ +export interface WorktreeRepo { + /** The space the worktree call is addressed to (every route is scoped to a space). */ + workspaceId: string; + repoRoot: string; + /** What to call it in the picker: the space's own label, which the operator already recognises. */ + label: string; +} + +/** Stable empty default: a fresh `[]` per render would break referential equality downstream. */ +const NO_REPOS: readonly WorktreeRepo[] = []; + interface NewSpaceSheetProps { open: boolean; onClose: () => void; onCreate: (opts: { label?: string; cwd?: string }) => void; + /** + * The repos a worktree could be branched from. EMPTY means the worktree tab is not offered at + * all — either the multiplexer cannot do it, or nothing open sits in a repo. Hiding it beats + * showing a tab whose only content would be "no repos". + */ + repos?: readonly WorktreeRepo[]; + /** Branch a worktree from `workspaceId`. Absent alongside an empty `repos`. */ + onCreateWorktree?: (workspaceId: string, branch: string) => void; + /** Show a worktree that exists on disk but is not open as a space. */ + onOpenWorktree?: (workspaceId: string, path: string) => void; + /** Session scope for the listing read. */ + scope?: Scope; } // Create a new space (workspace). Both fields are optional and dictation-friendly: leave the // directory blank to open the shell in your home dir (it's a shell — cd from there), or set a path // for a specific project. The new space opens a fresh shell you launch your own agent in. -export function NewSpaceSheet({ open, onClose, onCreate }: NewSpaceSheetProps) { +export function NewSpaceSheet({ + open, + onClose, + onCreate, + repos = NO_REPOS, + onCreateWorktree, + onOpenWorktree, + scope, +}: NewSpaceSheetProps) { useLocale(); const [label, setLabel] = useState(""); const [cwd, setCwd] = useState(""); + // Which kind of space this will be. Two tabs rather than two entry points: from the spaces list + // there is no "current space" to carry a repo, so the worktree side has to ask which repo anyway + // — and once it asks, the choice belongs beside the plain one, not behind a second button. + const [mode, setMode] = useState<"space" | "worktree">("space"); + const [branch, setBranch] = useState(""); + const [repo, setRepo] = useState(""); + const worktreesOffered = repos.length > 0 && onCreateWorktree !== undefined; + /** + * Worktrees of the chosen repo that NOTHING is showing. + * + * The ones that are open are already spaces in the list behind this sheet — offering them again + * here would be the same thing under two names. These are the only worktrees the phone has no + * other route to, which is exactly why they are here and not in a panel of their own. + */ + const [unopened, setUnopened] = useState([]); // Don't let a self-update reload yank this tab/space form out from under a half-typed // directory/label — hold while it's open; the self-updater shows the banner and updates on close. @@ -28,17 +79,129 @@ export function NewSpaceSheet({ open, onClose, onCreate }: NewSpaceSheetProps) { if (open) { setLabel(""); setCwd(""); + setBranch(""); + setMode("space"); + // Default to the first repo, which is the most recently used one: the list arrives in the + // spaces list's own order, so the top entry is the repo you were last in. + setRepo(repos[0]?.workspaceId ?? ""); } + // `repos` is derived per render; keying the reset on `open` alone is deliberate — a poll that + // reorders the repos must not wipe a half-typed branch name. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); + useEffect(() => { + if (!open || mode !== "worktree" || repo === "") { + setUnopened([]); + return; + } + let live = true; + void (async () => { + const res = await listWorktrees(repo, scope); + // A read the operator asked for by opening this tab — not a poll, so it runs once per repo + // choice and never on the list behind it. + if (live) setUnopened(res.ok ? res.worktrees.filter((w) => w.linked && w.openWorkspaceId === null) : []); + })(); + return () => { + live = false; + }; + }, [open, mode, repo, scope]); + function create() { onCreate({ label: label.trim() || undefined, cwd: cwd.trim() || undefined }); onClose(); } + function createWorktree() { + const name = branch.trim(); + if (name === "" || repo === "" || onCreateWorktree === undefined) return; + onCreateWorktree(repo, name); + onClose(); + } + return (
+ {/* Only where there is a choice to make: one tab is not a tab strip, it is noise. */} + {worktreesOffered && ( +
+ {(["space", "worktree"] as const).map((option) => ( + + ))} +
+ )} + + {mode === "worktree" && worktreesOffered ? ( + <> + + + + {unopened.length > 0 && onOpenWorktree !== undefined && ( +
+ + {t("worktree.orOpenExisting")} + + {unopened.map((worktree) => ( + + ))} +
+ )} + + ) : ( + <>
); diff --git a/web/src/components/space-overview.tsx b/web/src/components/space-overview.tsx index 88226afa..a16dd58c 100644 --- a/web/src/components/space-overview.tsx +++ b/web/src/components/space-overview.tsx @@ -5,7 +5,13 @@ import { cn } from "@/lib/utils"; import { useMuxCapability } from "@/lib/mux-capability"; import { SectionHeader } from "@/components/section-header"; import { StatusDot } from "@/components/status-badge"; -import { filterSpaces, sortSpacesByRecency, spaceLastSeenMap, spaceTriageMap } from "@/lib/spaces"; +import { + filterSpaces, + nestWorktrees, + sortSpacesByRecency, + spaceLastSeenMap, + spaceTriageMap, +} from "@/lib/spaces"; import { spaceKey } from "@/lib/hosts"; import { TRIAGE_STATUS } from "@/lib/triage"; import { timeAgo } from "@/lib/format"; @@ -64,6 +70,10 @@ export function SpaceOverview({ const worstBySpace = spaceTriageMap(agents); const blockedSpaces = [...worstBySpace.values()].filter((b) => b === "needs").length; const visible = filterSpaces(sortSpacesByRecency(workspaces, panes, lastSeen, host), query); + // Worktrees sit under the space holding their repo — but NOT while filtering: a filter that + // matched only the child would indent a row under a parent that is not on screen, which reads as + // a rendering fault rather than as structure. + const rows = query.trim() ? visible.map((space) => ({ space, depth: 0 as const })) : nestWorktrees(visible); return (
@@ -143,7 +153,7 @@ export function SpaceOverview({ {t("space.overview.empty.noMatch", { query })}

) : ( - visible.map((w) => { + rows.map(({ space: w, depth }) => { // (host, workspaceId): these rows are the lead's spaces, so a peer that happens to // expose the same workspace id contributes nothing to them. const key = spaceKey(host, w.workspaceId); @@ -162,6 +172,9 @@ export function SpaceOverview({ // so it keeps its radius. "w-full text-left transition-colors active:scale-[0.99]", !blocked && "hover:bg-muted/50", + // A worktree of the space above it. Indented rather than labelled: the nesting + // IS the sentence, and a badge would repeat it once per row. + depth === 1 && "pl-5", )} > {/* Flat rows, not cards: these are single-line entries, so a card is 100% chrome @@ -208,3 +221,4 @@ export function SpaceOverview({
); } + diff --git a/web/src/lib/api-error-codes.ts b/web/src/lib/api-error-codes.ts index d191cbe9..0270a46e 100644 --- a/web/src/lib/api-error-codes.ts +++ b/web/src/lib/api-error-codes.ts @@ -54,6 +54,16 @@ export const API_ERROR_CODES = [ "tab.workspace_required", "workspace.create_failed", + // Worktrees — /api/workspace/:id/worktree[s|/open|/remove] (ADR 0032) + "worktree.list_failed", + "worktree.create_failed", + "worktree.created_not_opened", + "worktree.open_failed", + "worktree.busy", + "worktree.ambiguous_branch", + "worktree.branch_required", + "worktree.not_a_repo", + // Image upload — POST /api/pane/:id/upload "upload.too_large", "upload.no_file", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 01923dda..370c9a94 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -22,6 +22,8 @@ import type { SnapshotResponse, UpdateInfo, UploadResponse, + WorktreeListResponse, + WorktreeOpenResponse, } from "./types"; export type { NotifyPrefs, UpdateInfo }; @@ -531,6 +533,38 @@ export function createWorkspace( }); } +/** The worktrees of the repo a space sits in. Empty-handed when the space is not in one. */ +export function listWorktrees(workspaceId: string, scope?: Scope): Promise { + return req( + withScope(`/api/workspace/${encodeURIComponent(workspaceId)}/worktrees`, scope), + ); +} + +/** Create a worktree on a new branch and open it as its own space. */ +export function createWorktree( + workspaceId: string, + branch: string, + scope?: Scope, +): Promise { + return req( + withScope(`/api/workspace/${encodeURIComponent(workspaceId)}/worktree`, scope), + { method: "POST", body: JSON.stringify({ branch }) }, + ); +} + +/** Show a worktree that already exists. Answers `alreadyOpen` rather than refusing. */ +export function openWorktree( + workspaceId: string, + path: string, + scope?: Scope, +): Promise { + return req( + withScope(`/api/workspace/${encodeURIComponent(workspaceId)}/worktree/open`, scope), + { method: "POST", body: JSON.stringify({ path }) }, + ); +} + + /** * The bridge's startup config: push setup, the build id, the operator's own rows, and the * multiplexer's declared capabilities (M10/06 — read them through lib/mux-capability.ts, never by diff --git a/web/src/lib/i18n/messages/de.ts b/web/src/lib/i18n/messages/de.ts index f66f195f..11543466 100644 --- a/web/src/lib/i18n/messages/de.ts +++ b/web/src/lib/i18n/messages/de.ts @@ -672,4 +672,30 @@ export const de: Dictionary = { "apiError.session.unknown": "Auf diesem collie gibt es keine Sitzung namens {session}.", "apiError.host.unknown": "In diesem pack gibt es keinen collie namens {host}.", "apiError.pack.not_lead": "Dieser collie führt kein pack, also gibt es kein pack zu zeigen.", + // --- worktrees (ADR 0032) --- + "apiError.worktree.list_failed": "Die Worktrees konnten nicht aufgelistet werden: {reason}", + "apiError.worktree.create_failed": "Der Worktree konnte nicht erstellt werden: {reason}", + "apiError.worktree.created_not_opened": "Der Branch wurde erstellt, aber nichts konnte darauf geöffnet werden: {reason}", + "apiError.worktree.open_failed": "Der Worktree konnte nicht geöffnet werden: {reason}", + "apiError.worktree.busy": "Ein anderer Worktree-Vorgang läuft noch — gleich noch einmal versuchen.", + "apiError.worktree.ambiguous_branch": "Dieser Branch-Name passt auf mehr als eine Sache: {reason}", + "apiError.worktree.branch_required": "Gib zuerst einen Branch-Namen ein.", + "apiError.worktree.not_a_repo": "Dieser Space liegt in keinem Git-Repository.", + "worktree.section": "Worktrees", + "worktree.new": "Neuer Worktree", + "worktree.branchLabel": "Branch-Name", + "worktree.branchPlaceholder": "feature/meine-aenderung", + "worktree.branchesFrom": "Zweigt ab von {branch}", + "worktree.create": "Erstellen", + "worktree.creating": "Wird erstellt…", + "worktree.open": "Öffnen", + "worktree.opening": "Wird geöffnet…", + "worktree.mainCheckout": "das Repository selbst", + "worktree.empty": "Noch keine Worktrees.", + "worktree.detached": "losgelöst", + "worktree.recoverOpen": "Erstellten Branch öffnen", + "space.new.tab.plain": "Space", + "space.new.tab.worktree": "Worktree", + "space.new.repo.label": "Repository", + "worktree.orOpenExisting": "Oder einen vorhandenen öffnen", }; diff --git a/web/src/lib/i18n/messages/en.ts b/web/src/lib/i18n/messages/en.ts index df6c7cab..ebec62c9 100644 --- a/web/src/lib/i18n/messages/en.ts +++ b/web/src/lib/i18n/messages/en.ts @@ -677,6 +677,32 @@ export const en = { "apiError.session.unknown": "There is no session called {session} on this collie.", "apiError.host.unknown": "There is no collie called {host} in this pack.", "apiError.pack.not_lead": "This collie doesn't lead a pack, so there is no pack to show.", + // --- worktrees (ADR 0032) --- + "apiError.worktree.list_failed": "The worktrees couldn't be listed: {reason}", + "apiError.worktree.create_failed": "The worktree couldn't be created: {reason}", + "apiError.worktree.created_not_opened": "The branch was created, but nothing could be opened on it: {reason}", + "apiError.worktree.open_failed": "The worktree couldn't be opened: {reason}", + "apiError.worktree.busy": "Another worktree operation is still running — try again in a moment.", + "apiError.worktree.ambiguous_branch": "That branch name matches more than one thing: {reason}", + "apiError.worktree.branch_required": "Type a branch name first.", + "apiError.worktree.not_a_repo": "This space isn't in a Git repository.", + "worktree.section": "Worktrees", + "worktree.new": "New worktree", + "worktree.branchLabel": "Branch name", + "worktree.branchPlaceholder": "feature/my-change", + "worktree.branchesFrom": "Branches from {branch}", + "worktree.create": "Create", + "worktree.creating": "Creating…", + "worktree.open": "Open", + "worktree.opening": "Opening…", + "worktree.mainCheckout": "the repo itself", + "worktree.empty": "No worktrees yet.", + "worktree.detached": "detached", + "worktree.recoverOpen": "Open the branch that was created", + "space.new.tab.plain": "Space", + "space.new.tab.worktree": "Worktree", + "space.new.repo.label": "Repository", + "worktree.orOpenExisting": "Or open one that already exists", } as const; /** Every key that exists, as a union of string literals. The completeness contract. */ diff --git a/web/src/lib/i18n/messages/es.ts b/web/src/lib/i18n/messages/es.ts index 5815ab3c..5d67bb47 100644 --- a/web/src/lib/i18n/messages/es.ts +++ b/web/src/lib/i18n/messages/es.ts @@ -669,4 +669,30 @@ export const es: Dictionary = { "apiError.session.unknown": "No hay ninguna sesión llamada {session} en este collie.", "apiError.host.unknown": "No hay ningún collie llamado {host} en este pack.", "apiError.pack.not_lead": "Este collie no lidera ningún pack, así que no hay pack que mostrar.", + // --- worktrees (ADR 0032) --- + "apiError.worktree.list_failed": "No se pudieron listar los worktrees: {reason}", + "apiError.worktree.create_failed": "No se pudo crear el worktree: {reason}", + "apiError.worktree.created_not_opened": "La rama se creó, pero no se pudo abrir nada en ella: {reason}", + "apiError.worktree.open_failed": "No se pudo abrir el worktree: {reason}", + "apiError.worktree.busy": "Otra operación de worktree sigue en curso: inténtalo en un momento.", + "apiError.worktree.ambiguous_branch": "Ese nombre de rama coincide con más de una cosa: {reason}", + "apiError.worktree.branch_required": "Escribe primero un nombre de rama.", + "apiError.worktree.not_a_repo": "Este espacio no está en un repositorio Git.", + "worktree.section": "Worktrees", + "worktree.new": "Nuevo worktree", + "worktree.branchLabel": "Nombre de la rama", + "worktree.branchPlaceholder": "feature/mi-cambio", + "worktree.branchesFrom": "Se ramifica desde {branch}", + "worktree.create": "Crear", + "worktree.creating": "Creando…", + "worktree.open": "Abrir", + "worktree.opening": "Abriendo…", + "worktree.mainCheckout": "el repositorio en sí", + "worktree.empty": "Todavía no hay worktrees.", + "worktree.detached": "separado", + "worktree.recoverOpen": "Abrir la rama que se creó", + "space.new.tab.plain": "Espacio", + "space.new.tab.worktree": "Worktree", + "space.new.repo.label": "Repositorio", + "worktree.orOpenExisting": "O abre uno que ya existe", }; diff --git a/web/src/lib/i18n/messages/ja.ts b/web/src/lib/i18n/messages/ja.ts index 3280b455..63bfa502 100644 --- a/web/src/lib/i18n/messages/ja.ts +++ b/web/src/lib/i18n/messages/ja.ts @@ -658,4 +658,30 @@ export const ja: Dictionary = { "apiError.session.unknown": "この collie に {session} というセッションはありません。", "apiError.host.unknown": "この pack に {host} という collie はありません。", "apiError.pack.not_lead": "この collie は pack を率いていないため、表示する pack がありません。", + // --- worktrees (ADR 0032) --- + "apiError.worktree.list_failed": "worktree を一覧できませんでした:{reason}", + "apiError.worktree.create_failed": "worktree を作成できませんでした:{reason}", + "apiError.worktree.created_not_opened": "ブランチは作成されましたが、その上で何も開けませんでした:{reason}", + "apiError.worktree.open_failed": "worktree を開けませんでした:{reason}", + "apiError.worktree.busy": "別の worktree 操作が実行中です。少し待ってからもう一度お試しください。", + "apiError.worktree.ambiguous_branch": "そのブランチ名は複数に一致します:{reason}", + "apiError.worktree.branch_required": "先にブランチ名を入力してください。", + "apiError.worktree.not_a_repo": "このスペースは Git リポジトリ内にありません。", + "worktree.section": "worktree", + "worktree.new": "新しい worktree", + "worktree.branchLabel": "ブランチ名", + "worktree.branchPlaceholder": "feature/my-change", + "worktree.branchesFrom": "{branch} から分岐", + "worktree.create": "作成", + "worktree.creating": "作成中…", + "worktree.open": "開く", + "worktree.opening": "オープン中…", + "worktree.mainCheckout": "リポジトリ本体", + "worktree.empty": "worktree はまだありません。", + "worktree.detached": "detached", + "worktree.recoverOpen": "作成されたブランチを開く", + "space.new.tab.plain": "スペース", + "space.new.tab.worktree": "worktree", + "space.new.repo.label": "リポジトリ", + "worktree.orOpenExisting": "または既存のものを開く", }; diff --git a/web/src/lib/i18n/messages/ko.ts b/web/src/lib/i18n/messages/ko.ts index c9c02401..42473b88 100644 --- a/web/src/lib/i18n/messages/ko.ts +++ b/web/src/lib/i18n/messages/ko.ts @@ -650,4 +650,30 @@ export const ko: Dictionary = { "apiError.session.unknown": "이 collie에는 {session} 세션이 없어요.", "apiError.host.unknown": "이 pack에는 {host}라는 collie가 없어요.", "apiError.pack.not_lead": "이 collie는 pack을 이끌지 않아서 보여줄 pack이 없어요.", + // --- worktrees (ADR 0032) --- + "apiError.worktree.list_failed": "worktree 목록을 불러오지 못했습니다: {reason}", + "apiError.worktree.create_failed": "worktree를 만들지 못했습니다: {reason}", + "apiError.worktree.created_not_opened": "브랜치는 만들어졌지만 그 위에 아무것도 열지 못했습니다: {reason}", + "apiError.worktree.open_failed": "worktree를 열지 못했습니다: {reason}", + "apiError.worktree.busy": "다른 worktree 작업이 아직 실행 중입니다. 잠시 후 다시 시도하세요.", + "apiError.worktree.ambiguous_branch": "그 브랜치 이름은 두 개 이상과 일치합니다: {reason}", + "apiError.worktree.branch_required": "먼저 브랜치 이름을 입력하세요.", + "apiError.worktree.not_a_repo": "이 스페이스는 Git 저장소 안에 있지 않습니다.", + "worktree.section": "Worktree", + "worktree.new": "새 worktree", + "worktree.branchLabel": "브랜치 이름", + "worktree.branchPlaceholder": "feature/my-change", + "worktree.branchesFrom": "{branch} 에서 분기", + "worktree.create": "만들기", + "worktree.creating": "만드는 중…", + "worktree.open": "열기", + "worktree.opening": "여는 중…", + "worktree.mainCheckout": "저장소 자체", + "worktree.empty": "아직 worktree가 없습니다.", + "worktree.detached": "detached", + "worktree.recoverOpen": "생성된 브랜치 열기", + "space.new.tab.plain": "스페이스", + "space.new.tab.worktree": "Worktree", + "space.new.repo.label": "저장소", + "worktree.orOpenExisting": "또는 이미 있는 것을 열기", }; diff --git a/web/src/lib/i18n/messages/zh.ts b/web/src/lib/i18n/messages/zh.ts index 93144818..00bc4624 100644 --- a/web/src/lib/i18n/messages/zh.ts +++ b/web/src/lib/i18n/messages/zh.ts @@ -637,4 +637,30 @@ export const zh: Dictionary = { "apiError.session.unknown": "此 collie 上没有名为 {session} 的会话。", "apiError.host.unknown": "此 pack 中没有名为 {host} 的 collie。", "apiError.pack.not_lead": "此 collie 不是 pack 的 lead,没有可显示的 pack。", + // --- worktrees (ADR 0032) --- + "apiError.worktree.list_failed": "无法列出 worktree:{reason}", + "apiError.worktree.create_failed": "无法创建 worktree:{reason}", + "apiError.worktree.created_not_opened": "分支已创建,但没能在它上面打开空间:{reason}", + "apiError.worktree.open_failed": "无法打开 worktree:{reason}", + "apiError.worktree.busy": "还有一个 worktree 操作在进行中 —— 稍等片刻再试。", + "apiError.worktree.ambiguous_branch": "这个分支名匹配到了多个目标:{reason}", + "apiError.worktree.branch_required": "请先填写分支名。", + "apiError.worktree.not_a_repo": "这个空间不在 Git 仓库里。", + "worktree.section": "Worktree", + "worktree.new": "新建 worktree", + "worktree.branchLabel": "分支名", + "worktree.branchPlaceholder": "feature/my-change", + "worktree.branchesFrom": "从 {branch} 分叉", + "worktree.create": "创建", + "worktree.creating": "创建中…", + "worktree.open": "打开", + "worktree.opening": "打开中…", + "worktree.mainCheckout": "仓库本体", + "worktree.empty": "还没有 worktree。", + "worktree.detached": "游离头指针", + "worktree.recoverOpen": "打开已创建的分支", + "space.new.tab.plain": "空间", + "space.new.tab.worktree": "Worktree", + "space.new.repo.label": "仓库", + "worktree.orOpenExisting": "或打开已有的", }; diff --git a/web/src/lib/spaces-nest.test.ts b/web/src/lib/spaces-nest.test.ts new file mode 100644 index 00000000..7ff985ae --- /dev/null +++ b/web/src/lib/spaces-nest.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { nestWorktrees } from "./spaces"; +import type { WorkspaceView } from "./types"; + +// The grouping rules the spaces list leans on. Written against the shape a real herd produces: +// a repo checkout open as one space, its worktrees open as others, and unrelated spaces between. + +function space( + workspaceId: string, + extra: Partial = {}, +): WorkspaceView { + return { + workspaceId, + number: 1, + label: workspaceId, + focused: false, + activeTabId: `${workspaceId}:t1`, + tabCount: 1, + paneCount: 1, + ...extra, + }; +} + +const REPO = "/repo/jmds"; +const parent = space("w1", { repoRoot: REPO, isWorktree: false }); +const child = space("w2", { repoRoot: REPO, isWorktree: true }); +const unrelated = space("w3"); + +describe("nestWorktrees", () => { + it("puts a worktree one level under the space holding its repo", () => { + expect(nestWorktrees([parent, child])).toEqual([ + { space: parent, depth: 0 }, + { space: child, depth: 1 }, + ]); + }); + + it("leaves spaces outside any repo flat", () => { + expect(nestWorktrees([unrelated])).toEqual([{ space: unrelated, depth: 0 }]); + }); + + it("keeps a worktree flat when its repo is not open — nothing to indent under", () => { + expect(nestWorktrees([child])).toEqual([{ space: child, depth: 0 }]); + }); + + it("moves the whole group to the position of its most recent member", () => { + // Incoming order is recency: the worktree was used most recently, the repo checkout long ago. + const rows = nestWorktrees([child, unrelated, parent]); + expect(rows.map((r) => r.space.workspaceId)).toEqual(["w1", "w2", "w3"]); + expect(rows.map((r) => r.depth)).toEqual([0, 1, 0]); + }); + + it("groups several worktrees of one repo, in the order they arrived", () => { + const second = space("w4", { repoRoot: REPO, isWorktree: true }); + const rows = nestWorktrees([parent, second, unrelated, child]); + expect(rows.map((r) => r.space.workspaceId)).toEqual(["w1", "w4", "w2", "w3"]); + expect(rows.map((r) => r.depth)).toEqual([0, 1, 1, 0]); + }); + + it("keeps two repos apart", () => { + const otherParent = space("w5", { repoRoot: "/repo/infra", isWorktree: false }); + const otherChild = space("w6", { repoRoot: "/repo/infra", isWorktree: true }); + const rows = nestWorktrees([parent, otherParent, child, otherChild]); + expect(rows.map((r) => r.space.workspaceId)).toEqual(["w1", "w2", "w5", "w6"]); + expect(rows.map((r) => r.depth)).toEqual([0, 1, 0, 1]); + }); +}); diff --git a/web/src/lib/spaces.ts b/web/src/lib/spaces.ts index 985a111b..c5f84e73 100644 --- a/web/src/lib/spaces.ts +++ b/web/src/lib/spaces.ts @@ -125,3 +125,84 @@ export function filterSpaces( if (!q) return [...workspaces]; return workspaces.filter((w) => w.label.toLowerCase().includes(q)); } + +/** + * One row of the spaces list: a space, plus how deep it sits. + * + * `depth: 1` is a worktree shown under the space that holds its repo. It is a VIEW fact, computed + * per render, and deliberately not a field on the space: whether a worktree has a parent to sit + * under depends on what is open right now, which is not something a space can know about itself. + */ +export interface SpaceRow { + readonly space: WorkspaceView; + readonly depth: 0 | 1; +} + +/** + * Nest each worktree under the space showing its repo, keeping the list's recency order. + * + * THREE RULES, and each answers a case the flat list never had: + * + * • **A group takes the position of its most recent member.** Sorting by the parent alone would + * bury a worktree you used a minute ago under a repo checkout you last touched last week — the + * list promises "what is fresh is near the top", and a group must keep that promise. + * • **A worktree whose repo is not open stays at depth 0.** There is no row to indent under, and + * indenting under nothing reads as a rendering bug. + * • **Order within a group is the incoming order**, which is already recency: the parent first, + * then its worktrees as they were sorted. + * + * `ordered` must already be in the order the caller wants (see {@link sortSpacesByRecency}); this + * function only regroups, never re-sorts. + */ +export function nestWorktrees(ordered: readonly WorkspaceView[]): SpaceRow[] { + // Only a space that IS the repo's own checkout can be a parent (`isWorktree === false`). + const parentByRepo = new Map(); + for (const space of ordered) { + if (space.repoRoot !== undefined && space.isWorktree === false) { + parentByRepo.set(space.repoRoot, space); + } + } + + const childrenByParent = new Map(); + for (const space of ordered) { + if (space.repoRoot === undefined || space.isWorktree !== true) continue; + const parent = parentByRepo.get(space.repoRoot); + if (parent === undefined) continue; // orphan — rendered flat, below + const kin = childrenByParent.get(parent.workspaceId) ?? []; + kin.push(space); + childrenByParent.set(parent.workspaceId, kin); + } + + const rows: SpaceRow[] = []; + const placed = new Set(); + for (const space of ordered) { + if (placed.has(space.workspaceId)) continue; + const kin = childrenByParent.get(space.workspaceId); + // A parent reached through its own position, or dragged up here by a child that came first. + if (kin !== undefined) { + rows.push({ space, depth: 0 }); + placed.add(space.workspaceId); + for (const child of kin) { + rows.push({ space: child, depth: 1 }); + placed.add(child.workspaceId); + } + continue; + } + // A child met before its parent: emit the whole group HERE, at the child's (fresher) position. + if (space.repoRoot !== undefined && space.isWorktree === true) { + const parent = parentByRepo.get(space.repoRoot); + if (parent !== undefined && !placed.has(parent.workspaceId)) { + rows.push({ space: parent, depth: 0 }); + placed.add(parent.workspaceId); + for (const child of childrenByParent.get(parent.workspaceId) ?? []) { + rows.push({ space: child, depth: 1 }); + placed.add(child.workspaceId); + } + continue; + } + } + rows.push({ space, depth: 0 }); + placed.add(space.workspaceId); + } + return rows; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 34deb0fb..54c2d9e2 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -132,6 +132,15 @@ export interface WorkspaceView { activeTabId: string; tabCount: number; paneCount: number; + /** + * The Git repo this space sits in, when the multiplexer reports one. + * + * Absent means "no repo, or this multiplexer keeps no such mapping" — and absence is what hides + * the worktree rows, so no extra call is needed to decide whether to show them. + */ + repoRoot?: string; + /** Whether this space is a linked worktree of `repoRoot`, not the repo's own checkout. */ + isWorktree?: boolean; } /** A tab within a workspace (holds one or more panes). */ @@ -532,6 +541,9 @@ export const MUX_CAPABILITIES = [ "renameTab", "closeTab", "createSpace", + "listWorktrees", + "createWorktree", + "openWorktree", "pushTopologyEvents", "pushPaneEvents", ] as const; @@ -684,3 +696,25 @@ export const STATUS_RANK = { export function statusLabel(status: AgentStatus): string { return t(`status.label.${status}`); } + +/** One Git worktree of the repo a space sits in. Mirrors `WorktreeView` in bridge/types.ts. */ +export interface WorktreeView { + path: string; + branch: string | null; + /** The space showing it, or `null` when nothing does — which is what hides its Remove row. */ + openWorkspaceId: string | null; + /** `false` for the repo's own checkout: listed for context, never removable. */ + linked: boolean; + prunable: boolean; +} + +/** GET /api/workspace/:id/worktrees */ +export type WorktreeListResponse = + | { ok: true; worktrees: WorktreeView[] } + | { ok: false; error: string; code?: ApiErrorCode; detail?: ApiErrorDetail }; + +/** POST /api/workspace/:id/worktree[/open] — `alreadyOpen` is an answer, never a failure. */ +export type WorktreeOpenResponse = + | { ok: true; pane: CreatedPane; alreadyOpen: boolean } + | { ok: false; error: string; code?: ApiErrorCode; detail?: ApiErrorDetail }; + diff --git a/web/src/playground/app.tsx b/web/src/playground/app.tsx index 19f3f1af..095f303f 100644 --- a/web/src/playground/app.tsx +++ b/web/src/playground/app.tsx @@ -16,6 +16,8 @@ import { AgentList } from "@/components/agent-list"; import { AppHeader, SettingsGear } from "@/components/app-header"; import { BuildStamp } from "@/components/build-stamp"; import { CollieHome } from "@/components/collie-home"; +import { NewSpaceSheet } from "@/components/new-space-sheet"; +import { SpaceOverview } from "@/components/space-overview"; import { CollieMark } from "@/components/collie-mark"; import { ConnectionBanner } from "@/components/connection-banner"; import { HostStaleBanner } from "@/components/host-stale-banner"; @@ -53,6 +55,7 @@ import { paneShell, paneWorking, rosterFive, + spacesWithWorktrees, updateMajor, updateRelease, updateRestart, @@ -659,6 +662,47 @@ function SettingsSection() { + + + {}} + onNewSpace={() => {}} + open + onOpenChange={() => {}} + /> + + + + + + {}} + onCreate={() => {}} + repos={[ + { workspaceId: "w1", repoRoot: "/src/collie", label: "collie" }, + { workspaceId: "w9", repoRoot: "/src/nixcfg", label: "nixcfg" }, + ]} + onCreateWorktree={() => {}} + onOpenWorktree={() => {}} + /> + + + ); } diff --git a/web/src/playground/fixtures.ts b/web/src/playground/fixtures.ts index 69a4efed..73632c19 100644 --- a/web/src/playground/fixtures.ts +++ b/web/src/playground/fixtures.ts @@ -93,6 +93,50 @@ export const FIXTURE_TS = 400_000; // than one of them. Herdr numbers spaces per machine, which is why the ids repeat across hosts in // the pack snapshot below — that collision is the reason a pane row carries its own host. +/** + * Spaces as a repo and its worktrees, for the nesting card. + * + * `repoRoot` + `isWorktree` are what the multiplexer reports on the workspace itself, so this is the + * real shape rather than a staged one: `collie` is the repo's own checkout, the two below it are + * worktrees of it, and `blog` sits outside any repo. + */ +export const spacesWithWorktrees: WorkspaceView[] = [ + { + workspaceId: "w1", + number: 1, + label: "collie", + focused: true, + activeTabId: "w1:t1", + tabCount: 3, + paneCount: 4, + repoRoot: "/src/collie", + isWorktree: false, + }, + { + workspaceId: "w2", + number: 2, + label: "feat-worktrees", + focused: false, + activeTabId: "w2:t1", + tabCount: 1, + paneCount: 1, + repoRoot: "/src/collie", + isWorktree: true, + }, + { + workspaceId: "w3", + number: 3, + label: "fix-dirty-refusal", + focused: false, + activeTabId: "w3:t1", + tabCount: 1, + paneCount: 2, + repoRoot: "/src/collie", + isWorktree: true, + }, + { workspaceId: "w4", number: 4, label: "blog", focused: false, activeTabId: "w4:t1", tabCount: 2, paneCount: 2 }, +]; + export const spaces: WorkspaceView[] = [ { workspaceId: "w1", number: 1, label: "collie", focused: true, activeTabId: "w1:t1", tabCount: 3, paneCount: 4 }, { workspaceId: "w2", number: 2, label: "sprqvntrs-api", focused: false, activeTabId: "w2:t1", tabCount: 3, paneCount: 4 }, diff --git a/web/src/routes/home.tsx b/web/src/routes/home.tsx index 714ccb37..81391d3c 100644 --- a/web/src/routes/home.tsx +++ b/web/src/routes/home.tsx @@ -8,7 +8,7 @@ import { PullToRefresh } from "@/components/pull-to-refresh"; import { ReadOnlyBanner } from "@/components/read-only-banner"; import { AgentList } from "@/components/agent-list"; import { SpaceOverview } from "@/components/space-overview"; -import { NewSpaceSheet } from "@/components/new-space-sheet"; +import { NewSpaceSheet, type WorktreeRepo } from "@/components/new-space-sheet"; import { StatusArea } from "@/components/status-area"; import { BuildStamp } from "@/components/build-stamp"; import { PackFooterLink } from "@/components/pack-footer-link"; @@ -16,6 +16,10 @@ import { UpdateBanner } from "@/components/update-banner"; import { useDashPrefs, openForCount } from "@/hooks/use-dash-prefs"; import { useLoadingStalled } from "@/hooks/use-loading-stalled"; import { useSpaceActions } from "@/hooks/use-spaces"; +import { useMuxCapability } from "@/lib/mux-capability"; +import { createWorktree, openWorktree } from "@/lib/api"; +import { describeApiError } from "@/lib/api-error-message"; +import { setStatus } from "@/lib/status"; import { leadHost, paneScope, sessionsOnHost } from "@/lib/hosts"; import { panePath, spacePath } from "@/lib/nav"; import type { AgentView } from "@/lib/types"; @@ -33,6 +37,17 @@ export function HomeRoute() { const stalled = useLoadingStalled(); const navigate = useNavigate(); const { newSpace } = useSpaceActions(); + + // Which repos a worktree could be branched from: one entry per repo, taken from the space that + // shows the repo ITSELF (a worktree's own space would branch from the same repo, so listing both + // would offer the same thing twice under two names). In the spaces list's order, so the first + // entry — the sheet's default — is the repo most recently used. + const canCreateWorktree = useMuxCapability("createWorktree"); + const worktreeRepos: WorktreeRepo[] = canCreateWorktree + ? data.workspaces + .filter((w) => w.repoRoot !== undefined && w.isWorktree === false) + .map((w) => ({ workspaceId: w.workspaceId, repoRoot: w.repoRoot!, label: w.label })) + : []; const [newSpaceOpen, setNewSpaceOpen] = useState(false); const { prefs, setSpacesOpen, setRecentOpen, setRecentDir } = useDashPrefs(); // No stored choice yet? The space count decides — a two-space install shouldn't be handed a @@ -120,7 +135,30 @@ export function HomeRoute() { - setNewSpaceOpen(false)} onCreate={newSpace} /> + setNewSpaceOpen(false)} + onCreate={newSpace} + repos={worktreeRepos} + scope={data.scope} + onOpenWorktree={(workspaceId, path) => { + void (async () => { + const res = await openWorktree(workspaceId, path, data.scope); + // `alreadyOpen` is an answer, not a failure — either way the pane is where to go. + if (res.ok) navigate(panePath(res.pane.paneId, data.scope)); + else setStatus(describeApiError(res), "error"); + })(); + }} + onCreateWorktree={(workspaceId, branch) => { + void (async () => { + const res = await createWorktree(workspaceId, branch, data.scope); + // Same promise the sheet on a space makes: the phone goes to the new pane, the + // operator's own screen is not moved. + if (res.ok) navigate(panePath(res.pane.paneId, data.scope)); + else setStatus(describeApiError(res), "error"); + })(); + }} + /> ); }