feat(workspace): a session in its own worktree, Claude's way

Desktop-parity phase 6, following Claude Desktop:
- the workspace home's Worktree checkbox starts the pane with
  `claude --worktree=<name>`, so Claude Code makes, populates
  (.worktreeinclude, WorktreeCreate hook) and cleans up
  <repo>/.claude/worktrees/<name>; AgentHub only excludes
  /.claude/worktrees/ at the repo root (Claude does not)
- server/lib/session-worktree.js resolves a session's worktree from the
  session row only (owned by the workspace, registered by git worktree
  list); every lane git route takes ?session= and answers ENOWORKTREE
  instead of falling back to the main folder
- the session view's Changes panel works in the opened run's worktree
  (dashboard_runs.worktree, GitTarget in the client api)
- Resume/Fork start in the session's worktree; a gone one is ENOWORKTREE
- Archive of a worktree session removes the worktree after asking:
  git worktree remove without --force, git branch -d (unmerged work keeps
  its branch), a lock held by a live pid refused, a stale one cleared
- listWorktrees reads `locked <reason>` locks
- tests scrub GIT_* env so the pre-commit suite cannot act on this repo
This commit is contained in:
2026-10-02 13:22:29 +07:00
parent a6bd33bcff
commit 5084334d81
35 changed files with 1041 additions and 119 deletions
@@ -19,6 +19,7 @@ import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: {
lanes: {
git: vi.fn(),
@@ -14,6 +14,7 @@ import { LaneConflictView } from "../LaneConflictView";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
}));
@@ -17,6 +17,7 @@ import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
@@ -13,6 +13,7 @@ import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
@@ -43,7 +43,12 @@ function Where() {
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderButton(session: { id: string; cwd: string | null; tool?: string }) {
function renderButton(session: {
id: string;
cwd: string | null;
tool?: string;
lane_id?: number;
}) {
return render(
<MemoryRouter initialEntries={["/sessions/x"]}>
<Routes>
@@ -136,6 +141,17 @@ describe("SessionResumeButton", () => {
});
});
it("resumes a session in its own worktree through the workspace that owns it", async () => {
const user = userEvent.setup();
renderButton({ id: "s-wt", cwd: "/r/.claude/worktrees/ah-3-x", tool: "claude", lane_id: 3 });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
resumeSessionId: "s-wt",
});
});
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
@@ -193,6 +193,21 @@ describe("WorkspaceHome", () => {
);
});
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.click(await screen.findByRole("checkbox", { name: "Worktree" }));
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
prompt: "try it",
permissionMode: "auto",
worktree: true,
});
});
it("refuses to start when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle