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>
863 lines
30 KiB
TypeScript
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"
|
|
);
|
|
}
|