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>
This commit is contained in:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

@ -1,26 +1,27 @@
/**
* D5 — `LayoutGrid` cell-kind routing (§17.6): an agent cell whose launch reports
* `cellKind:"chat"` must render an {@link AgentChatView}; every other cell (plain
* or a `pty` agent) keeps the unchanged {@link TerminalView}. Wired through the
* real {@link DIProvider} with the in-memory mocks, exactly like
* `LayoutGrid.test.tsx`.
* F-1 — `LayoutGrid` cell routing (Option 1, Terminal + MCP): **every** agent
* cell renders the raw {@link TerminalView}; no structured chat view is ever
* mounted. This replaces the former §17.6 `cellKind:"chat"` routing — the human
* view is now the native interactive PTY, and cross-model delegation flows
* through MCP tools, not a chat view. Wired through the real {@link DIProvider}
* with the in-memory mocks, exactly like `LayoutGrid.test.tsx`.
*
* Routing to chat is *derived from the launch* (the leaf starts as a terminal
* and swaps once the handle's `cellKind` arrives). Under jsdom xterm's `open`
* may bail, so the terminal opener that triggers the launch might not run; these
* tests therefore assert the *reachable* outcomes without depending on xterm:
* - a plain cell renders the terminal view and NEVER a chat view (hard);
* - when the launch does run, a chat agent ends up rendering the chat view.
* The decisive case: an agent cell always renders the terminal and never swaps
* to a chat view (the structured chat surface was removed in the F-2 cleanup).
*
* Under jsdom xterm's `open` may bail, so the opener that triggers the launch
* might not run; we therefore stub xterm (as in the original test) so the launch
* does fire and we genuinely exercise the post-launch routing — which must stay
* on the terminal regardless of the reported kind.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor as rtlWaitFor } from "@testing-library/react";
// Make xterm "wire up" under jsdom: the real `Terminal.open` throws without a
// layout engine, which makes `TerminalView`'s effect bail before it ever calls
// the opener — so the launch (and thus the chat-routing swap) would never fire.
// A minimal stub lets `term.open` succeed, the opener run, and the leaf derive
// its `cellKind` from the launch exactly as in the app. We do NOT stub the chat
// view or the routing — only xterm, the headless-only obstacle.
// the opener — so the launch would never fire. A minimal stub lets `term.open`
// succeed and the opener run, so the launch (and any routing it could trigger)
// is genuinely exercised. We do NOT stub the routing — only xterm.
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
@ -64,16 +65,17 @@ import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
/** Seeds an agent in the gateway and pins it onto the (single) leaf cell. */
async function seedPinnedAgent(opts: {
chat: boolean;
}): Promise<{ gateways: Gateways; layout: MockLayoutGateway; agentGateway: MockAgentGateway }> {
async function seedPinnedAgent(): Promise<{
gateways: Gateways;
layout: MockLayoutGateway;
agentGateway: MockAgentGateway;
}> {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
if (opts.chat) agentGateway._setChatAgents([agent.id]);
// Pin the agent onto the single leaf.
const tree = await layout.loadLayout("p1");
@ -92,7 +94,7 @@ function renderGrid(gateways: Gateways) {
);
}
describe("LayoutGrid cell-kind routing (§17.6)", () => {
describe("LayoutGrid cell routing (F-1, Terminal + MCP)", () => {
it("a plain (agent-less) cell renders the terminal view, never a chat view", async () => {
const layout = new MockLayoutGateway();
const gateways = {
@ -105,34 +107,49 @@ describe("LayoutGrid cell-kind routing (§17.6)", () => {
renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// Non-regression: the plain path is the terminal, and there is no chat view.
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a pty agent cell renders the terminal view, never a chat view", async () => {
const { gateways } = await seedPinnedAgent({ chat: false });
const { gateways } = await seedPinnedAgent();
renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// A pty agent keeps the unchanged terminal path; the chat view never appears.
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a chat agent cell routes to the chat view once its launch reports cellKind 'chat'", async () => {
const { gateways } = await seedPinnedAgent({ chat: true });
it("re-mounting a known agent cell (persisted session) repaints as a terminal, never chat", async () => {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
// Seed a persisted session on the leaf — the pre-F-1 path would have re-mounted
// such a known agent cell as a chat view; now it must always be a terminal.
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id });
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "running-session",
});
const gateways = { layout, agent: agentGateway, terminal, system } as unknown as Gateways;
// First mount.
const { unmount } = renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
unmount();
// Re-mount (as after a tab/layout navigation): the known agent cell with its
// persisted session must repaint as a terminal — never a chat view.
renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// The leaf starts as a terminal and swaps to the chat view once the derived
// `cellKind` ("chat") arrives from the launch (xterm is stubbed so the
// opener actually runs). This is the real §17.6 routing.
await rtlWaitFor(() =>
expect(screen.queryByTestId("agent-chat-view")).toBeTruthy(),
);
// Once routed to chat, the terminal view is gone for that cell.
expect(screen.queryByTestId("terminal-view")).toBeNull();
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
});

View File

@ -0,0 +1,239 @@
/**
* F2 — mediated input for agent cells (cadrage §4.2/§7).
*
* Two contracts are exercised here, with the real {@link DIProvider} and the
* in-memory mocks:
*
* 1. **Keystrokes are mediated in agent mode.** A cell that pins an agent puts
* `TerminalView` in `agentMode`: human keystrokes (`term.onData`) MUST NOT be
* written to the PTY (input flows through {@link MediatedInput}). A plain
* (agent-less) cell keeps the raw-shell behaviour (keystrokes → PTY).
* 2. **The PTY *output* is always painted** (both modes) — xterm stays the raw
* output view, unchanged by F2.
* 3. **`MediatedInput` is mounted under the terminal** for an agent cell, and
* **never** an `AgentChatView` (the structured chat surface stays removed).
*
* xterm is stubbed (as in the sibling chat test) so `term.open` succeeds under
* jsdom and the opener/keystroke wiring genuinely runs. The stub captures the
* `onData` keystroke handler so the test can fire a keystroke and assert whether
* it reached the PTY handle (`handle.write`).
*/
import { beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
// Capture the keystroke handler xterm's `onData` is given, and the output sink
// `term.write` so we can assert PTY output is painted. A single shared slot is
// enough: each test renders exactly one terminal cell.
const xtermState: {
keyHandler: ((data: string) => void) | null;
writes: Uint8Array[];
} = { keyHandler: null, writes: [] };
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData(cb: (data: string) => void) {
xtermState.keyHandler = cb;
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write(bytes: Uint8Array) {
xtermState.writes.push(bytes);
}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { Gateways, TerminalHandle } from "@/ports";
import {
MockAgentGateway,
MockInputGateway,
MockLayoutGateway,
MockSystemGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
/** Fresh mock set (input gateway included so MediatedInput is fully wired). */
function makeGateways(agentGateway: MockAgentGateway): Gateways {
return {
layout: new MockLayoutGateway(),
agent: agentGateway,
terminal: new MockTerminalGateway(),
system: new MockSystemGateway(),
input: new MockInputGateway(),
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
/**
* Spy on the handle the agent gateway hands out, so we can detect whether a
* keystroke reached the PTY (`handle.write`). Returns the spy.
*/
function spyAgentWrite(gw: MockAgentGateway): ReturnType<typeof vi.fn> {
const writeSpy = vi.fn();
const original = gw.launchAgent.bind(gw);
vi.spyOn(gw, "launchAgent").mockImplementation(async (p, a, o, onData) => {
const handle: TerminalHandle = await original(p, a, o, onData);
return { ...handle, write: writeSpy };
});
return writeSpy;
}
/** Spy on the terminal gateway handle write (plain-cell PTY path). */
function spyTerminalWrite(gw: MockTerminalGateway): ReturnType<typeof vi.fn> {
const writeSpy = vi.fn();
const original = gw.openTerminal.bind(gw);
vi.spyOn(gw, "openTerminal").mockImplementation(async (o, onData) => {
const handle: TerminalHandle = await original(o, onData);
return { ...handle, write: writeSpy };
});
return writeSpy;
}
beforeEach(() => {
xtermState.keyHandler = null;
xtermState.writes = [];
});
describe("LayoutGrid F2 — mediated input for agent cells", () => {
it("an AGENT cell does NOT write keystrokes to the PTY (input is mediated)", async () => {
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
// Pin the agent onto the leaf in this run's layout gateway.
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
const writeSpy = spyAgentWrite(agentGateway);
renderGrid(gateways);
// Wait for the launch to settle (the opener ran and adopted a handle).
await waitFor(() => expect(agentGateway.launchAgent).toHaveBeenCalled());
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
// Simulate a human keystroke into xterm.
xtermState.keyHandler!("h");
xtermState.keyHandler!("i");
// In agent mode the keystroke must be swallowed — never forwarded to the PTY.
expect(writeSpy).not.toHaveBeenCalled();
});
it("a PLAIN cell still writes keystrokes to the PTY (raw shell, unchanged)", async () => {
const agentGateway = new MockAgentGateway();
const gateways = makeGateways(agentGateway);
const writeSpy = spyTerminalWrite(gateways.terminal as MockTerminalGateway);
renderGrid(gateways);
await waitFor(() =>
expect((gateways.terminal as MockTerminalGateway).openTerminal).toHaveBeenCalled(),
);
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
xtermState.keyHandler!("l");
xtermState.keyHandler!("s");
// Plain shell: keystrokes flow straight to the PTY (current behaviour).
await waitFor(() => expect(writeSpy).toHaveBeenCalled());
});
it("paints PTY output in BOTH modes (xterm output view unchanged)", async () => {
// Agent cell: the mock greets on open → output must reach term.write.
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
// The mock agent greets on open; that output must be painted by xterm even
// though keystroke input is mediated.
await waitFor(() => expect(xtermState.writes.length).toBeGreaterThan(0));
});
it("mounts MediatedInput under an agent cell, never an AgentChatView", async () => {
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.getByTestId("mediated-input")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a PLAIN cell has NO mediated input strip", async () => {
const agentGateway = new MockAgentGateway();
const gateways = makeGateways(agentGateway);
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("mediated-input")).toBeNull();
});
});

View File

@ -18,7 +18,7 @@
import { useEffect, useRef, useState } from "react";
import type { Agent, CellKind } from "@/domain";
import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain";
import type {
ConversationDetails,
@ -26,8 +26,12 @@ import type {
OpenTerminalOptions,
TerminalHandle,
} from "@/ports";
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
import { AgentChatView } from "@/features/chat";
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";
@ -144,17 +148,6 @@ interface LeafViewProps {
visibleNodeIds: Set<string>;
}
/**
* Remembers each live session's {@link CellKind} (`"pty"` | `"chat"`) by session
* id, so that after a navigation/layout change re-mounts a leaf — when only the
* persisted session id is known and a re-attach (not a re-launch) is due — the
* grid can still route to the right view (`AgentChatView` vs `TerminalView`)
* without re-spawning. Populated at launch from the handle's derived `cellKind`
* (ARCHITECTURE §17.6). Module-scoped (survives unmount); a closed session's
* id is never reused, so stale entries are harmless.
*/
const cellKindBySession = new Map<string, CellKind>();
/**
* 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
@ -210,6 +203,11 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
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(() => {
@ -330,35 +328,13 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);
// ── Cell kind routing (§17.6) ─────────────────────────────────────────────
// Which view this agent cell renders: a structured chat (`AgentChatView`) or a
// raw terminal (`TerminalView`). `null` until known. It is derived from the
// launched session's `cellKind`; on a re-mount with a persisted session we
// recover it from the module-level cache so a chat cell repaints as chat
// (re-attach, not re-spawn). Plain (agent-less) cells are always terminals.
const [cellKind, setCellKind] = useState<CellKind | null>(() =>
agent && session ? (cellKindBySession.get(session) ?? null) : null,
);
// The chat session id once a chat launch resolved — passed to `AgentChatView`
// immediately on the view swap, independent of the layout-persistence round
// trip (so the chat re-attaches to the just-spawned session, never re-spawns).
const [chatSessionId, setChatSessionId] = useState<string | null>(
agent && session && cellKindBySession.get(session) === "chat"
? session
: null,
);
// Reset the derived routing when the pinned agent changes (a different agent —
// or unpinning to a plain cell — may be a different cell kind). We re-seed from
// the persisted session's cached kind so a remount of a known chat cell stays a
// chat (re-attach, not re-spawn); otherwise routing falls back to a terminal
// until the next launch reports the kind.
useEffect(() => {
const known = agent && session ? cellKindBySession.get(session) : undefined;
setCellKind(known ?? null);
setChatSessionId(known === "chat" ? session : null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent]);
// ── 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 (
@ -398,12 +374,6 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
if (handle.assignedConversationId) {
void vm.setCellConversation(id, handle.assignedConversationId);
}
// Record the derived cell kind so re-mounts (after navigation) route to the
// right view without re-spawning (§17.6).
const kind = handle.cellKind ?? "pty";
cellKindBySession.set(handle.sessionId, kind);
if (kind === "chat") setChatSessionId(handle.sessionId);
setCellKind(kind);
return handle;
};
@ -455,21 +425,6 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
agentGateway.reattach(sessionId, onData)
: undefined;
// ── Chat cell callbacks (§17.6) ───────────────────────────────────────────
// Only used when this cell renders as a structured chat. `launch` spawns the
// session (reusing the same `doLaunch` as the terminal path, so the singleton
// invariant and conversation persistence are shared); `reattach`/`send` map to
// the chat-specific gateway methods.
const chatLaunch = (): Promise<string> =>
// A chat launch produces no PTY output; the byte sink is a no-op.
doLaunch({ cwd, rows: 0, cols: 0 }, () => {}, conversationId ?? undefined).then(
(handle) => {
setChatSessionId(handle.sessionId);
void vm.setSession(id, handle.sessionId);
return handle.sessionId;
},
);
return (
<div
data-testid="layout-leaf"
@ -596,35 +551,47 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
</button>
)}
</div>
{/* Route by derived cell kind (§17.6): a structured AI cell renders the
chat view, every other cell (plain terminal or a TUI/PTY agent) keeps
the unchanged xterm path. Re-key on the agent so the right view/opener
is captured at mount; the persisted session drives re-attach (never a
re-spawn) when navigating. */}
{agentGateway && agentId && cellKind === "chat" ? (
<AgentChatView
key={`${id}-${agentId}-chat`}
launch={chatLaunch}
reattach={(sid, onReply) => agentGateway.reattachChat(sid, onReply)}
send={(sid, prompt, onReply) =>
agentGateway.sendPrompt(sid, prompt, onReply)
}
sessionId={chatSessionId ?? session}
onSessionId={(sid) => {
setChatSessionId(sid);
void vm.setSession(id, sid);
}}
/>
) : (
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
/>
)}
{/* 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"