/** * 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 { ProjectWorkState } from "@/domain"; import type { ConversationDetails, LiveAgent, OpenTerminalOptions, TerminalHandle, } from "@/ports"; import { ResumeConversationPopup, TerminalView, useWritePortal, } from "@/features/terminals"; import { useGateways } from "@/app/di"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { TargetAnnouncementsOverlay, useTargetAnnouncements, } from "@/features/announcements"; import { PluginLayoutCellView } from "@/features/plugins"; import { modelServerOverlayText, describeModelServerDownload, useModelServerLaunchState, } from "@/features/agents"; import { leaves, normalizeWeights, resizeAdjacent } from "./layout"; import { useLayout, type LayoutViewModel } from "./useLayout"; /** * Full-cell veil exclusion (ticket #4, Architect arbitrage): the write-portal * veil (delegation injection into the PTY) and the F3 target overlay (another * agent is talking to this one) share the same relative container and both cover * `inset:0`. On the inter-agent injection path the write-portal `overlay` flag and * the target's `busy` state rise *together*, which would stack two veils with * near-duplicate banners. * * Rule: exactly one veil at a time, F3 has priority. So the write-portal veil is * shown only for a pinned agent, when the portal asks for it, **and** the target * is not busy-active (F3 owns the cell then). When busy is not yet set — the brief * window before the mediator marks it — the write-portal veil is the fallback. * * Purely visual: this gates rendering only. The keystroke suspension during * injection (`portal.isSuspended()` in `TerminalView`) is untouched. */ export function shouldShowWritePortalVeil( agentPinned: boolean, writePortalOverlay: boolean, busyActive: boolean, ): boolean { return agentPinned && writePortalOverlay && !busyActive; } 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; /** Opens the read-only canonical transcript for a conversation. */ onOpenConversation?: (conversationId: string) => void; /** * Navigates to `Paramètres > Plugins` (#43, F4) — the "Ouvrir Plugins" action * of a plugin layout's unavailable fallback. Threaded down to * `PluginLayoutCellView` the same way `onOpenConversation` is. */ onOpenPluginsSettings?: () => void; } export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation, onOpenPluginsSettings, }: LayoutGridProps) { const vm = useLayout(projectId, layoutId); const work = useProjectWorkState(projectId); const [refitEpoch, setRefitEpoch] = useState(0); useEffect(() => { setRefitEpoch((epoch) => epoch + 1); }, [projectId, layoutId, cwd, vm.layoutVersion]); if (!vm.layout) { return (
{vm.error ? (

{vm.error}

) : (

Loading layout…

)}
); } 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; workState: ProjectWorkState | null; refreshWorkState: () => Promise; refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } function NodeView({ node, cwd, vm, parentSplit, projectId, workState, refreshWorkState, refitSignal, onOpenConversation, onOpenPluginsSettings, }: NodeViewProps) { switch (node.type) { case "customPluginLayout": // A true top-level `LayoutNode` variant (#43, F4, carnet v2 §3) — a // plugin layout occupies a slot in the tree at the same level as a // terminal leaf, split or grid. Rendered separately from `LeafView` // (which owns a lot of terminal-only concerns — write-portal, // model-server overlay, agent dropdown — none of which apply here). return ( vm.setPluginLayoutState(node.node.id, nextState)} onOpenPlugins={() => onOpenPluginsSettings?.()} onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)} /> ); 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; workState: ProjectWorkState | null; refreshWorkState: () => Promise; refitSignal: number; onOpenConversation?: (conversationId: string) => void; } /** * 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); } /** * Local z-index scale for a single cell (ticket #48). Intra-cell layering only — * unrelated to the global {@link zIndex} scale used for app-level surfaces. * * The cell controls (agent selector, split, close) must stay reachable even when * a non-modal banner is shown, otherwise a cell stuck in an error state (e.g. an * agent launch failure) can no longer be managed. Full-cell veils (write-portal * injection, F3 busy) deliberately cover the terminal and sit above the banners, * but they are `pointer-events:none` and stay *below* the controls so the user * can always re-pick an agent / split / close the cell. * * terminal 0 (implicit — the xterm surface) * status/error 3 (in-cell status strip, launch-error banner, notices) * full-cell veils 4 (write-portal + F3 overlays; mutually exclusive) * cell controls 5 (always on top and clickable) */ const CELL_Z = { banner: 3, veil: 4, controls: 5, } as const; function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, workState, refreshWorkState, refitSignal, onOpenConversation, }: 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, input, system } = useGateways(); // The single write-portal of this cell (ARCHITECTURE §20). It owns the human // line counter, the local delegation FIFO, the handshake (b→e) and the overlay // shown while a delegation is being injected. For a plain (agent-less) cell // `agent` is null: the portal stays inert (no subscription, no overlay) and is // simply not passed to the terminal. const { portal, overlay } = useWritePortal(projectId, agent ?? null); // 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; // F3 lifecycle state (busy) for this cell's agent — shared here so the // write-portal veil can be mutually excluded with the F3 overlay (exactly one // full-cell veil at a time, F3 first). Same source the overlay itself reads. const { active: busyActive } = useTargetAnnouncements(agentId ?? ""); // Ticket #54 — local model-server launch state for this cell's pinned agent. // Correlated `agentId → profileId → opencode.localModelServerId → status` via // the shared hook (same reduction as `useAgents`). While the bound server is // still preparing (downloading a model / starting / probing), a full-cell // overlay replaces the timeout/error the empty terminal used to show. It has // the HIGHEST veil priority: nothing else in the cell is usable until the // server is ready, so it suppresses the write-portal and F3 veils. const { statusForAgent } = useModelServerLaunchState(projectId); const pinnedAgent = agentId ? agents.find((a) => a.id === agentId) : undefined; const modelServerStatus = statusForAgent(pinnedAgent); const modelServerOverlay = modelServerOverlayText(modelServerStatus); // F2 — download progress (bar/%/bytes/source) when the status carries it; null // for every non-`downloading` state, so `starting`/`probing` show just the // title. An indeterminate download (unknown total) yields `percent: null`. const modelServerDownload = describeModelServerDownload(modelServerStatus); const agentWork = agentId ? workState?.agents.find((row) => row.agentId === agentId) : undefined; const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress"); const queuedTickets = agentWork?.tickets.filter((ticket) => ticket.status === "queued") ?? []; const inboxDepth = Math.max( agentWork?.inboxDepth ?? 0, agentWork?.inbox?.length ?? 0, queuedTickets.length, ); const completedBackgroundTasks = agentWork?.backgroundTasks?.filter((task) => task.status === "completed" || task.status === "failed" || task.status === "cancelled", ) ?? []; const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; const historyConversationId = activeTicket?.conversationId ?? conversationId ?? null; async function interruptCurrentTurn(): Promise { if (!agentId || !input) return; try { await input.interrupt(projectId, agentId); await refreshWorkState(); } catch (err) { setBusyNotice({ message: describeNotice(err) }); } } /** 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, }; }; /** * The cell that still displays `candidate` in the CURRENT layout, if any: a * visible leaf other than this one whose pinned agent IS `candidate`. * * Ticket #56 — derived from the layout, NOT from `liveAgents[].nodeId`. The * live registry keeps pointing at an agent's LAST host cell even after that * cell swapped to another agent (or none): reading `live.nodeId` as "displayed * here" wrongly disabled the agent everywhere else, although it is no longer * shown anywhere. An agent truly shown in a visible cell stays non-selectable * elsewhere (singleton display invariant); a stale live association does not. */ const visibleElsewhere = (candidate: string): { nodeId: string } | undefined => { if (!vm.layout) return undefined; const host = leaves(vm.layout).find( (leaf) => leaf.id !== id && (leaf.agent ?? null) === candidate, ); return host ? { nodeId: host.id } : undefined; }; /** * A live PTY session for `candidate` that this cell can re-attach without * re-spawning: it exists, is not recorded on THIS cell (`nodeId === id`, so a * fresh self-launch never triggers a re-attach loop), and is not currently * displayed by another visible cell ({@link visibleElsewhere} — the singleton * double-display case). A session whose former host cell now shows a different * agent (or none) is a background session → selectable here (ticket #56). */ const backgroundLive = (candidate: string): LiveAgent | undefined => { const live = liveFor(candidate); if (!live || live.kind !== "pty" || live.nodeId === id) return undefined; return visibleElsewhere(candidate) ? undefined : live; }; // 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); // ── Live re-attach on background wake (inter-agent delegation) ───────────── // Bumped to re-key the TerminalView and force a re-attach to the cell's // pinned agent when that agent becomes live in the *background* AFTER this // cell has mounted — the classic case being an inter-agent delegation: the // orchestrator wakes a cold target with `node_id = None` (background) and // publishes `AgentLaunched`. Without this, the cell keeps showing its // previous (often dead) session and only re-subscribes to the live output // once the user toggles the view (Plain↔agent), which remounts the terminal. // We do that re-attach declaratively instead, so the delegated turn is // visible live with no manual toggle. The counter is bumped ONLY here (the // background-wake path), never on a normal fresh launch — so ordinary mounts // are untouched and never thrash. const [attachGen, setAttachGen] = useState(0); useEffect(() => { if (!agentId || !agentGateway?.attachLiveAgent) return; // Mid resume decision: let the popup own the (re)launch — don't race it. if (pendingResume) return; // Only act when the pinned agent is live in ANOTHER (non-visible) cell and // this cell is not already bound to that session. `backgroundLive` is the // same guard `doLaunch` and the dropdown use; crucially it ignores a session // hosted by THIS cell (nodeId === id), so a fresh self-launch never triggers // a re-attach loop. const bg = backgroundLive(agentId); if (!bg?.sessionId || bg.sessionId === session) return; let cancelled = false; void (async () => { const attached = await agentGateway.attachLiveAgent!(projectId, agentId, id); if (cancelled) return; await vm.attachLiveAgentToCell(id, agentId, attached.sessionId ?? bg.sessionId); if (!cancelled) setAttachGen((g) => g + 1); })().catch(() => { /* ignore — the view toggle remains the manual fallback */ }); return () => { cancelled = true; }; // `liveAgents` is the trigger (refreshed by the agentLaunched event below); // `session` guards against re-firing once attached. // eslint-disable-next-line react-hooks/exhaustive-deps }, [liveAgents, agentId, session]); // ── 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 && ( )}
{agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && (
{busyByWorkState && ( {delegatedTicket ? `Busy · ${delegatedTicket.requesterLabel}` : "Busy"} )} {!busyByWorkState && inboxDepth > 0 && ( Queued · {inboxDepth} )} {busyByWorkState && inboxDepth > 0 && ( Queued {inboxDepth} )} {completedBackgroundTasks.length > 0 && ( Task done {completedBackgroundTasks.length} )} {busyByWorkState && ( )} {historyConversationId && onOpenConversation && ( )}
)} {/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the raw xterm {@link TerminalView}. Agent cells are **native terminals** (ARCHITECTURE §20): every human keystroke (Enter included) reaches the PTY. There is no mediated-input strip; cross-model delegation flows through the write-portal (which injects at a clean line boundary) and MCP tools. 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. The agent cell passes its {@link WritePortal} so keystroke counting + injection suspension are wired; a plain cell passes none. */}
void vm.setSession(id, sid)} agentMode={agentId != null} portal={agentId != null ? portal : undefined} refitSignal={refitSignal} /> {/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation is being injected into the agent's PTY, a grey veil with a centred message sits above the terminal. Only ever shown for an agent cell — and never together with the F3 overlay (exactly one veil, F3 first). */} {!modelServerOverlay && shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
Un agent est en train de parler…
)} {/* F3 (ticket #4): while another agent is talking to THIS agent (target), an availability veil showing its live réflexions. Driven by the target's busy state (agentBusyChanged + read-model hydration), never by the raw PTY — it retracts at idle even when a turn ends without a completion event. Self-guards on `active` (busy) and renders null otherwise. */} {!modelServerOverlay && agentId != null && ( )} {/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay (suppresses the write-portal + F3 veils above): while the pinned agent's bound local server is downloading its model / starting / probing, the empty terminal is covered rather than left to time out. Retracts at `ready`/`failed` (the predicate returns null then). */} {modelServerOverlay && (
{modelServerOverlay} {modelServerDownload && ( <> {/* Determinate bar when the total is known; an indeterminate track (no aria-valuenow → ARIA indeterminate) otherwise. */}
{(modelServerDownload.percent != null || modelServerDownload.bytesLabel) && ( {modelServerDownload.percent != null ? `${Math.round(modelServerDownload.percent)} %` : ""} {modelServerDownload.percent != null && modelServerDownload.bytesLabel ? " · " : ""} {modelServerDownload.bytesLabel ?? ""} )} {modelServerDownload.source && ( {modelServerDownload.source} )} )}
)}
{busyNotice && (
{busyNotice.message} {busyNotice.goToNodeId && ( )}
)} {pendingResume && ( )}
); } interface SplitViewProps { split: Extract["node"]; cwd: string; vm: LayoutViewModel; projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } function SplitView({ split, cwd, vm, projectId, workState, refreshWorkState, refitSignal, onOpenConversation, onOpenPluginsSettings, }: 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; workState: ProjectWorkState | null; refreshWorkState: () => Promise; refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } function GridView({ grid, cwd, vm, projectId, workState, refreshWorkState, refitSignal, onOpenConversation, onOpenPluginsSettings, }: 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" ); }