/** * @file SessionResumeButton.tsx * @description The transcript page's explicit Resume and Fork for a Claude * session inside a workspace. Opening a session never resumes it; this is the * one place that does, behind a warning, because a resume re-reads the whole * transcript into context. Fork resumes it into a NEW session id * (`forkSession: true` → `claude --resume --fork-session`), leaving the * original untouched, and is offered even while the original has a live pane. A * session that still has a live pane offers that pane instead. A resume * starts in the workspace's first free pane slot, picked by the server * (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, panes re-read * first) and opens it — offered only * for a session whose folder IS the workspace's, because the pane starts in * the workspace's folder and Claude finds a session by the folder it ran in. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState } from "react"; 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, Session } from "../../lib/types"; import { useLanes } from "../../hooks/useLanes"; import { ConfirmModal } from "../ConfirmModal"; import { startInFreeTabDetailed } from "./startSession"; import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList"; import { usePermissions } from "../AuthGate"; /** A path without its trailing separators — `/` or a Windows `\`. */ const strip = (p: string) => p.replace(/[\\/]+$/, ""); /** The workspace of this tool whose folder holds `cwd` most deeply — the * same path-boundary rule the server binds hook sessions with. */ export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null { let best: Lane | null = null; const target = strip(cwd); for (const l of lanes) { if (l.tool !== tool) continue; const root = strip(l.cwd); if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\")) continue; if (!best || root.length > strip(best.cwd).length) best = l; } return best; } export function SessionResumeButton({ session, }: { session: { id: string; cwd: string | null; tool?: string; lane_id?: number | null }; }) { const { t } = useTranslation("lanes"); const navigate = useNavigate(); const { lanes } = useLanes(); const { canEditLane } = usePermissions(); const [runs, setRuns] = useState(null); const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { let live = true; api.run .list() .then((r) => live && setRuns(r.items ?? [])) .catch(() => live && setRuns([])); return () => { live = false; }; }, [session.id]); // Claude only: a Codex session id is not what // `codex resume` takes for every kind of thread. const tool = session.tool || "claude"; // 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)); // Server mode: an analyst may resume only in a workspace of their own. if (!lane || !canEditLane(lane) || runs === null) return null; const run = liveRunFor(lane, runs, session.id); const same = (a: string, b: string) => strip(a) === strip(b); // 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, laneId = lane.id) => navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`); const resume = async (fork: boolean) => { // Busy first: the Confirm button is disabled only through it, and the // re-read below is a round trip a second click could land in. setBusy(true); setError(null); try { // Re-read: a pane started since this page loaded must not be picked, // and a session resumed elsewhere meanwhile is opened, not resumed twice. const fresh = await api.run .list() .then((r) => r.items ?? []) .catch(() => runs); setRuns(fresh); const live = liveRunFor(lane, fresh, session.id); if (live && !fork) return open(live.id); // A pane of another (nested) workspace may hold it: open it there. const r = await startInFreeTabDetailed(lane.id, { resumeSessionId: session.id, ...(fork ? { forkSession: true } : {}), }); open(r.runId, r.laneId); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); setConfirming(null); } }; return (
{run ? ( ) : ( )} {resumable && ( )} {error && (

{error}

)} void resume(confirming === "fork")} onCancel={() => setConfirming(null)} />
); }