diff --git a/.ideai/briefs/d5-frontend-chat.md b/.ideai/briefs/d5-frontend-chat.md new file mode 100644 index 0000000..b677ac6 --- /dev/null +++ b/.ideai/briefs/d5-frontend-chat.md @@ -0,0 +1,99 @@ +# Brief Dev — Lot D5 : frontend chat (§17.9) + +> Demandé par **Main** à **DevFrontend** (dev) + **QA** (test). Cycle §3 : code → tests → vert. +> Périmètre **frontend uniquement** (`frontend/src`). Le backend D4 est livré et committé (`f4d5727`). + +## 0. Où on en est + +Le fil §17 (exécution structurée des agents IA) est livré jusqu'à **D4 inclus** côté backend : +les commandes Tauri `agent_send` / `reattach_agent_chat` / `close_agent_session` existent et +streament des `ReplyChunk` sur un `Channel`. D5 = **la vue chat React** qui consomme ça, plus +le **routage par type de cellule** dans le layout. + +### Contrats backend exacts à mirrorer (déjà livrés) +Commandes Tauri (`crates/app-tauri/src/commands.rs`) : +- `agent_send(sessionId: string, prompt: string, onReply: Channel) -> void` +- `reattach_agent_chat(sessionId: string, onReply: Channel) -> ReattachChatDto` +- `close_agent_session(sessionId: string) -> void` + +DTO (`crates/app-tauri/src/dto.rs`) — **sérialisation camelCase tagué `kind`** : +```ts +type ReplyChunk = + | { kind: "textDelta"; text: string } + | { kind: "toolActivity"; label: string } + | { kind: "final"; content: string }; + +// ReattachChatDto : le scrollback de conversation (chunks déjà streamés) +interface ReattachChatDto { sessionId: string; scrollback: ReplyChunk[]; } + +// Et surtout : le DTO de session porte désormais cellKind +type CellKind = "pty" | "chat"; // toujours présent sur TerminalSessionDto +``` +> `cellKind` vaut `"chat"` quand l'agent est piloté en mode structuré (profil Claude/Codex), +> `"pty"` pour un terminal brut. C'est **la seule info dont le frontend a besoin** pour router +> cellule chat vs terminal. + +## 1. Périmètre D5 (réf. tableau §17.9 ligne D5) + +1. **`AgentChatView`** (nouveau, `frontend/src/features/chat/`) : vue de conversation IdeA. + - Affiche les **deltas live** (accumulation `textDelta` → texte du tour en cours), l'**activité + d'outil** (`toolActivity`), et **fige le tour** sur `final`. + - Zone de **saisie** d'un prompt → appelle `AgentGateway.sendPrompt`. + - **Scrollback de conversation** : à l'attache, repeint l'historique renvoyé par + `reattachChat` ; survit à un changement d'onglet/layout (ré-attache, pas re-spawn). + - C'est le **jumeau chat** de `TerminalView` (`frontend/src/features/terminals/TerminalView.tsx`, + 283 l.) — inspire-toi de sa gestion de cycle de vie (mount/attach/detach), mais pour un + flux de messages structuré au lieu d'octets xterm. +2. **Routage par `cellKind` dans `LayoutGrid`** (`frontend/src/features/layout/LayoutGrid.tsx`, + fonction `LeafView` ~l.159) : une cellule rend `AgentChatView` si `cellKind === "chat"`, + sinon `TerminalView` (comportement actuel inchangé). Le `cellKind` arrive sur le handle/session + au lancement (sortie de `launchAgent`) — propage-le jusqu'au leaf. +3. **Port `AgentGateway`** (`frontend/src/ports/index.ts`, interface ~l.76) : ajoute + - `sendPrompt(sessionId: string, prompt: string, onReply: (c: ReplyChunk) => void): Promise` + - `reattachChat(sessionId: string, onReply: (c: ReplyChunk) => void): Promise` + - `closeAgentSession(sessionId: string): Promise` + (signatures à aligner avec le style des méthodes existantes `launchAgent`/`reattach`). +4. **Adapter Tauri** (`frontend/src/adapters/agent.ts`, classe `TauriAgentGateway`) : implémente + les 3 méthodes via `invoke(...)` + `new Channel()` (modèle déjà présent pour + `launchAgent`/`reattach` qui utilisent `Channel`). +5. **Mock gateway** (`frontend/src/adapters/mock/index.ts`) : streame des `ReplyChunk` scriptés + (quelques `textDelta` puis un `final`) pour les tests et le dev hors-Tauri. +6. **Types TS** : ajoute `ReplyChunk`, `CellKind`, `ReattachChatDto` aux types partagés (là où + vivent les autres mirrors de DTO), et le champ `cellKind` sur le type de session/handle. + +## 2. Invariants à respecter + +- **Zéro régression terminal** : `TerminalView` et le chemin PTY de `LayoutGrid` restent + identiques ; une cellule `pty` se comporte exactement comme avant. +- **Ré-attache ≠ re-spawn** : changer d'onglet puis revenir repeint la conversation depuis le + scrollback renvoyé par `reattachChat`, sans relancer de tour (miroir du PTY reattach). +- **Accumulation correcte** : les `textDelta` s'accumulent dans le tour courant ; `final` clôt + le tour (le texte final fait foi). Pas de doublon delta/final affiché deux fois. +- Frontières : la vue dépend du **port** `AgentGateway`, jamais directement de `invoke`/Tauri + (c'est l'adapter qui parle à Tauri). Hexagonal côté front respecté. + +## 3. Tests attendus (QA — Vitest, réf. colonne « Tests attendus » D5 du §17.9) + +Aligne-toi sur le style des `*.test.tsx` existants (`LayoutGrid.test.tsx`, `TerminalView.test.tsx`, +`adapters/agent.test.ts`), avec le **mock gateway** : +- cellule `cellKind:"chat"` rend `AgentChatView` ; `cellKind:"pty"` rend `TerminalView`. +- les `textDelta` s'accumulent à l'écran → `final` fige le tour. +- ré-attache repeint le scrollback **sans** re-spawn (le mock ne reçoit pas de nouveau `sendPrompt`). +- envoi d'un prompt → `AgentGateway.sendPrompt` appelé avec les bons args. +- `toolActivity` affiché comme activité d'outil. +- mock gateway streame bien une séquence `ReplyChunk` (deltas… puis final). + +## 4. Méthode + +Cycle §3 strict : DevFrontend code → QA écrit + exécute les tests Vitest → vert avant de clore. +Vérifie `npm run build` (tsc --noEmit + vite) **et** `npx vitest run` verts. **Ne pas committer, +ne pas push** — Main relit et commit. Rapport d'erreurs clair si rouge → correction → re-test. + +## 5. Références + +- Jumeau à copier : `frontend/src/features/terminals/TerminalView.tsx` (cycle de vie attach/detach). +- Routage cellule : `frontend/src/features/layout/LayoutGrid.tsx` (`LeafView`). +- Port : `frontend/src/ports/index.ts` (`AgentGateway`) ; adapter : `frontend/src/adapters/agent.ts` ; + mock : `frontend/src/adapters/mock/index.ts`. +- Backend déjà livré : commit `f4d5727`, `crates/app-tauri/src/{chat,commands,dto}.rs`. +- Spec : `ARCHITECTURE.md` §17.6 (deux types de cellules) et tableau §17.9 ligne **D5**. diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 103387e..09c088a 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -14,7 +14,14 @@ import { Channel, invoke } from "@tauri-apps/api/core"; -import type { Agent, ResumableAgent, TerminalSession } from "@/domain"; +import type { + Agent, + CellKind, + ReattachChatDto, + ReplyChunk, + ResumableAgent, + TerminalSession, +} from "@/domain"; import type { AgentGateway, ConversationDetails, @@ -34,6 +41,8 @@ interface LaunchAgentResponse { cols: number; /** Conversation id minted by this launch (omitted when nothing was assigned). */ assignedConversationId?: string; + /** How the hosting cell should render (`"chat"` ⇒ structured AI cell, §17.6). */ + cellKind: CellKind; } export class TauriAgentGateway implements AgentGateway { @@ -144,12 +153,17 @@ export class TauriAgentGateway implements AgentGateway { onOutput: channel, }); - const handle = makeTerminalHandle(res.sessionId, channel); - // Surface the id assigned by this launch so the caller persists it on the - // leaf (`setCellConversation`) and resumes next time. - return res.assignedConversationId - ? { ...handle, assignedConversationId: res.assignedConversationId } - : handle; + const base = makeTerminalHandle(res.sessionId, channel); + // Surface the id assigned by this launch (so the caller persists it on the + // leaf and resumes next time) and the derived `cellKind` (so the leaf routes + // to `AgentChatView` vs `TerminalView`, §17.6). + return { + ...base, + cellKind: res.cellKind, + ...(res.assignedConversationId + ? { assignedConversationId: res.assignedConversationId } + : {}), + }; } async reattach( @@ -183,4 +197,40 @@ export class TauriAgentGateway implements AgentGateway { request: { projectId, agentId, conversationId }, }); } + + async sendPrompt( + sessionId: string, + prompt: string, + onReply: (chunk: ReplyChunk) => void, + ): Promise { + // Per-turn reply channel. The backend streams typed `ReplyChunk`s (tagged on + // `kind`); the pump runs to the `final` chunk then detaches its generation. + const channel = new Channel(); + channel.onmessage = (chunk) => onReply(chunk); + + // `agent_send` takes top-level args (Tauri renames the snake_case command + // params to camelCase): `sessionId`, `prompt`, `onReply`. + await invoke("agent_send", { sessionId, prompt, onReply: channel }); + } + + async reattachChat( + sessionId: string, + onReply: (chunk: ReplyChunk) => void, + ): Promise { + // Re-bind to a still-living structured session without re-spawning it. The + // returned scrollback repaints prior turns; subsequent chunks arrive over the + // freshly-registered channel. + const channel = new Channel(); + channel.onmessage = (chunk) => onReply(chunk); + + const res = await invoke("reattach_agent_chat", { + sessionId, + onReply: channel, + }); + return res.scrollback; + } + + async closeAgentSession(sessionId: string): Promise { + await invoke("close_agent_session", { sessionId }); + } } diff --git a/frontend/src/adapters/mock/agent-chat.test.ts b/frontend/src/adapters/mock/agent-chat.test.ts new file mode 100644 index 0000000..54466d9 --- /dev/null +++ b/frontend/src/adapters/mock/agent-chat.test.ts @@ -0,0 +1,112 @@ +/** + * D5 — the `MockAgentGateway` chat surface (`_setChatAgents`, `launchAgent` as a + * chat cell, `sendPrompt`, `reattachChat`, `closeAgentSession`). Verifies the + * mock scripts a realistic `ReplyChunk` sequence (deltas… toolActivity… final) + * and that re-attach replays the retained scrollback WITHOUT a new turn — the + * exact behaviours the feature tests rely on. + */ +import { describe, it, expect, vi } from "vitest"; + +import type { ReplyChunk } from "@/domain"; +import { MockAgentGateway } from "./index"; + +const CWD = "/home/me/proj"; + +/** Launches a chat agent and returns its session id. */ +async function launchChat(gw: MockAgentGateway, agentId = "a1", projectId = "p1") { + await gw.createAgent(projectId, { name: agentId, profileId: "claude" }); + // createAgent mints its own id — re-fetch so we mark the real id as chat. + const created = (await gw.listAgents(projectId)).find((a) => a.name === agentId)!; + gw._setChatAgents([created.id]); + const handle = await gw.launchAgent( + projectId, + created.id, + { cwd: CWD, rows: 0, cols: 0, nodeId: "node-1" }, + () => {}, + ); + return { handle, agentId: created.id, projectId }; +} + +describe("MockAgentGateway chat surface", () => { + it("launchAgent reports cellKind 'chat' for a structured agent", async () => { + const gw = new MockAgentGateway(); + const { handle } = await launchChat(gw); + expect(handle.cellKind).toBe("chat"); + }); + + it("launchAgent stays 'pty' for a non-structured agent (no regression)", async () => { + const gw = new MockAgentGateway(); + await gw.createAgent("p1", { name: "plain", profileId: "claude" }); + const plain = (await gw.listAgents("p1")).find((a) => a.name === "plain")!; + const handle = await gw.launchAgent( + "p1", + plain.id, + { cwd: CWD, rows: 24, cols: 80, nodeId: "node-1" }, + () => {}, + ); + expect(handle.cellKind ?? "pty").toBe("pty"); + }); + + it("sendPrompt streams deltas… toolActivity… then a single final", async () => { + const gw = new MockAgentGateway(); + const { handle } = await launchChat(gw); + const chunks: ReplyChunk[] = []; + await gw.sendPrompt(handle.sessionId, "hi", (c) => chunks.push(c)); + + // The send pump runs on a microtask; flush it. + await new Promise((r) => queueMicrotask(() => r(undefined))); + + const kinds = chunks.map((c) => c.kind); + expect(kinds.filter((k) => k === "textDelta").length).toBeGreaterThanOrEqual(1); + expect(kinds).toContain("toolActivity"); + // Exactly one terminal final, and it is the LAST chunk. + expect(kinds.filter((k) => k === "final")).toHaveLength(1); + expect(kinds.at(-1)).toBe("final"); + + // The aggregated final content equals the concatenated deltas (no loss). + const deltas = chunks + .filter((c): c is { kind: "textDelta"; text: string } => c.kind === "textDelta") + .map((c) => c.text) + .join(""); + const final = chunks.find((c): c is { kind: "final"; content: string } => c.kind === "final")!; + expect(final.content).toBe(deltas); + expect(final.content).toBe("echo: hi"); + }); + + it("reattachChat replays the retained scrollback without re-sending", async () => { + const gw = new MockAgentGateway(); + const { handle } = await launchChat(gw); + + // Run one full turn so the session retains a scrollback. + await gw.sendPrompt(handle.sessionId, "remember me", () => {}); + await new Promise((r) => queueMicrotask(() => r(undefined))); + + // Re-attach: returns the prior chunks; the live sink must NOT receive any + // new chunk (no fresh turn is triggered by a re-attach). + const liveSink = vi.fn(); + const scrollback = await gw.reattachChat(handle.sessionId, liveSink); + + expect(scrollback.length).toBeGreaterThan(0); + expect(scrollback.at(-1)?.kind).toBe("final"); + await new Promise((r) => queueMicrotask(() => r(undefined))); + expect(liveSink).not.toHaveBeenCalled(); + }); + + it("reattachChat rejects for a closed session", async () => { + const gw = new MockAgentGateway(); + const { handle } = await launchChat(gw); + await gw.closeAgentSession(handle.sessionId); + await expect(gw.reattachChat(handle.sessionId, () => {})).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("sendPrompt rejects once the session is closed", async () => { + const gw = new MockAgentGateway(); + const { handle } = await launchChat(gw); + await gw.closeAgentSession(handle.sessionId); + await expect(gw.sendPrompt(handle.sessionId, "x", () => {})).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 1a5cd29..e3cc136 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -27,8 +27,10 @@ import type { MemoryIndexEntry, MemoryLink, MemoryType, + CellKind, Project, ProfileAvailability, + ReplyChunk, ResumableAgent, Skill, SkillScope, @@ -159,6 +161,68 @@ class MockPtySession { } } +/** + * A live in-memory mock **chat** session (the structured-AI twin of + * {@link MockPtySession}). It retains a conversation scrollback (every + * {@link ReplyChunk} streamed) and tracks the current reply sink so a view can + * detach (sink cleared, session stays alive) and later re-attach (new sink, + * scrollback replayed by the caller). `sendPrompt` streams a scripted turn: + * a couple of `textDelta`s, an optional `toolActivity`, then one `final`. + */ +class MockChatSession { + /** Accumulated reply chunks (the conversation scrollback). */ + private scrollback: ReplyChunk[] = []; + /** Current reply sink; `null` while detached. */ + private sink: ((chunk: ReplyChunk) => void) | null = null; + /** Whether the session was explicitly closed (shut down). */ + closed = false; + + constructor(readonly sessionId: string) {} + + /** Records a chunk into the scrollback and forwards it to the current sink. */ + private emit(chunk: ReplyChunk): void { + if (this.closed) return; + this.scrollback.push(chunk); + this.sink?.(chunk); + } + + /** Re-attaches a new reply sink, returning the retained scrollback to replay. */ + reattach(sink: (chunk: ReplyChunk) => void): ReplyChunk[] { + this.sink = sink; + return structuredClone(this.scrollback); + } + + /** Detaches the current view: stop delivering, keep the session alive. */ + detach(): void { + this.sink = null; + } + + /** + * Streams a scripted reply turn for `prompt`: two text deltas, a tool-activity + * badge, then the deterministic `final` carrying the aggregated content. Each + * chunk is recorded into the scrollback (so a re-attach replays the whole + * turn) and forwarded live to the current sink. + */ + send(prompt: string, onReply: (chunk: ReplyChunk) => void): void { + if (this.closed) return; + this.sink = onReply; + const content = `echo: ${prompt}`; + const half = Math.ceil(content.length / 2); + queueMicrotask(() => { + this.emit({ kind: "textDelta", text: content.slice(0, half) }); + this.emit({ kind: "toolActivity", label: "thinking" }); + this.emit({ kind: "textDelta", text: content.slice(half) }); + this.emit({ kind: "final", content }); + }); + } + + /** Shuts the session down (idempotent). */ + shutdown(): void { + this.closed = true; + this.sink = null; + } +} + /** * Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`, * `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and @@ -185,6 +249,19 @@ export class MockAgentGateway implements AgentGateway { private liveByAgent = new Map(); /** Live PTY session id per agent (`agentId → sessionId`). */ private liveSessionByAgent = new Map(); + /** Live structured (chat) sessions, kept across detach so reattach finds them. */ + private chatSessions = new Map(); + /** + * Agents that launch as a **chat** cell (`structured_adapter` present). Seeded + * by tests via {@link _setChatAgents}; absent ⇒ the agent launches as a `pty` + * cell (the historical default), so existing tests are unaffected. + */ + private chatAgents = new Set(); + + /** Marks the given agents as structured (chat) cells for `launchAgent`. */ + _setChatAgents(agentIds: string[]): void { + for (const id of agentIds) this.chatAgents.add(id); + } private getAgents(projectId: string): Agent[] { if (!this.agents.has(projectId)) this.agents.set(projectId, []); @@ -373,6 +450,7 @@ export class MockAgentGateway implements AgentGateway { cwd: `/home/user/mock-project`, rows, cols, + cellKind: this.chatAgents.has(agentId) ? "chat" : "pty", }, }; } @@ -457,26 +535,53 @@ export class MockAgentGateway implements AgentGateway { this.sessionSeq += 1; const sessionId = `mock-agent-session-${this.sessionSeq}`; const cwd = options.cwd; - const enc = new TextEncoder(); - const session = new MockPtySession(sessionId, onData); - this.sessions.set(sessionId, session); + const cellKind: CellKind = this.chatAgents.has(agentId) ? "chat" : "pty"; + // Record liveness for `listLiveAgents` + the guard above (only when the - // caller pins a node). + // caller pins a node) — shared by both cell kinds. if (options.nodeId) { this.liveByAgent.set(agentId, options.nodeId); this.liveSessionByAgent.set(agentId, sessionId); } - // Greet so something is visible immediately (mirrors MockTerminalGateway). - queueMicrotask(() => - session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), - ); - const handle = makeMockHandle(session, () => { - this.sessions.delete(sessionId); + const clearLive = () => { if (this.liveByAgent.get(agentId) === options.nodeId) { this.liveByAgent.delete(agentId); this.liveSessionByAgent.delete(agentId); } - }); + }; + + let handle: TerminalHandle; + if (cellKind === "chat") { + // Structured AI cell (§17.6): spawn a chat session (no PTY). The reply + // stream is driven by `sendPrompt`; the returned handle is mostly inert + // (no bytes), but its `close` shuts the structured session down. + const chat = new MockChatSession(sessionId); + this.chatSessions.set(sessionId, chat); + handle = { + ...makeInertHandle(sessionId, () => { + chat.shutdown(); + this.chatSessions.delete(sessionId); + clearLive(); + }), + cellKind, + }; + } else { + const enc = new TextEncoder(); + const session = new MockPtySession(sessionId, onData); + this.sessions.set(sessionId, session); + // Greet so something is visible immediately (mirrors MockTerminalGateway). + queueMicrotask(() => + session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), + ); + handle = { + ...makeMockHandle(session, () => { + this.sessions.delete(sessionId); + clearLive(); + }), + cellKind, + }; + } + // Simulate session assignment (T4b): a fresh cell (no conversation id) gets a // newly-minted id surfaced on the handle so the caller persists it; a cell // that already carries an id resumes it and nothing new is assigned. @@ -489,6 +594,58 @@ export class MockAgentGateway implements AgentGateway { return handle; } + async sendPrompt( + sessionId: string, + prompt: string, + onReply: (chunk: ReplyChunk) => void, + ): Promise { + const session = this.chatSessions.get(sessionId); + if (!session || session.closed) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `structured session ${sessionId} is not alive`, + }; + throw err; + } + session.send(prompt, onReply); + } + + async reattachChat( + sessionId: string, + onReply: (chunk: ReplyChunk) => void, + ): Promise { + const session = this.chatSessions.get(sessionId); + if (!session || session.closed) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `structured session ${sessionId} is not alive`, + }; + throw err; + } + // Re-attach the reply sink and return the retained conversation scrollback — + // no new turn is started here (mirrors the backend `reattach_agent_chat`). + return session.reattach(onReply); + } + + async closeAgentSession(sessionId: string): Promise { + const session = this.chatSessions.get(sessionId); + if (!session) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `structured session ${sessionId} is not alive`, + }; + throw err; + } + session.shutdown(); + this.chatSessions.delete(sessionId); + for (const [agentId, liveSessionId] of this.liveSessionByAgent) { + if (liveSessionId === sessionId) { + this.liveSessionByAgent.delete(agentId); + this.liveByAgent.delete(agentId); + } + } + } + async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, @@ -573,6 +730,28 @@ function makeMockHandle( }; } +/** + * Builds an inert {@link TerminalHandle} for a structured **chat** session: it + * carries no PTY, so `write`/`resize` are no-ops and `detach` does nothing + * (the chat view manages its own reply subscription). Only `close` is live — + * it shuts the structured session down. The chat cell never feeds bytes through + * this handle; it drives the session via `sendPrompt`/`reattachChat`. + */ +function makeInertHandle( + sessionId: string, + shutdown: () => void, +): TerminalHandle { + return { + sessionId, + async write(): Promise {}, + async resize(): Promise {}, + detach(): void {}, + async close(): Promise { + shutdown(); + }, + }; +} + /** * In-memory fake terminal: a shell-less PTY that **echoes** whatever is written * back to `onData` (so the xterm wrapper renders typed input) and greets on diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 6099d9f..e4c6ea9 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -271,6 +271,49 @@ export interface TerminalSession { * session assignment and the hosting cell had none yet; absent otherwise. */ assignedConversationId?: string; + /** + * How the frontend should render the cell hosting this session (mirror of the + * backend `TerminalSessionDto.cellKind`, ARCHITECTURE §17.6). `"chat"` ⇒ a + * structured AI session driven by `agent_send`/`reattach_agent_chat` (rendered + * as an `AgentChatView`); `"pty"` ⇒ a raw terminal (xterm). **Derived** + * backend-side from the launch routing — a single source of truth. + */ + cellKind: CellKind; +} + +/** + * Whether a session's hosting cell renders as a structured chat view or a raw + * terminal (mirror of the backend `CellKind`, ARCHITECTURE §17.6). The + * `LayoutGrid` switches `AgentChatView` vs `TerminalView` on this value. + */ +export type CellKind = "pty" | "chat"; + +/** + * One incremental event of an AI agent's reply turn (mirror of the backend + * `ReplyChunk`, ARCHITECTURE §17.7). Tagged on `kind` (camelCase on the wire), + * so the view branches without positional parsing: + * + * - `textDelta`: a text fragment of the current turn (accumulated live); + * - `toolActivity`: a human-readable tool-activity badge (best-effort); + * - `final`: the **deterministic** end-of-turn chunk carrying the aggregated + * final content — after it the turn is frozen and the stream ends. + */ +export type ReplyChunk = + | { kind: "textDelta"; text: string } + | { kind: "toolActivity"; label: string } + | { kind: "final"; content: string }; + +/** + * The retained **conversation scrollback** of a still-live structured session + * (mirror of the backend `ReattachChatDto`, ARCHITECTURE §17.7), returned by + * `reattachChat`. The frontend replays `scrollback` to rebuild the visible turns + * before subsequent chunks arrive over the freshly-registered channel. + */ +export interface ReattachChatDto { + /** The session that was re-attached (echoed back). */ + sessionId: string; + /** The chunks already streamed for this conversation, in order. */ + scrollback: ReplyChunk[]; } /** diff --git a/frontend/src/features/chat/AgentChatView.test.tsx b/frontend/src/features/chat/AgentChatView.test.tsx new file mode 100644 index 0000000..235af89 --- /dev/null +++ b/frontend/src/features/chat/AgentChatView.test.tsx @@ -0,0 +1,268 @@ +/** + * D5 — {@link AgentChatView}, the structured-AI chat twin of `TerminalView`. + * + * These are pure-component tests: the view takes its `launch`/`reattach`/`send` + * callbacks as props (the leaf wires them from the `AgentGateway` port), so we + * drive it with hand-rolled spies and assert the visible transcript + the exact + * lifecycle calls. The cardinal invariants under test: + * + * - deltas accumulate into the pending turn; `final` FREEZES it and REPLACES + * the deltas (no double rendering); + * - mounting with an existing session id RE-ATTACHES (replays scrollback) and + * NEVER launches / sends (re-attach ≠ re-spawn); + * - Enter submits the prompt via `send`; Shift+Enter does not. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; + +import type { ReplyChunk } from "@/domain"; +import { AgentChatView, type AgentChatViewProps } from "./AgentChatView"; + +/** A captured `onReply` sink, so a test can stream chunks live after attach. */ +interface Wired { + props: AgentChatViewProps; + /** Last `onReply` handed to `reattach`/`send` (live stream sink). */ + emit: (chunk: ReplyChunk) => void; + launch: ReturnType; + reattach: ReturnType; + send: ReturnType; + onSessionId: ReturnType; +} + +/** + * Builds the prop set with spy callbacks. `scrollback` is returned by + * `reattach`; `launchId` is the id `launch` resolves with. + */ +function wire(opts: { + sessionId?: string | null; + scrollback?: ReplyChunk[]; + launchId?: string; +}): Wired { + let lastSink: (chunk: ReplyChunk) => void = () => {}; + + const reattach = vi.fn( + async (_sid: string, onReply: (c: ReplyChunk) => void) => { + lastSink = onReply; + return opts.scrollback ?? []; + }, + ); + const launch = vi.fn(async () => opts.launchId ?? "launched-session"); + const send = vi.fn( + async (_sid: string, _prompt: string, onReply: (c: ReplyChunk) => void) => { + lastSink = onReply; + }, + ); + const onSessionId = vi.fn(); + + const props: AgentChatViewProps = { + launch, + reattach, + send, + sessionId: opts.sessionId ?? null, + onSessionId, + }; + return { + props, + emit: (chunk) => lastSink(chunk), + launch, + reattach, + send, + onSessionId, + }; +} + +describe("AgentChatView", () => { + it("mounts the chat view container", () => { + render(); + expect(screen.getByTestId("agent-chat-view")).toBeTruthy(); + }); + + // ── Zone 3: re-attach ≠ re-spawn ────────────────────────────────────────── + it("re-attaches to an existing session and NEVER launches or sends", async () => { + const w = wire({ sessionId: "live-1" }); + render(); + + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + // Re-attach used the persisted id. + expect(w.reattach.mock.calls[0][0]).toBe("live-1"); + // The cardinal invariant: navigating back must NOT re-spawn nor re-send. + expect(w.launch).not.toHaveBeenCalled(); + expect(w.send).not.toHaveBeenCalled(); + // A reattach (not a launch) must NOT re-persist the session id. + expect(w.onSessionId).not.toHaveBeenCalled(); + }); + + it("launches a fresh session only when the cell has no session yet", async () => { + const w = wire({ sessionId: null, launchId: "fresh-7" }); + render(); + + await waitFor(() => expect(w.launch).toHaveBeenCalled()); + // The launched id is persisted and then attached. + await waitFor(() => expect(w.onSessionId).toHaveBeenCalledWith("fresh-7")); + await waitFor(() => expect(w.reattach).toHaveBeenCalledWith("fresh-7", expect.any(Function))); + }); + + // ── Zone 3 (scrollback): re-attach repaints prior turns ─────────────────── + it("repaints the conversation scrollback returned by re-attach", async () => { + const w = wire({ + sessionId: "live-1", + scrollback: [ + { kind: "textDelta", text: "restored " }, + { kind: "textDelta", text: "answer" }, + { kind: "final", content: "restored answer" }, + ], + }); + render(); + + // The replayed final freezes into a (single) assistant turn. + await waitFor(() => + expect(screen.getByTestId("chat-turn-assistant").textContent).toContain( + "restored answer", + ), + ); + // No prompt was sent to rebuild it. + expect(w.send).not.toHaveBeenCalled(); + }); + + // ── Zone 2: accumulation + non-double-render ────────────────────────────── + it("accumulates textDelta into the pending turn, then final freezes it", async () => { + const w = wire({ sessionId: "s1" }); + render(); + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + + // Stream two deltas live → they accumulate in the pending bubble. + w.emit({ kind: "textDelta", text: "Hel" }); + w.emit({ kind: "textDelta", text: "lo" }); + await waitFor(() => + expect(screen.getByTestId("chat-turn-pending").textContent).toContain("Hello"), + ); + // Still pending (no assistant turn yet). + expect(screen.queryByTestId("chat-turn-assistant")).toBeNull(); + + // final freezes the turn into an assistant bubble; pending disappears. + w.emit({ kind: "final", content: "Hello" }); + await waitFor(() => + expect(screen.getByTestId("chat-turn-assistant").textContent).toContain("Hello"), + ); + expect(screen.queryByTestId("chat-turn-pending")).toBeNull(); + }); + + it("final content REPLACES the deltas — no double rendering", async () => { + const w = wire({ sessionId: "s1" }); + render(); + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + + w.emit({ kind: "textDelta", text: "abc" }); + w.emit({ kind: "final", content: "abc" }); + + await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy()); + // The frozen turn shows "abc" exactly ONCE — not "abcabc" (delta + final). + const text = screen.getByTestId("chat-turn-assistant").textContent ?? ""; + const occurrences = text.split("abc").length - 1; + expect(occurrences).toBe(1); + // And there is exactly one assistant turn, not two. + expect(screen.getAllByTestId("chat-turn-assistant")).toHaveLength(1); + }); + + // Anti "always-green" guard: prove the non-double assertion would FAIL if the + // component had appended final on top of the deltas (a mutated reducer). Here + // we feed a final whose content DIFFERS from the deltas, then check the visible + // turn is the final alone — if deltas leaked through, "xx" would also appear. + it("the frozen turn is the final content alone (deltas discarded)", async () => { + const w = wire({ sessionId: "s1" }); + render(); + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + + w.emit({ kind: "textDelta", text: "DELTA" }); + w.emit({ kind: "final", content: "FINAL" }); + + await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy()); + const text = screen.getByTestId("chat-turn-assistant").textContent ?? ""; + expect(text).toContain("FINAL"); + expect(text).not.toContain("DELTA"); + }); + + // ── Zone 5: toolActivity → badge ────────────────────────────────────────── + it("renders toolActivity as a tool badge on the turn", async () => { + const w = wire({ sessionId: "s1" }); + render(); + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + + w.emit({ kind: "textDelta", text: "working" }); + w.emit({ kind: "toolActivity", label: "Read(file.ts)" }); + await waitFor(() => { + const badges = screen.getAllByTestId("chat-tool-badge"); + expect(badges).toHaveLength(1); + expect(badges[0].textContent).toBe("Read(file.ts)"); + }); + + // The tools carry over onto the frozen turn after final. + w.emit({ kind: "final", content: "done" }); + await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy()); + expect(screen.getByTestId("chat-tool-badge").textContent).toBe("Read(file.ts)"); + }); + + // ── Zone 4: prompt submission ───────────────────────────────────────────── + it("Enter submits the prompt via send(sessionId, prompt, onReply)", async () => { + const w = wire({ sessionId: "live-9" }); + render(); + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + + const input = screen.getByTestId("agent-chat-input"); + fireEvent.change(input, { target: { value: "what is 2+2?" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => expect(w.send).toHaveBeenCalled()); + expect(w.send.mock.calls[0][0]).toBe("live-9"); + expect(w.send.mock.calls[0][1]).toBe("what is 2+2?"); + expect(typeof w.send.mock.calls[0][2]).toBe("function"); + // The user turn is echoed in the transcript and the input is cleared. + expect(screen.getByTestId("chat-turn-user").textContent).toContain("what is 2+2?"); + expect((input as HTMLTextAreaElement).value).toBe(""); + }); + + it("Shift+Enter does NOT submit (newline, not send)", async () => { + const w = wire({ sessionId: "live-9" }); + render(); + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + + const input = screen.getByTestId("agent-chat-input"); + fireEvent.change(input, { target: { value: "line one" } }); + fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); + + expect(w.send).not.toHaveBeenCalled(); + }); + + it("the submit button sends and streams the reply turn back over onReply", async () => { + const w = wire({ sessionId: "live-9" }); + render(); + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + + fireEvent.change(screen.getByTestId("agent-chat-input"), { + target: { value: "ping" }, + }); + fireEvent.click(screen.getByLabelText("send")); + + await waitFor(() => expect(w.send).toHaveBeenCalledWith("live-9", "ping", expect.any(Function))); + // The reply now streams over the same onReply captured by send(). + w.emit({ kind: "final", content: "pong" }); + await waitFor(() => + expect( + screen.getAllByTestId("chat-turn-assistant").some((el) => + (el.textContent ?? "").includes("pong"), + ), + ).toBe(true), + ); + }); + + it("does not send an empty/whitespace prompt", async () => { + const w = wire({ sessionId: "live-9" }); + render(); + await waitFor(() => expect(w.reattach).toHaveBeenCalled()); + + const input = screen.getByTestId("agent-chat-input"); + fireEvent.change(input, { target: { value: " " } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(w.send).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/chat/AgentChatView.tsx b/frontend/src/features/chat/AgentChatView.tsx new file mode 100644 index 0000000..ca81c4a --- /dev/null +++ b/frontend/src/features/chat/AgentChatView.tsx @@ -0,0 +1,431 @@ +/** + * Structured AI chat view (§17.6) — the chat twin of {@link TerminalView}. + * + * Where `TerminalView` wires raw PTY bytes into xterm, this view consumes the + * **typed** {@link ReplyChunk} stream of an `AgentSession`: + * + * - `textDelta` → accumulated into the current (in-flight) assistant turn; + * - `toolActivity` → a tool-activity badge on the current turn; + * - `final` → freezes the turn (the aggregated final content is authoritative, + * so it replaces the accumulated deltas — no double rendering). + * + * Input (the prompt box) → {@link AgentChatViewProps.send} (`agent_send`), which + * opens a fresh assistant turn whose chunks stream in over the same `onReply`. + * + * **Session lifecycle is decoupled from the view lifecycle** (same guarantee as + * the PTY): navigating (layout/tab switch) tears the view down but must NOT kill + * the backend structured session. So on mount the view **re-attaches** to the + * still-living session (repainting its conversation scrollback) via + * {@link AgentChatViewProps.reattach}; if no session exists yet it **launches** + * one via {@link AgentChatViewProps.launch}. On unmount it only drops its local + * subscription — it never closes the session (an explicit user action elsewhere). + * + * Pure presentation: it depends only on the callbacks the leaf wires from the + * {@link AgentGateway} port — never on `invoke()`/`Channel` (ARCHITECTURE §1.3). + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { ReplyChunk } from "@/domain"; + +/** A frozen, completed assistant turn (its `final` content). */ +interface AssistantTurn { + readonly kind: "assistant"; + readonly text: string; + /** Tool-activity labels observed during the turn (in order). */ + readonly tools: readonly string[]; +} + +/** A user prompt turn. */ +interface UserTurn { + readonly kind: "user"; + readonly text: string; +} + +type Turn = AssistantTurn | UserTurn; + +/** + * The live (in-flight) assistant turn being streamed: accumulated text deltas + * plus any tool-activity labels seen so far. `null` between turns. + */ +interface PendingTurn { + text: string; + tools: string[]; +} + +export interface AgentChatViewProps { + /** + * Launches a fresh structured session for this cell and returns its session id. + * Used at mount when the cell has no session yet. Mirrors `TerminalView.open`. + */ + launch: () => Promise; + /** + * Re-attaches to an already-living structured session, replaying its retained + * conversation scrollback. `onReply` then receives subsequent chunks. Mirrors + * `TerminalView.reattach`. Rejects if the session is gone (caller falls back + * to {@link launch}). + */ + reattach: ( + sessionId: string, + onReply: (chunk: ReplyChunk) => void, + ) => Promise; + /** + * Sends a prompt to the live session; its reply turn streams back over + * `onReply`. Mirrors a PTY `write`. Resolves once the turn stream is wired. + */ + send: ( + sessionId: string, + prompt: string, + onReply: (chunk: ReplyChunk) => void, + ) => Promise; + /** Persisted session id for this cell, if a session is already running for it. */ + sessionId?: string | null; + /** + * Called once a session is established (launched) so the caller persists its id + * for this cell and re-attaches on the next mount. Not called on reattach. + */ + onSessionId?: (sessionId: string) => void; +} + +export function AgentChatView({ + launch, + reattach, + send, + sessionId, + onSessionId, +}: AgentChatViewProps) { + /** Frozen turns (history), oldest first. */ + const [turns, setTurns] = useState([]); + /** The in-flight assistant turn, or `null` between turns. */ + const [pending, setPending] = useState(null); + /** The current input value. */ + const [input, setInput] = useState(""); + /** A connection/attach error to surface (non-fatal). */ + const [error, setError] = useState(null); + + // The live session id is held in a ref so the (stable) chunk reducer and the + // submit handler always read the current value without re-subscribing. + const liveSessionId = useRef(sessionId ?? null); + + // Callbacks read through refs so the mount effect does not depend on their + // identity (the leaf re-creates them each render). The view is re-mounted via + // a `key` when the agent changes, so the right callbacks are captured at mount. + const launchRef = useRef(launch); + launchRef.current = launch; + const reattachRef = useRef(reattach); + reattachRef.current = reattach; + const onSessionIdRef = useRef(onSessionId); + onSessionIdRef.current = onSessionId; + + const scrollRef = useRef(null); + + /** + * Folds one {@link ReplyChunk} into the visible state. Stable identity: it + * only touches state via the functional setters, so the same instance serves + * the reattach replay, the live stream, and every subsequent turn. + */ + const apply = useCallback((chunk: ReplyChunk) => { + switch (chunk.kind) { + case "textDelta": + setPending((p) => ({ + text: (p?.text ?? "") + chunk.text, + tools: p?.tools ?? [], + })); + break; + case "toolActivity": + setPending((p) => ({ + text: p?.text ?? "", + tools: [...(p?.tools ?? []), chunk.label], + })); + break; + case "final": + // The aggregated final content is authoritative — it replaces the + // accumulated deltas (no double rendering), carrying over the tools seen. + setPending((p) => { + const tools = p?.tools ?? []; + setTurns((prev) => [ + ...prev, + { kind: "assistant", text: chunk.content, tools }, + ]); + return null; + }); + break; + } + }, []); + + // Mount: re-attach to the existing session (replay scrollback) or launch a new + // one. Decoupled from view lifecycle — unmount never closes the session. + useEffect(() => { + let disposed = false; + const existing = liveSessionId.current; + + const attach = (sid: string) => + reattachRef.current(sid, (chunk) => { + if (!disposed) apply(chunk); + }).then((scrollback) => { + if (disposed) return; + // Replay the retained conversation scrollback to rebuild prior turns, + // then subsequent chunks arrive live over the same `onReply`. + for (const chunk of scrollback) apply(chunk); + }); + + if (existing) { + attach(existing).catch(() => { + // Session gone (explicitly closed / exited): fall back to a fresh launch + // so the cell still works. + if (disposed) return; + launchRef.current() + .then((sid) => { + if (disposed) return; + liveSessionId.current = sid; + onSessionIdRef.current?.(sid); + return attach(sid); + }) + .catch((e) => { + if (!disposed) setError(describe(e)); + }); + }); + } else { + launchRef.current() + .then((sid) => { + if (disposed) return; + liveSessionId.current = sid; + onSessionIdRef.current?.(sid); + return attach(sid); + }) + .catch((e) => { + if (!disposed) setError(describe(e)); + }); + } + + return () => { + // Drop the local subscription only — the backend session keeps running so + // the AI is not cut off (same guarantee as the PTY detach). + disposed = true; + }; + // Re-attach/launch only on (re)mount. Callbacks are read from refs; the agent + // switch re-mounts via `key`, so we must NOT depend on them. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Auto-scroll to the latest content as turns/pending grow. + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [turns, pending]); + + const submit = () => { + const prompt = input.trim(); + const sid = liveSessionId.current; + if (!prompt || !sid) return; + setInput(""); + setError(null); + setTurns((prev) => [...prev, { kind: "user", text: prompt }]); + void send(sid, prompt, apply).catch((e) => setError(describe(e))); + }; + + return ( +
+
+ {turns.map((turn, i) => + turn.kind === "user" ? ( + + ) : ( + + ), + )} + {pending && ( + + )} +
+ + {error && ( +

+ {error} +

+ )} + +
{ + e.preventDefault(); + submit(); + }} + style={{ + display: "flex", + gap: 6, + padding: 6, + borderTop: "1px solid var(--color-border, #3a3a3a)", + }} + > +