Files
IdeaSDK/frontend/src/features/layout/LayoutGrid.tsx
Blomios eca2ba95c4 feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision
"terminal natif PTY, pas d'UI chat" (Option 1).

Domaine
- nouveaux modules conversation, mailbox, input, fileguard (ports + types)
- orchestrator/profile/events étendus (conversation par paire, FIFO)

Application / Infrastructure
- orchestrator/service + context_guard : sérialisation FIFO par agent,
  garde RW mémoire/contexte, dispatch ask/reply
- adapters in-memory conversation / mailbox / input / fileguard
- registry session + lifecycle agent durcis (1 agent = 1 session vivante)
- outils MCP idea_* alignés sur le nouveau dispatch

Frontend
- MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA,
  terminal = vue sortie inchangée
- suppression de la vue chat structurée (AgentChatView) — abandonnée
- adapter input + ports mis à jour

Divers
- .ideai/ : mémoire projet + briefs de cadrage versionnés ;
  requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA)

Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 07:33:04 +02:00

863 lines
30 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 {
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 (
<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>
);
}
const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id));
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}
visibleNodeIds={visibleNodeIds}
/>
</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;
visibleNodeIds: Set<string>;
}
function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) {
switch (node.type) {
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}
visibleNodeIds={visibleNodeIds}
/>
);
case "split":
return (
<SplitView
split={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
case "grid":
return (
<GridView
grid={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
}
}
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<string>;
}
/**
* 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);
}
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<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;
/** 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,
};
};
/** 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<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);
// ── 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,
zIndex: 2,
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);
const live = current.find((la) => la.agentId === val);
const isVisible =
live && live.nodeId !== id && visibleNodeIds.has(live.nodeId);
if (isVisible) {
setBusyNotice({
message: "Cet agent est déjà actif dans une autre cellule.",
goToNodeId: live!.nodeId,
});
return;
}
const isBackground =
live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId);
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>
{/* 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. */}
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null}
/>
</div>
{agentId != null && (
<MediatedInput
projectId={projectId}
agentId={agentId}
busy={busyMap[agentId] ?? false}
/>
)}
</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: 3,
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;
visibleNodeIds: Set<string>;
}
function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: 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}
visibleNodeIds={visibleNodeIds}
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;
visibleNodeIds: Set<string>;
}
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 (
<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}
visibleNodeIds={visibleNodeIds}
/>
</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"
);
}