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
+4
View File
@@ -78,6 +78,8 @@ export interface CheckboxProps {
* (bypass permissions, purge data, force). */
tone?: "accent" | "danger" | "warning";
disabled?: boolean;
/** Tooltip (the app turns every `title` into its themed bubble). */
title?: string;
}
const TONE = {
@@ -99,6 +101,7 @@ export function Checkbox({
labelClassName,
tone = "accent",
disabled = false,
title,
}: CheckboxProps) {
return (
<button
@@ -106,6 +109,7 @@ export function Checkbox({
role="checkbox"
aria-checked={checked}
disabled={disabled}
title={title}
onClick={() => onChange(!checked)}
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
>
@@ -53,6 +53,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
list: vi.fn(async () => ({ lanes, counts: {} })),
update: vi.fn(async () => ({ lane: lanes[0] })),
action: vi.fn(async () => ({ ok: true })),
removeSessionWorktree: vi.fn(async () => ({
removed: true,
branch: "x",
branchKept: false,
})),
routerList: vi.fn(async () => ({ routers: [] })),
},
sessions: {
@@ -158,6 +163,50 @@ describe("WorkspaceTree", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
});
it("archiving a worktree session removes its worktree first, after asking", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
{ ...session("s-wt", "In a worktree"), cwd: "/r/.claude/worktrees/ah-1-x" } as Session,
],
total: 1,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
expect(await screen.findByTestId("workspace-session-worktree-s-wt")).toBeInTheDocument();
await user.click(screen.getByTestId("workspace-session-archive-s-wt"));
expect(api.sessions.patch).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toHaveTextContent(".claude/worktrees/ah-1-x");
await user.click(screen.getByRole("button", { name: "Archive and remove" }));
await waitFor(() =>
expect(api.sessions.patch).toHaveBeenCalledWith("s-wt", { archived: true })
);
expect(api.lanes.removeSessionWorktree).toHaveBeenCalledWith(1, "s-wt");
});
it("keeps a worktree session when git refuses to remove its worktree", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
{ ...session("s-wt", "In a worktree"), cwd: "/r/.claude/worktrees/ah-1-x" } as Session,
],
total: 1,
limit: 20,
offset: 0,
}));
vi.mocked(api.lanes.removeSessionWorktree).mockRejectedValueOnce(
Object.assign(new Error("contains modified or untracked files"), { code: "EGIT" })
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.click(await screen.findByTestId("workspace-session-archive-s-wt"));
await user.click(screen.getByRole("button", { name: "Archive and remove" }));
expect(await screen.findByRole("alert")).toHaveTextContent("modified or untracked");
expect(api.sessions.patch).not.toHaveBeenCalled();
});
it("dots a session waiting for the user amber and a failed one red", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
@@ -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();
@@ -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) => {
+6 -3
View File
@@ -36,7 +36,7 @@ import {
Trash2,
Upload,
} from "lucide-react";
import { api, type GitAuthNeeded } from "../../lib/api";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
@@ -89,12 +89,15 @@ function Btn({
export function LaneGitBar({
laneId,
session,
git,
onChanged,
onNewLane,
lead,
}: {
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
@@ -157,7 +160,7 @@ export function LaneGitBar({
setBusy(action);
setError(null);
try {
const res = await api.lanes.gitAction(lane, action, body || {});
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
@@ -208,7 +211,7 @@ export function LaneGitBar({
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
+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}
@@ -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)}`);
+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)}
@@ -16,12 +16,13 @@
*/
import { useEffect, useState, type ReactNode } from "react";
import { Archive } from "lucide-react";
import { Archive, GitBranch } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
export const SESSION_LIMIT = 20;
@@ -38,6 +39,14 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
);
}
/** The name of the git worktree a session ran in (`claude --worktree` puts it
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
const strip = (p: string) => p.replace(/\/+$/, "");
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null;
}
/** A session row's dot: waiting for the user, failed, held by a live pane, or
* at rest. */
export function sessionDot(session: Session, live: boolean): string {
@@ -178,6 +187,10 @@ export function WorkspaceSessionList({
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
// A worktree session asked to be archived: Claude Desktop removes its
// worktree too, so that is confirmed first.
const [archiving, setArchiving] = useState<Session | null>(null);
const [busy, setBusy] = useState(false);
// The lane's own session/status/needs-you move on every hook, which is when
// a session can appear or change state — re-read then, not on a timer.
@@ -205,6 +218,44 @@ export function WorkspaceSessionList({
}
};
// Archive: a worktree session with no live pane loses its worktree first
// (never forced — git refuses unsaved work, which then stops the archive);
// one Claude already cleaned up is just archived.
const archive = (s: Session) =>
sessionWorktreeName(lane, s) && !runFor(s)
? setArchiving(s)
: void patch(s, { archived: true });
const archiveWithWorktree = async (s: Session) => {
setBusy(true);
setError(null);
try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
});
await patch(s, { archived: true });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setArchiving(null);
}
};
const confirmArchive = (
<ConfirmModal
open={archiving !== null}
title={t("tree.archiveWorktreeTitle")}
message={t("tree.archiveWorktreeBody", {
name: archiving ? sessionWorktreeName(lane, archiving) : "",
})}
confirmLabel={t("tree.archiveWorktreeConfirm")}
cancelLabel={t("home.cancel")}
destructive
busy={busy}
onConfirm={() => archiving && void archiveWithWorktree(archiving)}
onCancel={() => setArchiving(null)}
/>
);
if (hidden) return null;
const failure = error && (
<p role="alert" className="pl-7 text-[11px] text-status-danger">
@@ -216,6 +267,7 @@ export function WorkspaceSessionList({
<>
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
{failure}
{confirmArchive}
</>
);
}
@@ -269,9 +321,11 @@ export function WorkspaceSessionList({
setDraft(s.name || "");
setRenaming(s.id);
}}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}\n${
run ? t("tree.splitHint") : t("tree.transcriptHint")
}`}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s)
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
@@ -283,11 +337,18 @@ export function WorkspaceSessionList({
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
/>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{sessionWorktreeName(lane, s) && (
<GitBranch
data-testid={`workspace-session-worktree-${s.id}`}
aria-label={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
</button>
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => void patch(s, { archived: true })}
onClick={() => archive(s)}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary focus:block group-hover:block"
@@ -299,6 +360,7 @@ export function WorkspaceSessionList({
})}
</ul>
{failure}
{confirmArchive}
</>
);
}
@@ -19,6 +19,7 @@ import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: {
lanes: {
git: vi.fn(),
@@ -14,6 +14,7 @@ import { LaneConflictView } from "../LaneConflictView";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
}));
@@ -17,6 +17,7 @@ import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
@@ -13,6 +13,7 @@ import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
@@ -43,7 +43,12 @@ function Where() {
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderButton(session: { id: string; cwd: string | null; tool?: string }) {
function renderButton(session: {
id: string;
cwd: string | null;
tool?: string;
lane_id?: number;
}) {
return render(
<MemoryRouter initialEntries={["/sessions/x"]}>
<Routes>
@@ -136,6 +141,17 @@ describe("SessionResumeButton", () => {
});
});
it("resumes a session in its own worktree through the workspace that owns it", async () => {
const user = userEvent.setup();
renderButton({ id: "s-wt", cwd: "/r/.claude/worktrees/ah-3-x", tool: "claude", lane_id: 3 });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
resumeSessionId: "s-wt",
});
});
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
@@ -193,6 +193,21 @@ describe("WorkspaceHome", () => {
);
});
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.click(await screen.findByRole("checkbox", { name: "Worktree" }));
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
prompt: "try it",
permissionMode: "auto",
worktree: true,
});
});
it("refuses to start when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle