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
+31 -1
View File
@@ -12,6 +12,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -19,6 +20,7 @@ import {
ArchiveRestore,
FileText,
Folder,
GitBranch,
MoreHorizontal,
Pencil,
Play,
@@ -41,7 +43,13 @@ import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { liveRunFor, SessionFold, sessionDot, useSessionPage } from "./WorkspaceSessionList";
import {
liveRunFor,
SessionFold,
sessionDot,
sessionWorktreeName,
useSessionPage,
} from "./WorkspaceSessionList";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
@@ -96,6 +104,9 @@ function HomeBody({
const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel]);
const [mode, setMode] = useState<PermissionMode>("auto");
// Claude Desktop's worktree toggle: the new session gets a git worktree of
// its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
@@ -219,6 +230,7 @@ function HomeBody({
prompt: prompt.trim(),
...(model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
const runId = r.run_id ?? paneRunId(lane.id, slot);
@@ -344,6 +356,15 @@ function HomeBody({
/>
</div>
)}
{claude && project?.root && (
<Checkbox
checked={inWorktree}
onChange={setInWorktree}
label={t("home.worktree")}
title={t("home.worktreeHint")}
className="ml-1"
/>
)}
<button
type="button"
data-testid="workspace-home-start"
@@ -382,6 +403,15 @@ function HomeBody({
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
{sessionWorktreeName(lane, s) && (
<span
title={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
className="inline-flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
>
<GitBranch className="h-3 w-3" />
{sessionWorktreeName(lane, s)}
</span>
)}
{!!s.cost && s.cost > 0 && (
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
{fmtCost(s.cost)}