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
@@ -19,11 +19,11 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { GitFork, History, SquareTerminal } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane } from "../../lib/types";
import type { Lane, Session } from "../../lib/types";
import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal";
import { freeSlot, paneRunId } from "./WorkspaceHome";
import { liveRunFor } from "./WorkspaceSessionList";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
/** The workspace of this tool whose folder holds `cwd` most deeply — the
* same path-boundary rule the server binds hook sessions with. */
@@ -41,7 +41,7 @@ export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | nul
export function SessionResumeButton({
session,
}: {
session: { id: string; cwd: string | null; tool?: string };
session: { id: string; cwd: string | null; tool?: string; lane_id?: number | null };
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -65,11 +65,21 @@ export function SessionResumeButton({
// Claude only: a Codex session id is not what
// `codex resume` takes for every kind of thread.
const tool = session.tool || "claude";
const lane = tool === "claude" && session.cwd ? owningLane(session.cwd, tool, lanes) : null;
// The workspace that owns it, else the one whose folder holds it.
const lane =
tool !== "claude" || !session.cwd
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null;
const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
const resumable = !!session.cwd && same(session.cwd, lane.cwd);
// Its own worktree counts too: the server resumes it there (`claude
// --worktree` sessions), and refuses one whose worktree is gone.
const resumable =
!!session.cwd &&
(same(session.cwd, lane.cwd) ||
sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null);
if (!run && !resumable) return null;
const open = (runId: string) =>
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);