/** * @file SessionReplay — an ENDED session opened in its workspace * (`/workspace?lane=&session=`), the way Claude Desktop opens * an old one: its conversation, read-only (`ConversationView`), and a prompt * box under it (a pasted image rides along as a thumbnail). Opening it resumes * nothing; only SENDING a message does — `POST /api/lanes/:id/start` with * `resumeSessionId`, that prompt and `slot: "auto"` (the server picks the free * tab), after which `onResumed` opens the pane (`&run=`). The panes are re-read * first, and a session some tab already holds opens THAT tab instead of a * second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed * message is pasted there (never sent: Enter is the user's), with a toast; while * it is live the composer is off. Only Claude and Codex can resume a session by id — * any other tool's session offers a new session from the workspace home * instead. The header carries the transcript page's Resume/Fork * (`SessionResumeButton`, behind its warning). The session's tool and folder * come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the * session ran on (the server enforces that too). * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ArrowUp, SquareTerminal } from "lucide-react"; import { api, type RunHandle } from "../../lib/api"; import type { Lane, Session } from "../../lib/types"; import { ConversationView } from "../conversation/ConversationView"; import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup"; import { startInFreeTabDetailed } from "./startSession"; import { pasteToRun } from "../../lib/panePaste"; import { toast } from "../../lib/toast"; import i18n from "../../i18n"; import { liveRunFor } from "./WorkspaceSessionList"; import { SessionResumeButton } from "./SessionResumeButton"; /** A message for a session that a tab already holds goes into THAT tab as a * paste, once its terminal is on screen (it mounts after the navigation); * a toast says where it went — or, failing that, carries the text. */ function pasteWhenShown(runId: string, text: string) { let tries = 0; const tick = () => { if (pasteToRun(runId, text)) toast.success(i18n.t("lanes:replay.pasted")); else if (++tries < 30) window.setTimeout(tick, 100); else toast.error(i18n.t("lanes:replay.notPasted", { text })); }; window.setTimeout(tick, 0); } /** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */ const RESUMABLE = new Set(["claude", "codex"]); export function SessionReplay({ lane, sessionId, liveRuns, onResumed, }: { lane: Lane; sessionId: string; /** Every live dashboard pane: a session one of them holds opens that pane. */ liveRuns: RunHandle[]; /** Opens the pane the resume started in — of `laneId` when it is another * (nested) workspace's pane that holds the session. */ onResumed: (runId: string, laneId?: number) => void; }) { const { t } = useTranslation("lanes"); const navigate = useNavigate(); const [session, setSession] = useState(null); const [prompt, setPrompt] = useState(""); const [images, setImages] = useState([]); const [uploading, setUploading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { let live = true; setSession(null); setPrompt(""); setImages([]); setError(null); api.sessions .get(sessionId, { events: false }) .then((r) => live && setSession(r.session)) .catch(() => undefined); return () => { live = false; }; }, [sessionId]); const tool = session?.tool || "claude"; const resumable = RESUMABLE.has(tool); const livePane = liveRunFor(lane, liveRuns, sessionId); const text = promptWithImages(prompt, images); const send = async () => { if (!text || busy || uploading || livePane || !resumable) return; setBusy(true); setError(null); try { // Re-read: a tab that took this session since the list was read is // opened, never resumed a second time. const fresh = await api.run .list() .then((r) => r.items ?? []) .catch(() => liveRuns); const holder = liveRunFor(lane, fresh, sessionId); const r = holder ? { runId: holder.id, laneId: lane.id, held: true } : await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text }); setPrompt(""); images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); setImages([]); onResumed(r.runId, r.laneId); // Held already: the prompt reached nothing yet — paste it there. if (r.held) pasteWhenShown(r.runId, text); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; return (
{session && (
)}
{livePane ? (
{t("replay.live")}
) : session && !resumable ? (
{t("replay.noResume", { tool })}
) : (
void send()} placeholder={t("replay.placeholder")} rows={2} slash={tool === "claude"} fileCwd={session?.cwd || lane.cwd} lane={lane.id} />
{t("replay.hint")}
{error && (

{error}

)}
)}
); }