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