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

@ -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<void> {
// 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<ReplyChunk>();
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<ReplyChunk[]> {
// 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<ReplyChunk>();
channel.onmessage = (chunk) => onReply(chunk);
const res = await invoke<ReattachChatDto>("reattach_agent_chat", {
sessionId,
onReply: channel,
});
return res.scrollback;
}
async closeAgentSession(sessionId: string): Promise<void> {
await invoke("close_agent_session", { sessionId });
}
}