/** * `useResumeProject` — view-model for the reopen resume flow (L15 / lot B2, * ARCHITECTURE §15.2). * * On project open, it pulls the read-only resumable inventory * ({@link AgentGateway.listResumableAgents}) and drives the per-agent resume * actions of the {@link ResumeProjectPanel}. The actual resume **reuses the * existing `launch_agent` flow** — no new backend use case: * * - **Reprendre** → `launchAgent(nodeId, conversationId)` (resume the CLI * conversation). For a profile without a usable session strategy * (`resumeSupported === false`) the entry simply carries no `conversationId`, * so the launch starts fresh ("relance à neuf"). * - **Nouvelle conversation** → `setCellConversation(nodeId, null)` first (so the * backend treats the cell as fresh and assigns a new id), then * `launchAgent(nodeId)` without a conversation id. * - **Ignorer** → drops the entry locally (no launch). * * The launch happens at the project level (no mounted xterm view yet): `onData` * is a no-op sink — the hosting cell reattaches to the spawned live session when * it mounts. It consumes the `agent` + `layout` gateways exclusively; never * `invoke()` (ARCHITECTURE §1.3), so it stays testable with mocks. */ import { useCallback, useEffect, useState } from "react"; import type { GatewayError, ResumableAgent } from "@/domain"; import { useGateways } from "@/app/di"; /** Default PTY geometry used for an open-time (headless) resume launch. */ const DEFAULT_ROWS = 24; const DEFAULT_COLS = 80; /** What the {@link ResumeProjectPanel} needs from this hook. */ export interface ResumeProjectViewModel { /** The agents still pending a decision (drains as the user acts). */ pending: ResumableAgent[]; /** Last error message, or `null`. */ error: string | null; /** Whether a resume launch is in flight. */ busy: boolean; /** Resumes one agent (keeps its conversation id ⇒ Resume / fresh if unsupported). */ resume: (agent: ResumableAgent) => Promise; /** Starts a fresh conversation for one agent (clears the id, then launches). */ newConversation: (agent: ResumableAgent) => Promise; /** Dismisses one agent without launching it. */ ignore: (agentId: string) => void; /** Resumes every pending agent. */ resumeAll: () => Promise; /** Dismisses every pending agent without launching. */ ignoreAll: () => void; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } /** A no-op output sink: the hosting cell reattaches to the live session later. */ const NO_DATA = (_bytes: Uint8Array): void => {}; export function useResumeProject( projectId: string, cwd: string, ): ResumeProjectViewModel { const { agent, layout } = useGateways(); const [pending, setPending] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); // Pull the inventory once per (project) open. A failure degrades to an empty // list (no panel), never surfaces — the per-cell fallback still works. useEffect(() => { if (!agent) return; let cancelled = false; agent .listResumableAgents(projectId) .then((list) => { if (!cancelled) setPending(list); }) .catch(() => { if (!cancelled) setPending([]); }); return () => { cancelled = true; }; }, [agent, projectId]); /** Removes one agent from the pending set (after a decision). */ const drop = useCallback((agentId: string) => { setPending((prev) => prev.filter((a) => a.agentId !== agentId)); }, []); /** Launches one agent, resuming `conversationId` when provided. */ const launch = useCallback( async (a: ResumableAgent, conversationId: string | undefined) => { if (!agent) return; await agent.launchAgent( projectId, a.agentId, { cwd, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, conversationId, nodeId: a.nodeId, }, NO_DATA, ); }, [agent, projectId, cwd], ); const resume = useCallback( async (a: ResumableAgent) => { setBusy(true); setError(null); try { // A profile without a usable session strategy carries no conversation id // ⇒ launches fresh ("relance à neuf"); otherwise resume the id. const convId = a.resumeSupported ? a.conversationId : undefined; await launch(a, convId ?? undefined); drop(a.agentId); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [launch, drop], ); const newConversation = useCallback( async (a: ResumableAgent) => { setBusy(true); setError(null); try { // Clear the persisted id BEFORE launching so the backend assigns a fresh // conversation (mirrors LayoutGrid's `onNewConversation`). await layout.mutateLayout(projectId, { type: "setCellConversation", target: a.nodeId, conversationId: null, }); await launch(a, undefined); drop(a.agentId); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [layout, projectId, launch, drop], ); const ignore = useCallback((agentId: string) => drop(agentId), [drop]); const resumeAll = useCallback(async () => { // Snapshot the current pending set; `resume` drains it entry by entry. const snapshot = pending; for (const a of snapshot) { // eslint-disable-next-line no-await-in-loop await resume(a); } }, [pending, resume]); const ignoreAll = useCallback(() => setPending([]), []); return { pending, error, busy, resume, newConversation, ignore, resumeAll, ignoreAll, }; }