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
@@ -15,7 +15,7 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { api } from "../../lib/api";
import { api, gitTarget } from "../../lib/api";
import {
applyChoices,
parseConflicts,
@@ -37,11 +37,14 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
export function LaneConflictView({
laneId,
session,
path,
onResolved,
onEdit,
}: {
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
@@ -62,7 +65,7 @@ export function LaneConflictView({
setChoices([]);
setError(null);
api.lanes
.gitFile(laneId, path, "work")
.gitFile(gitTarget(laneId, session), path, "work")
.then((r) => {
if (!live) return;
setFile(
@@ -75,7 +78,7 @@ export function LaneConflictView({
return () => {
live = false;
};
}, [laneId, path]);
}, [laneId, session, path]);
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
@@ -98,11 +101,16 @@ export function LaneConflictView({
const save = () =>
run(async () => {
if (!parts || !file?.sha) return;
await api.lanes.gitSaveFile(laneId, path, applyChoices(parts, choices).join("\n"), file.sha);
await api.lanes.gitAction(laneId, "resolve", { path, side: "done" });
await api.lanes.gitSaveFile(
gitTarget(laneId, session),
path,
applyChoices(parts, choices).join("\n"),
file.sha
);
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
});
const takeAll = (side: "ours" | "theirs") =>
run(() => api.lanes.gitAction(laneId, "resolve", { path, side }));
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
const choose = (i: number, c: ConflictChoice | undefined) =>
setChoices((prev) => {