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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user