/** * Recursive terminal-layout grid (L4). Renders a {@link LayoutTree} as nested * `Split` / `Grid` containers down to `Leaf` cells, each hosting a * {@link TerminalView} (L3). Provides the spreadsheet-style interactions: * * - **resize**: drag the separator between two split children → recomputes the * two adjacent weights (`resizeAdjacent`, pure) → `mutateLayout` Resize; * - **split**: per-cell buttons split a leaf into rows/columns → Split; * - **merge**: when a split has > 1 child, a cell can collapse its parent split * onto itself (spreadsheet-like cell merge) → Merge. * - **agent**: each leaf cell has a dropdown to pin an agent; persisted via * `setCellAgent` in the layout (#3). * * Pure presentation: all behaviour comes from {@link useLayout}, which speaks to * the {@link LayoutGateway} port — no `invoke()` here. Track *sizing* is the pure * {@link normalizeWeights} function, kept out of the render for testability. */ import { useEffect, useRef, useState } from "react"; import type { Agent } from "@/domain"; import type { LayoutNode } from "@/domain"; import type { ConversationDetails, LiveAgent, OpenTerminalOptions, TerminalHandle, } from "@/ports"; import { MediatedInput, ResumeConversationPopup, TerminalView, useAgentBusy, } from "@/features/terminals"; import { useGateways } from "@/app/di"; import { leaves, normalizeWeights, resizeAdjacent } from "./layout"; import { useLayout, type LayoutViewModel } from "./useLayout"; interface LayoutGridProps { /** Project whose layout to render. */ projectId: string; /** Working directory new terminals open in (the project root). */ cwd: string; /** Active layout id; when provided the grid loads/mutates this layout. */ layoutId?: string; } export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) { const vm = useLayout(projectId, layoutId); if (!vm.layout) { return (
{vm.error ? (

{vm.error}

) : (

Loading layout…

)}
); } const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id)); return (
{vm.error && (

{vm.error}

)}
); } interface NodeViewProps { node: LayoutNode; cwd: string; vm: LayoutViewModel; /** The enclosing split + this node's index in it, for the merge action. */ parentSplit: { container: string; index: number; siblings: number } | null; projectId: string; visibleNodeIds: Set; } function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) { switch (node.type) { case "leaf": return ( ); case "split": return ( ); case "grid": return ( ); } } interface LeafViewProps { id: string; session: string | null; agent: string | null; conversationId: string | null; agentWasRunning: boolean; cwd: string; vm: LayoutViewModel; parentSplit: { container: string; index: number; siblings: number } | null; projectId: string; visibleNodeIds: Set; } /** * A launch deferred by the resume popup (T7): TerminalView asked the opener to * launch (fresh open or reattach-failed fallback) for a cell that carries a * `conversationId`. We hold the open request here until the user picks * Reprendre / Nouvelle conversation, then resolve the promise with the handle. */ interface PendingResume { opts: OpenTerminalOptions; onData: (bytes: Uint8Array) => void; resolve: (handle: TerminalHandle) => void; reject: (e: unknown) => void; } /** * A transient in-cell notice. `goToNodeId`, when present, identifies the layout * leaf that already hosts the conflicting live agent; the notice then renders an * "aller à la cellule" button that focuses that cell. An agent is a singleton * ("1 agent = 1 employé"): we surface where it already lives instead of cloning. */ interface CellNotice { message: string; goToNodeId?: string; } /** * Focuses the layout leaf with the given node id: scrolls it into view and * flashes a brief outline so the user sees where the agent already lives. Works * off the `data-node-id` attribute each {@link LeafView} renders. Best-effort — * a no-op when the node is not currently mounted (e.g. on another tab). */ function goToCell(nodeId: string): void { if (typeof document === "undefined") return; const el = document.querySelector(`[data-node-id="${nodeId}"]`); if (!el) return; // `scrollIntoView` is absent in some headless DOMs (jsdom); the highlight is // the essential affordance, so guard the scroll and still flash the outline. el.scrollIntoView?.({ block: "nearest", inline: "nearest" }); const previous = el.style.outline; el.style.outline = "2px solid var(--color-primary, #5b9bd5)"; window.setTimeout(() => { el.style.outline = previous; }, 1200); } function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) { // A cell can be closed only when it lives inside a (binary) split: closing it // collapses the parent split, keeping the *sibling*. Splits are always binary // in this model (a split wraps a leaf into a 2-child container), so the kept // child is simply the other index. Guarding on `siblings === 2` keeps the // operation correct (merge keeps exactly one child) and avoids ever dropping // the wrong terminal. The root cell (no parent split) cannot be closed. const canClose = parentSplit !== null && parentSplit.siblings === 2; const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0; const { agent: agentGateway, system } = useGateways(); // Per-agent busy map (lot F1) feeds the mediated input strip: while the agent // is processing a turn, "Envoyer" is dimmed (but the enqueue stays open) and // "Interrompre" is active. Absent key ⇒ idle. const busyMap = useAgentBusy(); // Load the project's agents for the dropdown. const [agents, setAgents] = useState([]); useEffect(() => { if (!agentGateway) return; let cancelled = false; agentGateway.listAgents(projectId).then((list) => { if (!cancelled) setAgents(list); }).catch(() => {/* ignore — dropdown stays empty */}); return () => { cancelled = true; }; }, [agentGateway, projectId]); // Load the agents currently running (and where), so the dropdown can disable an // agent already live in another cell — it cannot run in two cells at once. The // backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is // the matching UI affordance. const [liveAgents, setLiveAgents] = useState([]); const refreshLive = () => { if (!agentGateway?.listLiveAgents) return; agentGateway.listLiveAgents(projectId).then(setLiveAgents).catch(() => { /* ignore — treat as none live */ }); }; useEffect(() => { if (!agentGateway?.listLiveAgents) return; let cancelled = false; agentGateway.listLiveAgents(projectId).then((list) => { if (!cancelled) setLiveAgents(list); }).catch(() => {/* ignore */}); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [agentGateway, projectId]); // Live refresh on agent lifecycle events: a launch/exit in any cell changes // who is running where, so re-pull the live set to keep every dropdown in sync. useEffect(() => { if (!system) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if (event.type === "agentLaunched" || event.type === "agentExited") { refreshLive(); } }) .then((un) => { if (cancelled) un(); else unsubscribe = un; }); return () => { cancelled = true; unsubscribe?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [system, agentGateway, projectId]); // Build the terminal opener based on whether an agent is pinned. const agentId = agent ?? null; /** The live session for `candidate`, if any. */ const liveFor = (candidate: string): LiveAgent | undefined => liveAgents.find((la) => la.agentId === candidate); /** * Maps an error into a {@link CellNotice}. For the singleton-invariant refusal * (`AGENT_ALREADY_RUNNING`) it resolves the host cell from the live-agents set * (the source of truth exposed by R0b — PTY *and* chat) so the notice can * offer "aller à la cellule" rather than relaunch a clone. The lookup is done * against a *fresh* `listLiveAgents` call (the refusal often races the launch * on mount, before the cached `liveAgents` state has settled), falling back to * the cached set. The error message embeds the host node id too, but the * structured live-agents lookup is preferred. */ const noticeFromError = async ( err: unknown, candidate: string, ): Promise => { const message = describeNotice(err); if (!isAlreadyRunning(err)) return { message }; const fresh = agentGateway?.listLiveAgents ? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents) : liveAgents; const host = fresh.find((la) => la.agentId === candidate); return { message: "Cet agent est déjà actif dans une autre cellule.", goToNodeId: host && host.nodeId !== id ? host.nodeId : undefined, }; }; /** True when the live session is already displayed by another visible cell. */ const visibleElsewhere = (candidate: string): LiveAgent | undefined => { const live = liveFor(candidate); return live && live.nodeId !== id && visibleNodeIds.has(live.nodeId) ? live : undefined; }; /** A live session whose previous host cell no longer exists in the layout. */ const backgroundLive = (candidate: string): LiveAgent | undefined => { const live = liveFor(candidate); return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId) ? live : undefined; }; // A transient notice shown when an action is blocked by the singleton // invariant (selecting / launching an agent already live in another cell). // `goToNodeId`, when set, is the host cell of the conflicting live agent: the // notice then offers an "aller à la cellule" action that focuses that cell — // we never silently relaunch a clone (product rule "1 agent = 1 employé"). const [busyNotice, setBusyNotice] = useState(null); // ── Resume popup state (T7) ─────────────────────────────────────────────── // When an agent cell carries a persisted conversation id and its PTY session // is dead, the opener is about to relaunch in Resume mode. We intercept that // launch with a popup so the user can choose Resume vs a fresh conversation. // The reattach path (live PTY) never goes through this opener, so the popup is // naturally skipped there. const [pendingResume, setPendingResume] = useState(null); const [resumeDetails, setResumeDetails] = useState(null); // ── Cell routing (Option 1 — Terminal + MCP) ────────────────────────────── // Every agent cell renders the raw {@link TerminalView}: the human view is the // native interactive PTY (live reasoning + Échap are native CLI behaviours, // zero model parsing), and cross-model delegation flows through MCP tools, not // a structured chat view. The former `AgentChatView`/`cellKind:"chat"` routing // has been removed (F-2 cleanup); any `cellKind` field still emitted by the // backend on the wire is simply ignored. /** Performs the actual launch and persists any assigned id (T4b loop). */ const doLaunch = async ( opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, convId: string | undefined, ): Promise => { const background = backgroundLive(agentId!); if (background?.sessionId && agentGateway!.attachLiveAgent) { const attached = await agentGateway!.attachLiveAgent(projectId, agentId!, id); const sessionId = attached.sessionId ?? background.sessionId; void vm.setSession(id, sessionId); const result = await agentGateway!.reattach(sessionId, onData); refreshLive(); return result.handle; } const handle = await agentGateway! .launchAgent( projectId, agentId!, { ...opts, conversationId: convId, nodeId: id }, onData, ) .catch(async (err: unknown) => { // A neat backend refusal (`AGENT_ALREADY_RUNNING`) means the agent is a // singleton already alive in another cell: surface a clear notice with // an "aller à la cellule" action (host resolved from the live set), then // re-throw so the view layer does not treat the launch as succeeded. if (isAlreadyRunning(err)) { setBusyNotice(await noticeFromError(err, agentId!)); } throw err; }); // First launch on a fresh cell mints a conversation id: persist it on the // leaf so the next open resumes (T4b — closes the persistence loop). if (handle.assignedConversationId) { void vm.setCellConversation(id, handle.assignedConversationId); } return handle; }; const terminalOpener = agentGateway && agentId ? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise => { // No persisted conversation ⇒ fresh cell: open straight away (the launch // may assign a new id). No popup on this path. if (!conversationId) { return doLaunch(opts, onData, undefined); } // Resume case: defer the launch behind the popup. Fetch the best-effort // enriched details (last topic + tokens) to enrich it; failure or empty // ⇒ degraded mode (status only). Inspection never blocks the resume. Promise.resolve( agentGateway.inspectConversation?.(projectId, agentId, conversationId), ) .then((details) => setResumeDetails(details ?? {})) .catch(() => setResumeDetails({})); return new Promise((resolve, reject) => { setPendingResume({ opts, onData, resolve, reject }); }); } : undefined; /** "Reprendre" → launch resuming the existing conversation id. */ const onResume = () => { const p = pendingResume; if (!p) return; setPendingResume(null); setResumeDetails(null); doLaunch(p.opts, p.onData, conversationId ?? undefined).then(p.resolve, p.reject); }; /** "Nouvelle conversation" → clear the id first (Assign), then launch fresh. */ const onNewConversation = () => { const p = pendingResume; if (!p) return; setPendingResume(null); setResumeDetails(null); // Clear the persisted conversation id BEFORE launching so the backend treats // it as a fresh cell and assigns a new conversation. void vm.setCellConversation(id, null); doLaunch(p.opts, p.onData, undefined).then(p.resolve, p.reject); }; // Agent cells re-attach through the agent gateway; plain cells fall back to // the terminal gateway's reattach (handled by TerminalView's default). const reattachOpener = agentGateway && agentId ? (sessionId: string, onData: (bytes: Uint8Array) => void) => agentGateway.reattach(sessionId, onData) : undefined; return (
{/* Agent selector */} {canClose && ( )}
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their cross-model delegation happens through MCP tools, not a chat view. Re-key on the agent so the right opener is captured at mount; the persisted session drives re-attach (never a re-spawn) when navigating. Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only for the human (`agentMode`) and the {@link MediatedInput} strip is mounted **under** it — keystrokes route through IdeA, not the PTY. A **plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has no input strip. We stack terminal + strip in a column so the strip sits beneath the live output. */}
void vm.setSession(id, sid)} agentMode={agentId != null} />
{agentId != null && ( )}
{busyNotice && (
{busyNotice.message} {busyNotice.goToNodeId && ( )}
)} {pendingResume && ( )}
); } interface SplitViewProps { split: Extract["node"]; cwd: string; vm: LayoutViewModel; projectId: string; visibleNodeIds: Set; } function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) { const isRow = split.direction === "row"; const baseWeights = split.children.map((c) => c.weight); const containerRef = useRef(null); // Live drag preview: while a separator is dragged we override the rendered // sizes locally (so the split moves under the cursor) and only commit the new // weights to the backend on release (avoids a mutate round-trip per mousemove). const [dragWeights, setDragWeights] = useState(null); const sizes = normalizeWeights(dragWeights ?? baseWeights); return (
{split.children.map((child, i) => (
{i < split.children.length - 1 && ( setDragWeights(resizeAdjacent(baseWeights, i, deltaFraction)) } onDragEnd={(deltaFraction) => { const weights = resizeAdjacent(baseWeights, i, deltaFraction); setDragWeights(null); void vm.resize(split.id, weights); }} /> )}
))}
); } interface SeparatorProps { isRow: boolean; container: React.RefObject; /** Called continuously during the drag with the signed fraction of the container extent (live preview). */ onDragMove: (deltaFraction: number) => void; /** Called on release with the final fraction (commit). */ onDragEnd: (deltaFraction: number) => void; } /** * A draggable separator between two split children. It captures the pointer and * reports the dragged distance (as a fraction of the container extent) **live** * on every move so the split tracks the cursor, then once more on release so the * parent can commit the new weights. */ function Separator({ isRow, container, onDragMove, onDragEnd }: SeparatorProps) { const startRef = useRef(null); function fraction(clientPos: number): number | null { if (startRef.current === null) return null; const rect = container.current?.getBoundingClientRect(); const extent = rect ? (isRow ? rect.width : rect.height) : 0; if (extent <= 0) return null; return (clientPos - startRef.current) / extent; } function onPointerDown(e: React.PointerEvent) { e.preventDefault(); e.currentTarget.setPointerCapture(e.pointerId); startRef.current = isRow ? e.clientX : e.clientY; } function onPointerMove(e: React.PointerEvent) { if (startRef.current === null) return; const f = fraction(isRow ? e.clientX : e.clientY); if (f !== null) onDragMove(f); } function onPointerUp(e: React.PointerEvent) { const f = fraction(isRow ? e.clientX : e.clientY); startRef.current = null; if (f !== null) onDragEnd(f); } return (
); } interface GridViewProps { grid: Extract["node"]; cwd: string; vm: LayoutViewModel; projectId: string; visibleNodeIds: Set; } function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) { const cols = normalizeWeights(grid.colWeights) .map((p) => `${p}fr`) .join(" "); const rows = normalizeWeights(grid.rowWeights) .map((p) => `${p}fr`) .join(" "); return (
{grid.cells.map((cell, i) => (
))}
); } /** A stable-ish React key for a node (its id when it has one). */ function keyOf(node: LayoutNode, fallback: number): string { return node.node.id ?? String(fallback); } function describeNotice(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as { message: unknown }).message); } return String(e); } /** True when `e` is the backend singleton-invariant refusal (`GatewayError`). */ function isAlreadyRunning(e: unknown): boolean { return ( typeof e === "object" && e !== null && "code" in e && (e as { code: unknown }).code === "AGENT_ALREADY_RUNNING" ); }