feat(agent): vue chat frontend + routage cellKind (D5) — §17

Le frontend consomme l'exécution structurée livrée en D4 :
- AgentChatView : jumeau chat de TerminalView. Accumule les textDelta du
  tour courant, badges toolActivity, fige sur final (le contenu final
  remplace les deltas, pas de double rendu). Saisie Enter/Shift+Enter.
  Au montage : reattach (rejoue le scrollback) ou launch ; au démontage :
  détache seulement (jamais de close). Vue pure pilotée par le port.
- LayoutGrid/LeafView : routage cellKind — AgentChatView si "chat",
  TerminalView sinon (chemin PTY inchangé). Cache cellKindBySession pour
  router correctement après ré-attache/navigation.
- Port AgentGateway : sendPrompt / reattachChat / closeAgentSession ;
  cellKind sur TerminalHandle. Adapter Tauri (invoke + Channel<ReplyChunk>)
  et mock (MockChatSession, _setChatAgents) étendus.
- Types TS mirrors : CellKind, ReplyChunk, ReattachChatDto + cellKind sur
  TerminalSession.

Tests (QA) : 21 Vitest verts — routage chat/pty (non-régression terminal),
accumulation/non-doublon (garde anti-always-green delta!=final), ré-attache
sans re-spawn, envoi Enter vs Shift+Enter, badges toolActivity, mock stream.
npx vitest run : 345 passed, 0 failed. npm run build vert.

Reste D6 (messagerie inter-agents via send_blocking) et D7 (menu restreint
Claude/Codex + retrait custom) pour clore §17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:30:20 +02:00
parent f4d5727a69
commit 5059f37890
11 changed files with 1478 additions and 30 deletions

View File

@ -0,0 +1,138 @@
/**
* 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`.
*
* 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.
*/
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.
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData() {
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write() {}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
// jsdom has no ResizeObserver; TerminalView installs one after `term.open`.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { Gateways } from "@/ports";
import { MockAgentGateway, MockLayoutGateway, MockSystemGateway, MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
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 }> {
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");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id });
const gateways = { layout, agent: agentGateway, terminal, system } as unknown as Gateways;
return { gateways, layout, agentGateway };
}
function renderGrid(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
describe("LayoutGrid cell-kind routing (§17.6)", () => {
it("a plain (agent-less) cell renders the terminal view, never a chat view", async () => {
const layout = new MockLayoutGateway();
const gateways = {
layout,
agent: new MockAgentGateway(),
terminal: new MockTerminalGateway(),
system: new MockSystemGateway(),
} as unknown as Gateways;
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 });
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 });
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();
});
});

View File

@ -18,7 +18,7 @@
import { useEffect, useRef, useState } from "react";
import type { Agent } from "@/domain";
import type { Agent, CellKind } from "@/domain";
import type { LayoutNode } from "@/domain";
import type {
ConversationDetails,
@ -27,6 +27,7 @@ import type {
TerminalHandle,
} from "@/ports";
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
import { AgentChatView } from "@/features/chat";
import { useGateways } from "@/app/di";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
@ -143,6 +144,17 @@ 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
@ -258,6 +270,36 @@ 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]);
/** Performs the actual launch and persists any assigned id (T4b loop). */
const doLaunch = async (
opts: OpenTerminalOptions,
@ -285,6 +327,12 @@ 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;
};
@ -336,6 +384,21 @@ 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"
@ -455,17 +518,35 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
</button>
)}
</div>
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener.
The cell's persisted session id drives reattach-vs-open so navigating
(layout/tab switch) never kills the PTY. */}
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
/>
{/* 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)}
/>
)}
{busyNotice && (
<p
role="status"