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:
@ -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