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
@@ -53,6 +53,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
list: vi.fn(async () => ({ lanes, counts: {} })),
update: vi.fn(async () => ({ lane: lanes[0] })),
action: vi.fn(async () => ({ ok: true })),
removeSessionWorktree: vi.fn(async () => ({
removed: true,
branch: "x",
branchKept: false,
})),
routerList: vi.fn(async () => ({ routers: [] })),
},
sessions: {
@@ -158,6 +163,50 @@ describe("WorkspaceTree", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
});
it("archiving a worktree session removes its worktree first, after asking", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
{ ...session("s-wt", "In a worktree"), cwd: "/r/.claude/worktrees/ah-1-x" } as Session,
],
total: 1,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
expect(await screen.findByTestId("workspace-session-worktree-s-wt")).toBeInTheDocument();
await user.click(screen.getByTestId("workspace-session-archive-s-wt"));
expect(api.sessions.patch).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toHaveTextContent(".claude/worktrees/ah-1-x");
await user.click(screen.getByRole("button", { name: "Archive and remove" }));
await waitFor(() =>
expect(api.sessions.patch).toHaveBeenCalledWith("s-wt", { archived: true })
);
expect(api.lanes.removeSessionWorktree).toHaveBeenCalledWith(1, "s-wt");
});
it("keeps a worktree session when git refuses to remove its worktree", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
{ ...session("s-wt", "In a worktree"), cwd: "/r/.claude/worktrees/ah-1-x" } as Session,
],
total: 1,
limit: 20,
offset: 0,
}));
vi.mocked(api.lanes.removeSessionWorktree).mockRejectedValueOnce(
Object.assign(new Error("contains modified or untracked files"), { code: "EGIT" })
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.click(await screen.findByTestId("workspace-session-archive-s-wt"));
await user.click(screen.getByRole("button", { name: "Archive and remove" }));
expect(await screen.findByRole("alert")).toHaveTextContent("modified or untracked");
expect(api.sessions.patch).not.toHaveBeenCalled();
});
it("dots a session waiting for the user amber and a failed one red", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [