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:
@@ -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) => {
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user