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
@@ -59,7 +59,7 @@ import {
Trash2,
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
@@ -422,11 +422,16 @@ function ChangeRow({
export function LaneChangesPanel({
laneId,
session,
onNewLane,
full = false,
tab = "changes",
}: {
laneId: number;
/** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
session?: string;
/** The Workspace right panel's activity-bar tab: `files` is the browse mode
* on its own (no git header, no mode switcher), `changes` the other four.
* At full width both show the explorer sidebar beside the changes. */
@@ -440,7 +445,9 @@ export function LaneChangesPanel({
const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId);
const [git, refreshGit] = useLaneGitFacts(laneId, session);
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
@@ -544,7 +551,7 @@ export function LaneChangesPanel({
const seq = ++loadSeq.current;
setLoading(true);
try {
const result = await api.lanes.changes(laneId, {
const result = await api.lanes.changes(target, {
range,
...(range === "commit" && sha ? { sha } : {}),
...(mode === "file" && openFile ? { path: openFile, context } : {}),
@@ -591,7 +598,7 @@ export function LaneChangesPanel({
useEffect(() => {
if (mode !== "history") return;
void api.lanes
.gitLog(laneId, { limit: 50 })
.gitLog(target, { limit: 50 })
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId, reread]);
@@ -603,7 +610,7 @@ export function LaneChangesPanel({
const wanted = full || (mode === "browse" && !query.trim());
if (!wanted || treeRootLoaded) return;
void api.lanes
.gitTree(laneId, "")
.gitTree(target, "")
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` itself is the cache this fills — a dep on it would loop.
@@ -619,7 +626,7 @@ export function LaneChangesPanel({
});
if (path in treeChildren) return;
void api.lanes
.gitTree(laneId, path)
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
@@ -632,7 +639,7 @@ export function LaneChangesPanel({
const lane = laneId;
setTreeBusy(true);
try {
const { levels } = await api.lanes.gitTreeAll(lane);
const { levels } = await api.lanes.gitTreeAll(target);
// A lane switch mid-read must not land lane A's tree in lane B.
if (laneNow.current !== lane) return;
setTreeChildren(levels);
@@ -655,7 +662,7 @@ export function LaneChangesPanel({
let live = true;
const timer = window.setTimeout(() => {
void api.lanes
.gitLs(laneId, q)
.gitLs(target, q)
.then((r) => live && setHits(r.paths))
.catch(() => live && setHits([]));
}, 150);
@@ -670,7 +677,7 @@ export function LaneChangesPanel({
useEffect(() => {
if (mode !== "file" || !whole || !openFile) return;
void api.lanes
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
.then(
forLane(laneId, (r) =>
setText(
@@ -775,7 +782,7 @@ export function LaneChangesPanel({
setBusy(true);
setError(null);
try {
const r = await api.lanes.gitSaveFile(lane, text.path, draft, text.sha);
const r = await api.lanes.gitSaveFile(target, text.path, draft, text.sha);
if (laneNow.current !== lane) return;
if (r.available) {
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
@@ -803,7 +810,7 @@ export function LaneChangesPanel({
setError(null);
try {
const paths = Array.isArray(path) ? path : [path];
await api.lanes.gitAction(lane, action, { paths, ...(confirm ? { confirm } : {}) });
await api.lanes.gitAction(target, action, { paths, ...(confirm ? { confirm } : {}) });
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
refreshGit();
@@ -824,7 +831,7 @@ export function LaneChangesPanel({
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, body);
await api.lanes.gitAction(target, action, body);
if (laneNow.current !== lane) return;
setArmedAbort(false);
refreshGit();
@@ -849,7 +856,7 @@ export function LaneChangesPanel({
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, {
await api.lanes.gitAction(target, action, {
path,
hunk: change.hunk.header,
block: change.block,
@@ -916,7 +923,7 @@ export function LaneChangesPanel({
// `only: "staged"` — the whole point of staging file by file is that the
// commit takes the index as it stands, with no `add -A` sweeping the
// rest of the tree in behind it.
await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" });
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
if (laneNow.current !== lane) return;
setMessage("");
refreshGit();
@@ -1055,7 +1062,13 @@ export function LaneChangesPanel({
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{!filesTab && (
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
<LaneGitHeader
laneId={laneId}
session={session}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
/>
)}
{/* ONE wrapping row: where you are (mode) then how you are looking at it
(range, layout, context, whole file), refresh last. Each group is
@@ -1430,6 +1443,7 @@ export function LaneChangesPanel({
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
<LaneConflictView
laneId={laneId}
session={session}
path={openFile}
onResolved={() => {
refreshGit();