Files
IdeA/frontend/src/features/layout/LayoutGrid.tsx

1440 lines
54 KiB
TypeScript

/**
* 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 (
<div data-testid="layout-grid" style={{ width: "100%", height: "100%" }}>
{vm.error ? (
<p role="alert" style={{ color: "crimson" }}>
{vm.error}
</p>
) : (
<p>Loading layout</p>
)}
</div>
);
}
return (
<div
data-testid="layout-grid"
style={{ width: "100%", height: "100%", position: "relative" }}
>
{vm.error && (
<p role="alert" style={{ color: "crimson", margin: 0 }}>
{vm.error}
</p>
)}
<NodeView
node={vm.layout.root}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
workState={work.state}
refreshWorkState={work.refresh}
refitSignal={refitEpoch}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
</div>
);
}
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<void>;
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 (
<PluginLayoutCellView
projectId={projectId}
cell={node.node}
onStateChange={(nextState) => vm.setPluginLayoutState(node.node.id, nextState)}
onOpenPlugins={() => onOpenPluginsSettings?.()}
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
/>
);
case "leaf":
return (
<LeafView
id={node.node.id}
session={node.node.session ?? null}
agent={node.node.agent ?? null}
conversationId={node.node.conversationId ?? null}
agentWasRunning={node.node.agentWasRunning ?? false}
cwd={cwd}
vm={vm}
parentSplit={parentSplit}
projectId={projectId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
/>
);
case "split":
return (
<SplitView
split={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
);
case "grid":
return (
<GridView
grid={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
);
}
}
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<void>;
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<HTMLElement>(`[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<Agent[]>([]);
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<LiveAgent[]>([]);
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<void> {
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<CellNotice> => {
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<CellNotice | null>(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<PendingResume | null>(null);
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(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<TerminalHandle> => {
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<TerminalHandle> => {
// 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<TerminalHandle>((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 (
<div
data-testid="layout-leaf"
data-node-id={id}
style={{
position: "relative",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
border: "1px solid #2a2a2a",
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
top: 2,
// Clear the xterm viewport scrollbar (rendered flush against the right
// edge, ~15px wide). Without this offset the right-most control — the
// close button — sits *behind* the scrollbar and is hard to click.
right: 16,
// Above every banner/veil so a cell in an error state stays manageable
// (ticket #48).
zIndex: CELL_Z.controls,
display: "flex",
gap: 2,
alignItems: "center",
}}
>
{/* Agent selector */}
<select
aria-label={`agent selector ${id}`}
value={agentId ?? ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
setBusyNotice(null);
void vm.setCellAgent(id, null);
return;
}
void (async () => {
const current = agentGateway?.listLiveAgents
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
: liveAgents;
setLiveAgents(current);
// Visibility is derived from the CURRENT layout, not the live
// registry's stale last-known nodeId (ticket #56): the agent is
// "visible ailleurs" only while a visible cell still pins it.
const elsewhere = visibleElsewhere(val);
if (elsewhere) {
setBusyNotice({
message: "Cet agent est déjà actif dans une autre cellule.",
goToNodeId: elsewhere.nodeId,
});
return;
}
const live = current.find((la) => la.agentId === val);
const isBackground =
live && live.kind === "pty" && live.nodeId !== id;
if (isBackground) {
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
setBusyNotice({
message: "Session active introuvable pour cet agent.",
});
return;
}
setBusyNotice(null);
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
refreshLive();
return;
}
setBusyNotice(null);
await vm.setCellAgent(id, val);
})().catch(async (err: unknown) =>
setBusyNotice(await noticeFromError(err, val)),
);
}}
style={{
fontSize: 11,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
padding: "1px 2px",
maxWidth: 100,
}}
>
<option value="">Plain</option>
{agents.map((a) => {
const elsewhere = visibleElsewhere(a.id);
const background = backgroundLive(a.id);
return (
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
{a.name}
{elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
</option>
);
})}
</select>
<button
type="button"
title="Split into columns"
aria-label={`split ${id} columns`}
onClick={() => void vm.split(id, "row")}
>
</button>
<button
type="button"
title="Split into rows"
aria-label={`split ${id} rows`}
onClick={() => void vm.split(id, "column")}
>
</button>
{canClose && (
<button
type="button"
title="Close this terminal"
aria-label={`close ${id}`}
onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
>
</button>
)}
</div>
{agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && (
<div
role="status"
aria-live="polite"
style={{
position: "absolute",
top: 30,
left: 4,
zIndex: CELL_Z.banner,
display: "flex",
maxWidth: "calc(100% - 8px)",
alignItems: "center",
gap: 4,
overflow: "hidden",
}}
>
{busyByWorkState && (
<span
title={delegatedTicket?.requesterLabel ?? activeTicket?.taskPreview}
style={{
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
border: "1px solid var(--color-warning, #d49b3a)",
borderRadius: 3,
background: "rgba(212, 155, 58, 0.16)",
color: "var(--color-warning, #d49b3a)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
{delegatedTicket
? `Busy · ${delegatedTicket.requesterLabel}`
: "Busy"}
</span>
)}
{!busyByWorkState && inboxDepth > 0 && (
<span
title={`${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`}
style={{
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
border: "1px solid var(--color-primary, #5b9bd5)",
borderRadius: 3,
background: "rgba(91, 155, 213, 0.14)",
color: "var(--color-primary, #5b9bd5)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
Queued · {inboxDepth}
</span>
)}
{busyByWorkState && inboxDepth > 0 && (
<span
title={`${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`}
style={{
flexShrink: 0,
border: "1px solid var(--color-primary, #5b9bd5)",
borderRadius: 3,
background: "rgba(91, 155, 213, 0.14)",
color: "var(--color-primary, #5b9bd5)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
Queued {inboxDepth}
</span>
)}
{completedBackgroundTasks.length > 0 && (
<span
title={`${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`}
style={{
flexShrink: 0,
border: "1px solid var(--color-success, #57b36a)",
borderRadius: 3,
background: "rgba(87, 179, 106, 0.14)",
color: "var(--color-success, #57b36a)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
Task done {completedBackgroundTasks.length}
</span>
)}
{busyByWorkState && (
<button
type="button"
aria-label={`cancel current turn ${id}`}
title="Cancel current turn"
onClick={() => void interruptCurrentTurn()}
disabled={!input}
style={{
flexShrink: 0,
border: "1px solid var(--color-danger, #d45a5a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-danger, #d45a5a)",
cursor: input ? "pointer" : "default",
fontSize: 11,
padding: "1px 6px",
}}
>
Cancel
</button>
)}
{historyConversationId && onOpenConversation && (
<button
type="button"
aria-label={`open cell conversation ${id}`}
title="Open conversation history"
onClick={() => onOpenConversation(historyConversationId)}
style={{
flexShrink: 0,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
cursor: "pointer",
fontSize: 11,
padding: "1px 6px",
}}
>
History
</button>
)}
</div>
)}
{/* 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. */}
<div
style={{
position: "relative",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
<TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => 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) && (
<div
data-testid="write-portal-overlay"
role="status"
aria-live="polite"
style={{
position: "absolute",
inset: 0,
zIndex: CELL_Z.veil,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.45)",
color: "var(--color-content, #e0e0e0)",
fontSize: 13,
pointerEvents: "none",
// veil tier — below the cell controls (ticket #48).
userSelect: "none",
}}
>
<span
style={{
background: "var(--color-surface, #1e1e1e)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "6px 12px",
}}
>
Un agent est en train de parler
</span>
</div>
)}
{/* 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 && (
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
)}
{/* 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 && (
<div
data-testid="model-server-overlay"
role="status"
aria-live="polite"
aria-label="model server launch"
style={{
position: "absolute",
inset: 0,
zIndex: CELL_Z.veil,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.55)",
color: "var(--color-content, #e0e0e0)",
fontSize: 13,
pointerEvents: "none",
userSelect: "none",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
minWidth: 0,
maxWidth: "80%",
background: "var(--color-surface, #1e1e1e)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "8px 14px",
}}
>
<span>{modelServerOverlay}</span>
{modelServerDownload && (
<>
{/* Determinate bar when the total is known; an indeterminate
track (no aria-valuenow → ARIA indeterminate) otherwise. */}
<div
data-testid="model-server-progress"
role="progressbar"
aria-label="progression du téléchargement"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
modelServerDownload.percent != null
? Math.round(modelServerDownload.percent)
: undefined
}
style={{
position: "relative",
width: 220,
maxWidth: "100%",
height: 6,
borderRadius: 3,
overflow: "hidden",
background: "var(--color-raised, #2a2a2a)",
}}
>
<div
style={
modelServerDownload.percent != null
? {
height: "100%",
width: `${modelServerDownload.percent}%`,
background: "var(--color-primary, #5b9bd5)",
transition: "width 120ms linear",
}
: {
// Indeterminate: a sliding sliver.
position: "absolute",
top: 0,
bottom: 0,
width: "40%",
background: "var(--color-primary, #5b9bd5)",
animation:
"model-server-indeterminate 1.1s ease-in-out infinite",
}
}
/>
</div>
{(modelServerDownload.percent != null ||
modelServerDownload.bytesLabel) && (
<span
data-testid="model-server-progress-label"
style={{ fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}
>
{modelServerDownload.percent != null
? `${Math.round(modelServerDownload.percent)} %`
: ""}
{modelServerDownload.percent != null &&
modelServerDownload.bytesLabel
? " · "
: ""}
{modelServerDownload.bytesLabel ?? ""}
</span>
)}
{modelServerDownload.source && (
<span
data-testid="model-server-source"
style={{
fontSize: 11,
color: "var(--color-content-muted, #9a9a9a)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={modelServerDownload.source}
>
{modelServerDownload.source}
</span>
)}
</>
)}
</div>
</div>
)}
</div>
{busyNotice && (
<div
role="status"
style={{
position: "absolute",
bottom: 4,
left: 4,
right: 4,
margin: 0,
fontSize: 11,
color: "var(--color-content-muted, #9a9a9a)",
background: "var(--color-surface, #1e1e1e)",
padding: "2px 6px",
borderRadius: 3,
zIndex: CELL_Z.banner,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 6,
}}
>
<span style={{ minWidth: 0 }}>{busyNotice.message}</span>
{busyNotice.goToNodeId && (
<button
type="button"
aria-label="aller à la cellule"
onClick={() => {
goToCell(busyNotice.goToNodeId!);
setBusyNotice(null);
}}
style={{
flexShrink: 0,
fontSize: 11,
cursor: "pointer",
background: "transparent",
color: "var(--color-primary, #5b9bd5)",
border: "1px solid var(--color-primary, #5b9bd5)",
borderRadius: 3,
padding: "1px 6px",
}}
>
Aller à la cellule
</button>
)}
</div>
)}
{pendingResume && (
<ResumeConversationPopup
agentWasRunning={agentWasRunning}
details={resumeDetails}
onResume={onResume}
onNewConversation={onNewConversation}
/>
)}
</div>
);
}
interface SplitViewProps {
split: Extract<LayoutNode, { type: "split" }>["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
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<HTMLDivElement | null>(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<number[] | null>(null);
const sizes = normalizeWeights(dragWeights ?? baseWeights);
return (
<div
ref={containerRef}
data-testid="layout-split"
data-direction={split.direction}
style={{
display: "flex",
flexDirection: isRow ? "row" : "column",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{split.children.map((child, i) => (
<div key={keyOf(child.node, i)} style={{ display: "contents" }}>
<div
style={{
flexBasis: `${sizes[i]}%`,
flexGrow: 0,
flexShrink: 0,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<NodeView
node={child.node}
cwd={cwd}
vm={vm}
projectId={projectId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
parentSplit={{
container: split.id,
index: i,
siblings: split.children.length,
}}
/>
</div>
{i < split.children.length - 1 && (
<Separator
isRow={isRow}
container={containerRef}
onDragMove={(deltaFraction) =>
setDragWeights(resizeAdjacent(baseWeights, i, deltaFraction))
}
onDragEnd={(deltaFraction) => {
const weights = resizeAdjacent(baseWeights, i, deltaFraction);
setDragWeights(null);
void vm.resize(split.id, weights);
}}
/>
)}
</div>
))}
</div>
);
}
interface SeparatorProps {
isRow: boolean;
container: React.RefObject<HTMLDivElement | null>;
/** 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<number | null>(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<HTMLDivElement>) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
startRef.current = isRow ? e.clientX : e.clientY;
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (startRef.current === null) return;
const f = fraction(isRow ? e.clientX : e.clientY);
if (f !== null) onDragMove(f);
}
function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
const f = fraction(isRow ? e.clientX : e.clientY);
startRef.current = null;
if (f !== null) onDragEnd(f);
}
return (
<div
role="separator"
aria-orientation={isRow ? "vertical" : "horizontal"}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{
flex: "0 0 6px",
cursor: isRow ? "col-resize" : "row-resize",
background: "#3a3a3a",
touchAction: "none",
}}
/>
);
}
interface GridViewProps {
grid: Extract<LayoutNode, { type: "grid" }>["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
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 (
<div
data-testid="layout-grid-container"
style={{
display: "grid",
gridTemplateColumns: cols,
gridTemplateRows: rows,
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{grid.cells.map((cell, i) => (
<div
key={keyOf(cell.node, i)}
style={{
gridColumn: `${cell.col + 1} / span ${cell.colSpan}`,
gridRow: `${cell.row + 1} / span ${cell.rowSpan}`,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<NodeView
node={cell.node}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
</div>
))}
</div>
);
}
/** 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"
);
}