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
+12 -4
View File
@@ -15,7 +15,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api } from "../../lib/api";
import { api, gitTarget } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
@@ -29,7 +29,11 @@ const GIT_REFRESH_MS = 30_000;
* into the polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders with no git header.
*/
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
export function useLaneGitFacts(
laneId: number,
/** Read that session's own worktree instead (`?session=`). */
session?: string
): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
// this row, and waiting out the 30s poll makes the button look inert.
@@ -46,7 +50,7 @@ export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => voi
let alive = true;
const read = () => {
api.lanes
.git(laneId)
.git(gitTarget(laneId, session))
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
@@ -62,7 +66,7 @@ export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => voi
alive = false;
clearInterval(timer);
};
}, [laneId, nonce]);
}, [laneId, session, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
@@ -202,11 +206,14 @@ function LaneGitFilesPanel({
export function LaneGitHeader({
laneId,
session,
git,
onChanged,
onNewLane,
}: {
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
@@ -275,6 +282,7 @@ export function LaneGitHeader({
>
<LaneGitBar
laneId={laneId}
session={session}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}