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:
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
239
frontend/src/features/layout/LayoutGrid.f2.test.tsx
Normal file
239
frontend/src/features/layout/LayoutGrid.f2.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user