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();
|
||||
});
|
||||
});
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user