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
@@ -722,6 +722,34 @@ describe("Workspace — home and session view", () => {
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
});
it("points the Changes panel at the opened run's worktree session", async () => {
localStorage.setItem("agenthub.workspace.changesOpen", "1");
const original = vi.mocked(api.run.list).getMockImplementation();
vi.mocked(api.run.list).mockResolvedValue({
runs: [],
items: [
{
id: "agenthub-lane-1-1",
laneId: 1,
slot: 1,
status: "running",
sessionId: "s-wt",
worktree: true,
},
],
} as never);
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, session: "s-wt" }));
vi.mocked(api.run.list).mockImplementation(original!);
localStorage.removeItem("agenthub.workspace.changesOpen");
});
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
localStorage.removeItem("agenthub.workspace.splitView");
render(