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:
99
.ideai/briefs/d5-frontend-chat.md
Normal file
99
.ideai/briefs/d5-frontend-chat.md
Normal file
@ -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<ReplyChunk>) -> void`
|
||||||
|
- `reattach_agent_chat(sessionId: string, onReply: Channel<ReplyChunk>) -> 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<void>`
|
||||||
|
- `reattachChat(sessionId: string, onReply: (c: ReplyChunk) => void): Promise<ReplyChunk[]>`
|
||||||
|
- `closeAgentSession(sessionId: string): Promise<void>`
|
||||||
|
(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<ReplyChunk>()` (modèle déjà présent pour
|
||||||
|
`launchAgent`/`reattach` qui utilisent `Channel<number[]>`).
|
||||||
|
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**.
|
||||||
@ -14,7 +14,14 @@
|
|||||||
|
|
||||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
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 {
|
import type {
|
||||||
AgentGateway,
|
AgentGateway,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
@ -34,6 +41,8 @@ interface LaunchAgentResponse {
|
|||||||
cols: number;
|
cols: number;
|
||||||
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
||||||
assignedConversationId?: string;
|
assignedConversationId?: string;
|
||||||
|
/** How the hosting cell should render (`"chat"` ⇒ structured AI cell, §17.6). */
|
||||||
|
cellKind: CellKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TauriAgentGateway implements AgentGateway {
|
export class TauriAgentGateway implements AgentGateway {
|
||||||
@ -144,12 +153,17 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
onOutput: channel,
|
onOutput: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handle = makeTerminalHandle(res.sessionId, channel);
|
const base = makeTerminalHandle(res.sessionId, channel);
|
||||||
// Surface the id assigned by this launch so the caller persists it on the
|
// Surface the id assigned by this launch (so the caller persists it on the
|
||||||
// leaf (`setCellConversation`) and resumes next time.
|
// leaf and resumes next time) and the derived `cellKind` (so the leaf routes
|
||||||
return res.assignedConversationId
|
// to `AgentChatView` vs `TerminalView`, §17.6).
|
||||||
? { ...handle, assignedConversationId: res.assignedConversationId }
|
return {
|
||||||
: handle;
|
...base,
|
||||||
|
cellKind: res.cellKind,
|
||||||
|
...(res.assignedConversationId
|
||||||
|
? { assignedConversationId: res.assignedConversationId }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async reattach(
|
async reattach(
|
||||||
@ -183,4 +197,40 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
request: { projectId, agentId, conversationId },
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
frontend/src/adapters/mock/agent-chat.test.ts
Normal file
112
frontend/src/adapters/mock/agent-chat.test.ts
Normal file
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -27,8 +27,10 @@ import type {
|
|||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
MemoryLink,
|
MemoryLink,
|
||||||
MemoryType,
|
MemoryType,
|
||||||
|
CellKind,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
ReplyChunk,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
Skill,
|
Skill,
|
||||||
SkillScope,
|
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`,
|
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
|
||||||
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
|
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
|
||||||
@ -185,6 +249,19 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
private liveByAgent = new Map<string, string>();
|
private liveByAgent = new Map<string, string>();
|
||||||
/** Live PTY session id per agent (`agentId → sessionId`). */
|
/** Live PTY session id per agent (`agentId → sessionId`). */
|
||||||
private liveSessionByAgent = new Map<string, string>();
|
private liveSessionByAgent = new Map<string, string>();
|
||||||
|
/** Live structured (chat) sessions, kept across detach so reattach finds them. */
|
||||||
|
private chatSessions = new Map<string, MockChatSession>();
|
||||||
|
/**
|
||||||
|
* 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<string>();
|
||||||
|
|
||||||
|
/** 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[] {
|
private getAgents(projectId: string): Agent[] {
|
||||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||||
@ -373,6 +450,7 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
cwd: `/home/user/mock-project`,
|
cwd: `/home/user/mock-project`,
|
||||||
rows,
|
rows,
|
||||||
cols,
|
cols,
|
||||||
|
cellKind: this.chatAgents.has(agentId) ? "chat" : "pty",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -457,26 +535,53 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
this.sessionSeq += 1;
|
this.sessionSeq += 1;
|
||||||
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
||||||
const cwd = options.cwd;
|
const cwd = options.cwd;
|
||||||
const enc = new TextEncoder();
|
const cellKind: CellKind = this.chatAgents.has(agentId) ? "chat" : "pty";
|
||||||
const session = new MockPtySession(sessionId, onData);
|
|
||||||
this.sessions.set(sessionId, session);
|
|
||||||
// Record liveness for `listLiveAgents` + the guard above (only when the
|
// 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) {
|
if (options.nodeId) {
|
||||||
this.liveByAgent.set(agentId, options.nodeId);
|
this.liveByAgent.set(agentId, options.nodeId);
|
||||||
this.liveSessionByAgent.set(agentId, sessionId);
|
this.liveSessionByAgent.set(agentId, sessionId);
|
||||||
}
|
}
|
||||||
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
const clearLive = () => {
|
||||||
queueMicrotask(() =>
|
|
||||||
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
|
||||||
);
|
|
||||||
const handle = makeMockHandle(session, () => {
|
|
||||||
this.sessions.delete(sessionId);
|
|
||||||
if (this.liveByAgent.get(agentId) === options.nodeId) {
|
if (this.liveByAgent.get(agentId) === options.nodeId) {
|
||||||
this.liveByAgent.delete(agentId);
|
this.liveByAgent.delete(agentId);
|
||||||
this.liveSessionByAgent.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
|
// 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
|
// 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.
|
// that already carries an id resumes it and nothing new is assigned.
|
||||||
@ -489,6 +594,58 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendPrompt(
|
||||||
|
sessionId: string,
|
||||||
|
prompt: string,
|
||||||
|
onReply: (chunk: ReplyChunk) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
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<ReplyChunk[]> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
async reattach(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
onData: (bytes: Uint8Array) => void,
|
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<void> {},
|
||||||
|
async resize(): Promise<void> {},
|
||||||
|
detach(): void {},
|
||||||
|
async close(): Promise<void> {
|
||||||
|
shutdown();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
|
* 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
|
* back to `onData` (so the xterm wrapper renders typed input) and greets on
|
||||||
|
|||||||
@ -271,6 +271,49 @@ export interface TerminalSession {
|
|||||||
* session assignment and the hosting cell had none yet; absent otherwise.
|
* session assignment and the hosting cell had none yet; absent otherwise.
|
||||||
*/
|
*/
|
||||||
assignedConversationId?: string;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
268
frontend/src/features/chat/AgentChatView.test.tsx
Normal file
268
frontend/src/features/chat/AgentChatView.test.tsx
Normal file
@ -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<typeof vi.fn>;
|
||||||
|
reattach: ReturnType<typeof vi.fn>;
|
||||||
|
send: ReturnType<typeof vi.fn>;
|
||||||
|
onSessionId: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(<AgentChatView {...wire({ sessionId: "s1" }).props} />);
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
|
||||||
|
// 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(<AgentChatView {...w.props} />);
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
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(<AgentChatView {...w.props} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
431
frontend/src/features/chat/AgentChatView.tsx
Normal file
431
frontend/src/features/chat/AgentChatView.tsx
Normal file
@ -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<string>;
|
||||||
|
/**
|
||||||
|
* 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<ReplyChunk[]>;
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
/** 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<Turn[]>([]);
|
||||||
|
/** The in-flight assistant turn, or `null` between turns. */
|
||||||
|
const [pending, setPending] = useState<PendingTurn | null>(null);
|
||||||
|
/** The current input value. */
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
/** A connection/attach error to surface (non-fatal). */
|
||||||
|
const [error, setError] = useState<string | null>(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<string | null>(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<HTMLDivElement | null>(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 (
|
||||||
|
<div
|
||||||
|
data-testid="agent-chat-view"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: "16rem",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
background: "var(--color-surface, #1e1e1e)",
|
||||||
|
color: "var(--color-content, #e0e0e0)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
data-testid="agent-chat-transcript"
|
||||||
|
style={{
|
||||||
|
flex: "1 1 auto",
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "2rem 0.75rem 0.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{turns.map((turn, i) =>
|
||||||
|
turn.kind === "user" ? (
|
||||||
|
<UserBubble key={i} text={turn.text} />
|
||||||
|
) : (
|
||||||
|
<AssistantBubble key={i} text={turn.text} tools={turn.tools} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{pending && (
|
||||||
|
<AssistantBubble
|
||||||
|
text={pending.text}
|
||||||
|
tools={pending.tools}
|
||||||
|
pending
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "crimson",
|
||||||
|
background: "var(--color-surface, #1e1e1e)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 6,
|
||||||
|
padding: 6,
|
||||||
|
borderTop: "1px solid var(--color-border, #3a3a3a)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
aria-label="prompt"
|
||||||
|
data-testid="agent-chat-input"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter submits; Shift+Enter inserts a newline.
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
placeholder="Message à l'agent…"
|
||||||
|
style={{
|
||||||
|
flex: "1 1 auto",
|
||||||
|
resize: "none",
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
background: "var(--color-bg, #141414)",
|
||||||
|
color: "var(--color-content, #e0e0e0)",
|
||||||
|
border: "1px solid var(--color-border, #3a3a3a)",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "6px 8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="send"
|
||||||
|
disabled={input.trim() === ""}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "0 12px",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid var(--color-border, #3a3a3a)",
|
||||||
|
background: "var(--color-accent, #3a6ea5)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: input.trim() === "" ? "default" : "pointer",
|
||||||
|
opacity: input.trim() === "" ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Envoyer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserBubble({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="chat-turn-user"
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
maxWidth: "85%",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
background: "var(--color-accent, #3a6ea5)",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantBubble({
|
||||||
|
text,
|
||||||
|
tools,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
tools: readonly string[];
|
||||||
|
pending?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={pending ? "chat-turn-pending" : "chat-turn-assistant"}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
maxWidth: "85%",
|
||||||
|
background: "var(--color-bg, #141414)",
|
||||||
|
border: "1px solid var(--color-border, #3a3a3a)",
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tools.length > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="chat-tool-activity"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: text ? 6 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tools.map((label, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
data-testid="chat-tool-badge"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "var(--color-surface, #2a2a2a)",
|
||||||
|
color: "var(--color-content-muted, #9a9a9a)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||||
|
{text}
|
||||||
|
{pending && (
|
||||||
|
<span data-testid="chat-cursor" aria-hidden style={{ opacity: 0.6 }}>
|
||||||
|
▋
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as { message: unknown }).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
4
frontend/src/features/chat/index.ts
Normal file
4
frontend/src/features/chat/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** Chat feature (§17.6): structured AI conversation view bound to the `AgentGateway`. */
|
||||||
|
|
||||||
|
export { AgentChatView } from "./AgentChatView";
|
||||||
|
export type { AgentChatViewProps } from "./AgentChatView";
|
||||||
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 { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { Agent } from "@/domain";
|
import type { Agent, CellKind } from "@/domain";
|
||||||
import type { LayoutNode } from "@/domain";
|
import type { LayoutNode } from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
@ -27,6 +27,7 @@ import type {
|
|||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
|
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
|
||||||
|
import { AgentChatView } from "@/features/chat";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
||||||
import { useLayout, type LayoutViewModel } from "./useLayout";
|
import { useLayout, type LayoutViewModel } from "./useLayout";
|
||||||
@ -143,6 +144,17 @@ interface LeafViewProps {
|
|||||||
visibleNodeIds: Set<string>;
|
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
|
* 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
|
* 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 [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
|
||||||
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | 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). */
|
/** Performs the actual launch and persists any assigned id (T4b loop). */
|
||||||
const doLaunch = async (
|
const doLaunch = async (
|
||||||
opts: OpenTerminalOptions,
|
opts: OpenTerminalOptions,
|
||||||
@ -285,6 +327,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
|||||||
if (handle.assignedConversationId) {
|
if (handle.assignedConversationId) {
|
||||||
void vm.setCellConversation(id, 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;
|
return handle;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -336,6 +384,21 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
|||||||
agentGateway.reattach(sessionId, onData)
|
agentGateway.reattach(sessionId, onData)
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="layout-leaf"
|
data-testid="layout-leaf"
|
||||||
@ -455,9 +518,26 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener.
|
{/* Route by derived cell kind (§17.6): a structured AI cell renders the
|
||||||
The cell's persisted session id drives reattach-vs-open so navigating
|
chat view, every other cell (plain terminal or a TUI/PTY agent) keeps
|
||||||
(layout/tab switch) never kills the PTY. */}
|
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
|
<TerminalView
|
||||||
key={`${id}-${agentId ?? "plain"}`}
|
key={`${id}-${agentId ?? "plain"}`}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
@ -466,6 +546,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
|||||||
sessionId={session}
|
sessionId={session}
|
||||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{busyNotice && (
|
{busyNotice && (
|
||||||
<p
|
<p
|
||||||
role="status"
|
role="status"
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import type {
|
|||||||
MemoryType,
|
MemoryType,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
ReplyChunk,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
Skill,
|
Skill,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
@ -165,6 +166,40 @@ export interface AgentGateway {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<ConversationDetails>;
|
): Promise<ConversationDetails>;
|
||||||
|
/**
|
||||||
|
* Sends a prompt to a live **structured** (chat) session and streams the
|
||||||
|
* reply turn back through `onReply` (ARCHITECTURE §17.7, command `agent_send`).
|
||||||
|
* Each {@link ReplyChunk} is delivered as it arrives: `textDelta`s accumulate
|
||||||
|
* into the current turn, `toolActivity` shows tool badges, and the terminal
|
||||||
|
* `final` chunk freezes the turn. The chat twin of a PTY `write`. Resolves once
|
||||||
|
* the turn stream is wired (not when the turn completes).
|
||||||
|
*/
|
||||||
|
sendPrompt(
|
||||||
|
sessionId: string,
|
||||||
|
prompt: string,
|
||||||
|
onReply: (chunk: ReplyChunk) => void,
|
||||||
|
): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Re-attaches a chat view to an **already-living** structured session without
|
||||||
|
* re-sending or re-spawning it (ARCHITECTURE §17.7, command
|
||||||
|
* `reattach_agent_chat`). Returns the retained conversation scrollback (the
|
||||||
|
* chunks already streamed) so the view repaints its prior turns; subsequent
|
||||||
|
* chunks then arrive over `onReply`. The chat twin of {@link reattach}.
|
||||||
|
*
|
||||||
|
* Rejects if no live structured session owns the id (the caller then opens
|
||||||
|
* fresh).
|
||||||
|
*/
|
||||||
|
reattachChat(
|
||||||
|
sessionId: string,
|
||||||
|
onReply: (chunk: ReplyChunk) => void,
|
||||||
|
): Promise<ReplyChunk[]>;
|
||||||
|
/**
|
||||||
|
* Shuts a live structured session down and tears its transport (channel +
|
||||||
|
* conversation scrollback) down (ARCHITECTURE §17.7, command
|
||||||
|
* `close_agent_session`). The chat twin of {@link TerminalGateway.closeTerminal};
|
||||||
|
* reserved for an explicit user action — navigation must never call this.
|
||||||
|
*/
|
||||||
|
closeAgentSession(sessionId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for opening a terminal. */
|
/** Options for opening a terminal. */
|
||||||
@ -222,6 +257,14 @@ export interface TerminalHandle {
|
|||||||
* leaf so the next open resumes instead of re-assigning.
|
* leaf so the next open resumes instead of re-assigning.
|
||||||
*/
|
*/
|
||||||
readonly assignedConversationId?: string;
|
readonly assignedConversationId?: string;
|
||||||
|
/**
|
||||||
|
* How the cell hosting this session should render (ARCHITECTURE §17.6).
|
||||||
|
* Present on handles returned by {@link AgentGateway.launchAgent}: `"chat"`
|
||||||
|
* routes the leaf to an `AgentChatView`, `"pty"` (the default) to a raw
|
||||||
|
* {@link TerminalView}. `undefined` on plain-terminal handles ⇒ treated as
|
||||||
|
* `"pty"`.
|
||||||
|
*/
|
||||||
|
readonly cellKind?: import("@/domain").CellKind;
|
||||||
/** Sends bytes (xterm keystrokes) to the PTY. */
|
/** Sends bytes (xterm keystrokes) to the PTY. */
|
||||||
write(data: Uint8Array): Promise<void>;
|
write(data: Uint8Array): Promise<void>;
|
||||||
/** Resizes the PTY. */
|
/** Resizes the PTY. */
|
||||||
|
|||||||
Reference in New Issue
Block a user