From f74cc9c750161752cf6317863b2872e02233887b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 26 Aug 2026 14:52:53 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20gui=20renderer=20Batch=202=20=E2=80=94?= =?UTF-8?q?=20chat=20transcript=20store=20+=20TranscriptView=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port chat.js logic to a pure per-session state machine (lib/transcript.ts): delta/done/toolStart/toolEnd/clearTyping/clear with sid buckets (P3), tool-group merge (rant 21:28:49: summary + collapse + user-expanded) — 15 vitest tests mirroring the old tool-group.test.js assertions. Add React TranscriptView component: streaming plain-text -> markdown-on-done via injectable renderer, tool rows running/done/failed with elapsed and expandable output, merge-group bar, remote label — 7 component tests. Vanilla renderer stays live until Batch 5 switch (design D3). --- Agent.md | 4 +- .../src/components/TranscriptView.test.tsx | 145 ++++++ .../src/components/TranscriptView.tsx | 234 +++++++++ emrg/gui/renderer/src/lib/transcript.test.ts | 237 +++++++++ emrg/gui/renderer/src/lib/transcript.ts | 467 ++++++++++++++++++ 5 files changed, 1085 insertions(+), 2 deletions(-) create mode 100644 emrg/gui/renderer/src/components/TranscriptView.test.tsx create mode 100644 emrg/gui/renderer/src/components/TranscriptView.tsx create mode 100644 emrg/gui/renderer/src/lib/transcript.test.ts create mode 100644 emrg/gui/renderer/src/lib/transcript.ts diff --git a/Agent.md b/Agent.md index b55b8924..70e61de5 100644 --- a/Agent.md +++ b/Agent.md @@ -86,7 +86,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - Unit tests `npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t); Batch 1 remainder: i18n.js full dictionary (373 keys zh/en parity, auto-generated `lib/i18n-dicts.ts` + detectLocale/getLocale/setLocale/t) + markdown.js → `lib/markdown.ts` (marked/DOMPurify/hljs injectable, fence-closed heuristic + block-projection streaming) + - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t); Batch 1 remainder: i18n.js full dictionary (373 keys zh/en parity, auto-generated `lib/i18n-dicts.ts` + detectLocale/getLocale/setLocale/t) + markdown.js → `lib/markdown.ts` (marked/DOMPurify/hljs injectable, fence-closed heuristic + block-projection streaming); Batch 2: chat.js → `lib/transcript.ts` (pure per-session state machine — delta/done/toolStart/toolEnd/merge-group/clearTyping, sid buckets P3, 15 tests) + `components/TranscriptView.tsx` (React chat area: streaming plain-text → markdown-on-done via injectable renderer, tool rows running→done/failed with elapsed + expandable output, merge-group bar summary + collapse/user-expanded, remote label, 7 tests) - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -120,7 +120,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` Python: `uv run pytest tests/ -v` (1095) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (56 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown) + `npm run build` → `renderer/dist/` +GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (78 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/components/TranscriptView.test.tsx b/emrg/gui/renderer/src/components/TranscriptView.test.tsx new file mode 100644 index 00000000..87d66296 --- /dev/null +++ b/emrg/gui/renderer/src/components/TranscriptView.test.tsx @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { act } from "react"; +import { render, screen } from "@testing-library/react"; +import { TranscriptView } from "./TranscriptView"; +import { createTranscriptStore, type TranscriptStore } from "../lib/transcript"; +import { I18nProvider } from "../lib/i18n"; +import type { MarkdownRenderer } from "../lib/markdown"; + +/** + * TranscriptView.test.tsx — 聊天区 React 组件测试(Batch 2,设计 §5 Batch 2 项 6 验收)。 + * 注入假 markdown 渲染器(无 marked vendor 依赖),断言消息渲染/工具流转/合并组交互。 + */ + +// 假渲染器:renderMarkdown 包一层 .md-test 便于断言 done 后的 HTML 注入 +const fakeMd: MarkdownRenderer = { + renderMarkdown: async (text) => `
${text}
`, + streamProject: () => false, + streamFinalize: async () => {}, +}; + +function setup(store: TranscriptStore, sid?: string | null) { + return render( + + + , + ); +} + +describe("TranscriptView", () => { + it("渲染用户/系统/历史消息与历史加载条", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.addUserMessage("hello", "s1"); + store.addSystemMessage("system note", "s1"); + store.addHistoryMessage("old message", "s1"); + store.setLoadBar("加载历史中…", "s1"); + const { container } = setup(store, "s1"); + expect(screen.getByText("hello")).toHaveClass("msg", "user"); + expect(screen.getByText("system note")).toHaveClass("msg", "system"); + expect(screen.getByText("old message")).toHaveClass("history"); + expect(container.querySelector(".history-load-bar")).toHaveTextContent("加载历史中…"); + }); + + it("流式显示纯文本,done 后渲染 markdown(✦ 前缀不破坏块语法)", async () => { + const store = createTranscriptStore({ t: (k) => k }); + store.handleDelta([{ request_id: "r1", content: "**bold** text" }], "s1"); + const { container } = setup(store, "s1"); + const body = container.querySelector(".msg-body"); + expect(body).toHaveClass("typing"); + expect(body!.textContent).toContain("**bold** text"); + act(() => { + store.handleDone({ request_id: "r1" }, "s1"); + }); + // done → typing 移除 + 假渲染器输出 .md-test(整体 markdown;await 覆盖异步渲染与重渲染) + expect(container.querySelector(".msg-body")).not.toHaveClass("typing"); + const mdDiv = await screen.findByText("**bold** text", { selector: ".md-test" }); + expect(mdDiv).toBeInTheDocument(); + // ✦ 标记独立于渲染文本 + expect(container.querySelector(".msg-assistant-mark")).toHaveTextContent("✦"); + }); + + it("工具行 running → done:label 切换 + ✓ + 耗时 + 输出默认隐藏、点击展开", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.handleToolStart( + { tool_call_id: "t1", tool_name: "bash", request_id: "r1", intent: "check config" }, + "s1", + ); + const { container } = setup(store, "s1"); + const row = container.querySelector(".tool-row")!; + expect(row).toHaveClass("running"); + expect(screen.getByText("正在运行命令…")).toBeInTheDocument(); + expect(screen.getByText("check config")).toHaveClass("tool-intent"); + act(() => { + store.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 0.4, content: "out" }, "s1"); + }); + expect(row).toHaveClass("done"); + expect(screen.getByText("已运行命令")).toBeInTheDocument(); + expect(screen.getByText("· 0.4s")).toHaveClass("tool-time"); + const output = container.querySelector(".tool-output")!; + expect(output).toHaveClass("hidden"); + expect(output).toHaveTextContent("out"); + // 点击行 → 展开输出 + act(() => { + (row as HTMLElement).click(); + }); + expect(output).not.toHaveClass("hidden"); + }); + + it("失败工具行:failed label(tool.failText)且无耗时", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + store.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 9.9, error: "boom" }, "s1"); + const { container } = setup(store, "s1"); + const row = container.querySelector(".tool-row")!; + expect(row).toHaveClass("failed"); + expect(screen.getByText("这一步没成功,我换个方法试试")).toBeInTheDocument(); + expect(container.querySelector(".tool-time")).not.toBeInTheDocument(); + }); + + it("连续工具合并组:摘要 + 收起,bar 点击展开后不再自动收起", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + store.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 0.4, content: "" }, "s1"); + store.handleToolStart({ tool_call_id: "t2", tool_name: "read", request_id: "r1" }, "s1"); + store.handleToolEnd({ tool_call_id: "t2", tool_name: "read", elapsed: 1.2, content: "" }, "s1"); + const { container } = setup(store, "s1"); + const group = container.querySelector(".tool-group")!; + expect(group).toHaveClass("collapsed"); + expect(container.querySelectorAll(".tool-row")).toHaveLength(2); + expect(container.querySelector(".tool-group-summary")).toHaveTextContent("2 个工具执行 · 1.6s"); + // bar 点击 → 展开 + user-expanded + act(() => { + (container.querySelector(".tool-group-bar") as HTMLElement).click(); + }); + expect(group).not.toHaveClass("collapsed"); + expect((group as HTMLElement).dataset.userExpanded).toBe("1"); + // 新工具并入组且不自动收起 + act(() => { + store.handleToolStart({ tool_call_id: "t3", tool_name: "edit", request_id: "r1" }, "s1"); + store.handleToolEnd({ tool_call_id: "t3", tool_name: "edit", elapsed: 0.3, content: "" }, "s1"); + }); + expect(container.querySelectorAll(".tool-row")).toHaveLength(3); + expect(group).not.toHaveClass("collapsed"); + expect(container.querySelector(".tool-group-summary")).toHaveTextContent("3 个工具执行 · 1.9s"); + }); + + it("其他客户端的广播流带 remote 标签;own stream 不带", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.handleDelta([{ request_id: "r1", content: "broadcast" }], "s1"); + const { container } = setup(store, "s1"); + expect(screen.getByText("(来自其他客户端)")).toBeInTheDocument(); + // own stream → 无标签 + const store2 = createTranscriptStore({ t: (k) => k }); + store2.setOwnStream("r2"); + store2.handleDelta([{ request_id: "r2", content: "own" }], "s1"); + const { container: c2 } = setup(store2, "s1"); + expect(c2.querySelector(".remote-label")).not.toBeInTheDocument(); + }); + + it("sid=null 缺省桶:无 sid 事件渲染到默认视图", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.addUserMessage("legacy", null); + setup(store); + expect(screen.getByText("legacy")).toHaveClass("msg", "user"); + }); +}); diff --git a/emrg/gui/renderer/src/components/TranscriptView.tsx b/emrg/gui/renderer/src/components/TranscriptView.tsx new file mode 100644 index 00000000..92cc59be --- /dev/null +++ b/emrg/gui/renderer/src/components/TranscriptView.tsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import type { ReactNode } from "react"; +import { + type AssistantEntry, + type AssistantSegment, + type ToolGroup, + type ToolRow, + type TranscriptEntry, + type TranscriptStore, +} from "../lib/transcript"; +import { createMarkdownRenderer, type MarkdownRenderer } from "../lib/markdown"; +import { toolPhrases } from "../lib/copywriting"; +import { useI18n, type TranslateFn } from "../lib/i18n"; + +/** + * TranscriptView — 聊天区 React 渲染组件(Batch 2,设计 §5 Batch 2 项 1–3)。 + * 消费 lib/transcript.ts 纯状态机(每会话独立状态桶 P3),消息/工具行/合并组 + * 全部数据驱动渲染。与 chat.js 的 DOM 结构逐类对齐(.msg.user / .msg.assistant / + * .tool-row / .tool-group / .history-load-bar 等类名不变,Batch 5 切换时复用 + * vanilla CSS,无需改样式表)。 + * + * - 流式(typing)→ 纯文本;done → createMarkdownRenderer 整体渲染(与旧 + * done 渲染同源 renderMarkdown;vendor marked/DOMPurify/hljs 构造注入, + * 无 vendor 时降级转义)。✦ 标记独立于渲染文本,避免破坏块语法解析。 + * - 工具行:running 转圈 → done ✓ / failed;耗时 · Ns(仅成功);输出默认隐藏, + * 点击行展开(G91/G131 >2000 字符截断 + 展开全文按钮)。 + * - 合并组:bar 摘要(chat.toolGroupSummary)+ 收起/展开,user-expanded 后不再自动收起。 + * - 本组件不含滚动/欢迎屏副作用(Batch 5 接线时由宿主容器负责)。 + */ + +export interface TranscriptViewProps { + store: TranscriptStore; + sid?: string | null; + /** 注入 markdown 渲染器(测试/浏览器接线用;缺省按 t 构造降级渲染器) */ + renderer?: MarkdownRenderer; +} + +export function TranscriptView({ store, sid = null, renderer }: TranscriptViewProps) { + // 版本号快照:每次 store 变更 +1(getSnapshot 稳定引用,满足 useSyncExternalStore 要求) + const version = useSyncExternalStore(store.subscribe, store.getVersion); + const { t } = useI18n(); + const md = useMemo(() => renderer ?? createMarkdownRenderer({ t }), [renderer, t]); + const entries = store.getEntries(sid); + const loadBar = store.getLoadBar(sid); + + void version; // 版本号变化 → 重新读取 entries(同一数组引用被原地变更) + + return ( +
+ {loadBar ?
{loadBar}
: null} + {entries.map((entry, i) => ( + + ))} +
+ ); +} + +interface EntryViewProps { + entry: TranscriptEntry; + index: number; + t: TranslateFn; + md: MarkdownRenderer; + store: TranscriptStore; + sid: string | null; +} + +function EntryView({ entry, index, t, md, store, sid }: EntryViewProps): ReactNode { + switch (entry.kind) { + case "user": + return
{entry.text}
; + case "history": + return
{entry.text}
; + case "system": + return
{entry.text}
; + case "assistant": + return ; + case "tool-row": + return ; + case "tool-group": + return ; + } +} + +/** 助手消息:每文本段一个 .msg.assistant 节点(chat.js createAssistantNode 语义) */ +function AssistantView({ entry, t, md }: { entry: AssistantEntry; t: TranslateFn; md: MarkdownRenderer }) { + return ( + <> + {entry.segments.map((seg, i) => ( + + ))} + + ); +} + +function AssistantSegmentView({ + entry, + segment, + t, + md, +}: { + entry: AssistantEntry; + segment: AssistantSegment; + t: TranslateFn; + md: MarkdownRenderer; +}) { + return ( +
+ {!entry.isOwn ?
{t("chat.fromOtherClient")}
: null} +
+ + {segment.typing ? ( + // 流式纯文本(chat.js 无投影时的 textContent 追加路径;投影优化在浏览器接线批次) + {segment.text} + ) : ( + + )} +
+
+ ); +} + +/** done 后整体 markdown 渲染(与旧 done 渲染同源 renderMarkdown;✦ 标记在组件层,剥离前缀) */ +function MarkdownText({ text, md }: { text: string; md: MarkdownRenderer }) { + const [html, setHtml] = useState(null); + useEffect(() => { + let cancelled = false; + md.renderMarkdown(text.replace(/^✦\s*/, "")).then((h) => { + if (!cancelled) setHtml(h); + }); + return () => { + cancelled = true; + }; + }, [text, md]); + if (html === null) return {text}; + return ; +} + +/** 工具友好状态行(进行中 → 完成/失败,默认折叠,点开展示原始输出) */ +function ToolRowView({ + row, + t, + store, + sid, +}: { + row: ToolRow; + t: TranslateFn; + store: TranscriptStore; + sid: string | null; +}) { + const phrases = toolPhrases(row.toolName, t); + const label = + row.status === "running" ? phrases.doing : row.status === "done" ? phrases.done : t("tool.failText"); + const ok = row.status === "done"; + const timeText = ok && row.elapsed !== undefined ? `· ${row.elapsed.toFixed(1)}s` : null; + const title = + ok && row.elapsed !== undefined ? t("chat.elapsed", { s: row.elapsed.toFixed(1) + "s" }) : undefined; + const content = row.content || ""; + const truncated = content.length > 2000 ? content.slice(0, 2000) + "…" : content; + const showExpand = content.length > 2000 && !row.fullExpanded; + + return ( +
store.toggleRowOutput(sid, row.callId)} + > + {row.status === "running" ? : null} + {ok ? : null} + {label} + {row.intent ? {row.intent} : null} + {timeText ? {timeText} : null} + + {content ? ( +
+ {row.fullExpanded ? content : truncated} +
+ ) : null} + {showExpand ? ( + + ) : null} +
+ ); +} + +/** 连续工具合并组:bar 摘要 + 收起/展开(rant 21:28:49) */ +function ToolGroupView({ + group, + entryIndex, + t, + store, + sid, +}: { + group: ToolGroup; + entryIndex: number; + t: TranslateFn; + store: TranscriptStore; + sid: string | null; +}) { + return ( +
+
store.toggleGroup(sid, entryIndex)} + > + + + {group.summary + ? t("chat.toolGroupSummary", { + count: group.summary.count, + time: group.summary.totalElapsed.toFixed(1) + "s", + }) + : ""} + +
+
+ {group.rows.map((row) => ( + + ))} +
+
+ ); +} diff --git a/emrg/gui/renderer/src/lib/transcript.test.ts b/emrg/gui/renderer/src/lib/transcript.test.ts new file mode 100644 index 00000000..2611599d --- /dev/null +++ b/emrg/gui/renderer/src/lib/transcript.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { + createTranscriptStore, + type AssistantEntry, + type ToolGroup, + type TranscriptEntry, + type ToolRow, +} from "./transcript"; + +/** + * transcript.test.ts — 聊天区纯状态机测试(Batch 2)。 + * 镜像旧 emrg/gui/test/tool-group.test.js 的断言(工具合并组/文本穿插不合并), + * 并覆盖 chat.js 的流式 delta / done / 会话隔离 / 清除逻辑。 + */ +const identity = (k: string): string => k; + +function store(opts?: { t?: (k: string) => string }) { + return createTranscriptStore({ t: opts?.t ?? identity }); +} + +function assistantOf(entries: TranscriptEntry[], rid: string): AssistantEntry | undefined { + const e = entries.find((x) => x.kind === "assistant" && x.rid === rid); + return e?.kind === "assistant" ? e : undefined; +} + +function groupOf(entries: TranscriptEntry[]): ToolGroup | undefined { + const e = entries.find((x) => x.kind === "tool-group"); + return e?.kind === "tool-group" ? e.group : undefined; +} + +function rowByCallId(entries: TranscriptEntry[], callId: string): ToolRow | undefined { + for (const e of entries) { + if (e.kind === "tool-row" && e.row.callId === callId) return e.row; + if (e.kind === "tool-group") { + const r = e.group.rows.find((x) => x.callId === callId); + if (r) return r; + } + } + return undefined; +} + +describe("工具合并组(rant 21:28:49,镜像 tool-group.test.js)", () => { + it("连续工具(无文本穿插)合并为 1 组 + 摘要 + 收起/展开", () => { + const s = store(); + // 工具 1 完成 + s.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + s.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 0.4, content: "" }, "s1"); + // 工具 2 完成 + s.handleToolStart({ tool_call_id: "t2", tool_name: "read", request_id: "r1" }, "s1"); + s.handleToolEnd({ tool_call_id: "t2", tool_name: "read", elapsed: 1.2, content: "" }, "s1"); + // 工具 3 完成 + s.handleToolStart({ tool_call_id: "t3", tool_name: "edit", request_id: "r1" }, "s1"); + s.handleToolEnd({ tool_call_id: "t3", tool_name: "edit", elapsed: 0.3, content: "" }, "s1"); + + const entries = s.getEntries("s1"); + const groups = entries.filter((e) => e.kind === "tool-group"); + expect(groups).toHaveLength(1); // 应合并为 1 个组 + expect(entries.filter((e) => e.kind === "tool-row")).toHaveLength(0); // 无独立行 + const group = groupOf(entries)!; + expect(group.rows).toHaveLength(3); // 组内 3 行 + // 组收起(全部完成且未手动展开) + expect(group.collapsed).toBe(true); + // 摘要:3 个工具执行 · 1.9s(0.4+1.2+0.3) + expect(group.summary?.count).toBe(3); + expect(group.summary?.totalElapsed.toFixed(1)).toBe("1.9"); + expect(group.barHidden).toBe(false); + + // bar 点击 → 展开(user-expanded,不再自动收起) + const gi = entries.indexOf(groups[0]!); + s.toggleGroup("s1", gi); + expect(group.collapsed).toBe(false); + expect(group.userExpanded).toBe(true); + // 展开后再来新工具并完成 → 不自动收起(user-expanded) + s.handleToolStart({ tool_call_id: "t4", tool_name: "bash", request_id: "r1" }, "s1"); + s.handleToolEnd({ tool_call_id: "t4", tool_name: "bash", elapsed: 0.5, content: "" }, "s1"); + expect(group.rows).toHaveLength(4); // 新工具应并入组 + expect(group.collapsed).toBe(false); + }); + + it("文本穿插不合并:工具前有文本 → 独立行", () => { + const s = store(); + // 先来一个工具并完成 + s.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + s.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 0.2, content: "" }, "s1"); + // 文本 delta(穿插)——旧测试传 text 字段,chat.js 只读 content,故不产生文本 + s.handleDelta([{ request_id: "r1", content: "" }], "s1"); + // 再来工具 → 因上一节点是助手文本,独立行 + s.handleToolStart({ tool_call_id: "t2", tool_name: "read", request_id: "r2" }, "s1"); + + const entries = s.getEntries("s1"); + expect(entries.filter((e) => e.kind === "tool-group")).toHaveLength(0); + expect(entries.filter((e) => e.kind === "tool-row")).toHaveLength(2); + }); +}); + +describe("流式 delta", () => { + it("按 rid 累加文本到当前文本段,typing=true", () => { + const s = store(); + s.handleDelta([{ request_id: "r1", content: "Hel" }, { request_id: "r1", content: "lo" }], "s1"); + const e = assistantOf(s.getEntries("s1"), "r1"); + expect(e).toBeDefined(); + expect(e!.segments).toHaveLength(1); + expect(e!.segments[0].text).toBe("Hello"); + expect(e!.segments[0].typing).toBe(true); + expect(e!.isOwn).toBe(false); + }); + + it("上一文本段被工具封存后,新 delta 开新段", () => { + const s = store(); + s.handleDelta([{ request_id: "r1", content: "思考中…" }], "s1"); + s.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + s.handleDelta([{ request_id: "r1", content: "继续输出" }], "s1"); + const e = assistantOf(s.getEntries("s1"), "r1"); + expect(e!.segments).toHaveLength(2); + expect(e!.segments[0].sealed).toBe(true); + expect(e!.segments[0].typing).toBe(false); // 封存时移除 typing(rant 21:09) + expect(e!.segments[1].text).toBe("继续输出"); + expect(e!.segments[1].sealed).toBe(false); + }); + + it("done 之后到达的残留 delta 被丢弃(rant 14:11)", () => { + const s = store(); + s.handleDelta([{ request_id: "r1", content: "done" }], "s1"); + s.handleDone({ request_id: "r1" }, "s1"); + s.handleDelta([{ request_id: "r1", content: "late" }], "s1"); + const e = assistantOf(s.getEntries("s1"), "r1"); + expect(e!.segments[0].text).toBe("done"); + }); +}); + +describe("done 收尾", () => { + it("done 停止 typing 且保留条目(渲染层负责 markdown)", () => { + const s = store(); + s.handleDelta([{ request_id: "r1", content: "text" }], "s1"); + s.handleDone({ request_id: "r1" }, "s1"); + const e = assistantOf(s.getEntries("s1"), "r1"); + expect(e).toBeDefined(); + expect(e!.segments[0].typing).toBe(false); + // done 后 groupIndex 移除:同 rid 再 tool_start → 新建助手条目 + s.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + expect(s.getEntries("s1").filter((x) => x.kind === "assistant")).toHaveLength(2); + }); + + it("timeout → 系统消息(经注入 t 解析)", () => { + const s = store(); + s.handleDone({ request_id: "r1", timeout: true }, "s1"); + const entries = s.getEntries("s1"); + expect(entries.some((e) => e.kind === "system" && e.text === "chat.timeoutWarn")).toBe(true); + }); + + it("maxRounds 截断 → 系统提示", () => { + const s = store(); + s.handleDone({ request_id: "r1", content: "exceeded max tool rounds" }, "s1"); + expect(s.getEntries("s1").some((e) => e.kind === "system" && e.text === "chat.maxRoundsHint")).toBe(true); + // 不匹配 → 无提示 + const s2 = store(); + s2.handleDone({ request_id: "r1", content: "normal reply" }, "s1"); + expect(s2.getEntries("s1").filter((e) => e.kind === "system")).toHaveLength(0); + }); +}); + +describe("会话级状态隔离(P3)", () => { + it("每 sid 独立桶;sid=null 为旧版单会话桶", () => { + const s = store(); + s.handleDelta([{ request_id: "r1", content: "a" }], "s1"); + s.handleDelta([{ request_id: "r1", content: "b" }], "s2"); + s.handleDelta([{ request_id: "r1", content: "c" }]); // 无 sid → null 桶 + expect(assistantOf(s.getEntries("s1"), "r1")!.segments[0].text).toBe("a"); + expect(assistantOf(s.getEntries("s2"), "r1")!.segments[0].text).toBe("b"); + expect(assistantOf(s.getEntries(), "r1")!.segments[0].text).toBe("c"); + expect(s.getEntries("s1")).toHaveLength(1); + expect(s.getEntries("s2")).toHaveLength(1); + expect(s.getEntries()).toHaveLength(1); + }); + + it("unregisterSession 释放会话桶", () => { + const s = store(); + s.handleDelta([{ request_id: "r1", content: "x" }], "s1"); + s.unregisterSession("s1"); + expect(s.getEntries("s1")).toHaveLength(0); + // 再次使用自动重建(chat.js st() 惰性建桶) + s.handleDelta([{ request_id: "r1", content: "y" }], "s1"); + expect(assistantOf(s.getEntries("s1"), "r1")!.segments[0].text).toBe("y"); + }); +}); + +describe("clear / clearTyping / 行交互", () => { + it("clear 重置条目、索引与加载条", () => { + const s = store(); + s.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + s.handleDelta([{ request_id: "r1", content: "hi" }], "s1"); + s.setLoadBar("加载历史中…", "s1"); + s.clear("s1"); + expect(s.getEntries("s1")).toHaveLength(0); + expect(s.getLoadBar("s1")).toBeNull(); + }); + + it("clearTyping 停止所有助手段 typing", () => { + const s = store(); + s.handleDelta([{ request_id: "r1", content: "a" }], "s1"); + s.handleDelta([{ request_id: "r2", content: "b" }], "s1"); + s.clearTyping("s1"); + for (const e of s.getEntries("s1")) { + if (e.kind === "assistant") for (const seg of e.segments) expect(seg.typing).toBe(false); + } + }); + + it("toggleRowOutput 切换行输出可见性", () => { + const s = store(); + s.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + s.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 0.1, content: "out" }, "s1"); + expect(rowByCallId(s.getEntries("s1"), "t1")!.outputExpanded).toBe(false); + s.toggleRowOutput("s1", "t1"); + expect(rowByCallId(s.getEntries("s1"), "t1")!.outputExpanded).toBe(true); + }); + + it("失败的工具行不记录耗时(供合并组摘要求和时为 0)", () => { + const s = store(); + s.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); + s.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 9.9, content: "", error: "boom" }, "s1"); + const row = rowByCallId(s.getEntries("s1"), "t1")!; + expect(row.status).toBe("failed"); + expect(row.elapsed).toBeUndefined(); + }); + + it("订阅机制:变更通知 + 版本号递增", () => { + const s = store(); + let calls = 0; + const unsub = s.subscribe(() => calls++); + s.addSystemMessage("x", "s1"); + expect(calls).toBe(1); + expect(s.getVersion()).toBe(1); + unsub(); + s.addSystemMessage("y", "s1"); + expect(calls).toBe(1); + }); +}); diff --git a/emrg/gui/renderer/src/lib/transcript.ts b/emrg/gui/renderer/src/lib/transcript.ts new file mode 100644 index 00000000..c2052ce9 --- /dev/null +++ b/emrg/gui/renderer/src/lib/transcript.ts @@ -0,0 +1,467 @@ +import type { TranslateFn } from "./utils"; + +/** + * transcript.ts — 聊天区纯状态机(Batch 2,设计 §5 Batch 2 项 1–3)。 + * 源:vanilla renderer/js/chat.js(471 行)。chat.js 直接操作 DOM;本模块把 + * 消息/工具行/合并组建模为数据,React TranscriptView 消费渲染(D3:旧 vanilla + * 保持不动直到 Batch 5 一次性切换,故本模块独立存在、可脱离 DOM 测试)。 + * + * 迁移保真的关键行为(逐条对齐 chat.js): + * - P3(rant 15:07:19)会话级状态隔离:每 sid 一份 entries/groupIndex/toolRowIndex/doneRids; + * sid=null 为旧版单会话桶(无 sid 事件 → 行为与改造前完全一致)。 + * - 流式 delta(G122):按 rid 累加进当前文本段;rid 已 done → 丢弃残留 delta(rant 14:11)。 + * - 文本段封存(rant 21:57:10):助手文本段之后来工具 → 封存当前段(移除 typing), + * 后续 delta 开新段,保持 TUI 交错顺序。 + * - 工具合并组(rant 21:28:49):前一个工具已完成(.tool-row:not(.running))→ 建组收编; + * 已是组 → 新行并入;组收起后新工具 start 自动展开显示 running 行;全部完成且 ≥2 行 + * → bar 显示摘要(数量 + 总耗时)并自动收起(除非手动展开 user-expanded,rule 5)。 + * - done 收尾:typing 停止、doneRids 记入(>500 清空防长期运行增长)、timeout/maxRounds + * 提示系统消息(文案经注入 t 解析,与 copywriting.ts 同模式)。 + * + * 本模块不含 DOM/滚动/欢迎屏副作用(append/scrollToBottom/App.updateEmptyState 属组件层)。 + */ + +/** 工具行(chat.js .tool-row 的数据化) */ +export interface ToolRow { + callId: string; + toolName: string; + status: "running" | "done" | "failed"; + intent?: string; + /** 仅成功且提供 elapsed 时记录(chat.js 只在 ok 时写 dataset.elapsed,供合并组摘要求和) */ + elapsed?: number; + content?: string; + /** 点击行切换输出可见性(.tool-output hidden) */ + outputExpanded: boolean; + /** 展开全文按钮(G91/G131:content >2000 字符截断 + 展开) */ + fullExpanded: boolean; +} + +/** 工具合并组(rant 21:28:49)——摘要 + 收起/展开状态 */ +export interface ToolGroup { + rows: ToolRow[]; + collapsed: boolean; + /** 手动展开后不再自动收起(rule 5);折叠后再手动收起清除 */ + userExpanded: boolean; + /** 组内 <2 行或有 running 行 → bar 隐藏(进行中工具实时可见) */ + barHidden: boolean; + /** ≥2 行全部完成 → 摘要(数量 + 总耗时);否则 null */ + summary: { count: number; totalElapsed: number } | null; +} + +/** 助手消息文本段(同 rid 流式分段:段被工具封存后新 delta 开新段) */ +export interface AssistantSegment { + text: string; + hasText: boolean; + sealed: boolean; + typing: boolean; +} + +export interface AssistantEntry { + kind: "assistant"; + rid: string; + isOwn: boolean; + segments: AssistantSegment[]; +} + +export interface UserEntry { + kind: "user"; + text: string; +} +export interface SystemEntry { + kind: "system"; + text: string; +} +export interface HistoryEntry { + kind: "history"; + text: string; +} +export interface ToolRowEntry { + kind: "tool-row"; + row: ToolRow; +} +export interface ToolGroupEntry { + kind: "tool-group"; + group: ToolGroup; +} + +/** 消息列表条目(渲染层按 kind 分发组件) */ +export type TranscriptEntry = + | UserEntry + | SystemEntry + | HistoryEntry + | AssistantEntry + | ToolRowEntry + | ToolGroupEntry; + +/** 单会话状态桶(P3:每 sid 一份) */ +export interface SessionTranscript { + sid: string | null; + entries: TranscriptEntry[]; + /** 顶部历史加载条(rant 14:15:12;text=null 移除) */ + loadBar: string | null; + /** rid → entries 下标(助手条目映射;done 后删除,迟到 delta 由 doneRids 拦截) */ + groupIndex: Map; + /** callId → { entry: 条目下标, row: 组内行号(独立行 = null) } */ + toolRowIndex: Map; + doneRids: Set; +} + +/* ── 事件入参(与 main/daemon 协议字段对齐,仅取 chat.js 用到的) ── */ +export interface DeltaChunk { + request_id?: string; + content?: string; +} +export interface DoneData { + request_id?: string; + timeout?: boolean; + content?: string; +} +export interface ToolStartData { + request_id?: string; + tool_call_id: string; + tool_name: string; + intent?: string; +} +export interface ToolEndData { + tool_call_id: string; + tool_name: string; + elapsed?: number; + content?: string; + error?: unknown; +} + +/** 订阅接口(React useSyncExternalStore 消费) */ +export interface TranscriptStore { + subscribe(listener: () => void): () => void; + /** 单调版本号:每次变更 +1(getSnapshot 缓存值,稳定引用) */ + getVersion(): number; + registerSession(sid: string | null): void; + unregisterSession(sid?: string | null): void; + st(sid?: string | null): SessionTranscript; + getEntries(sid?: string | null): TranscriptEntry[]; + getLoadBar(sid?: string | null): string | null; + /** 设置 own stream request id(决定助手消息是否显示“来自其他客户端”标签) */ + setOwnStream(rid: string | null): void; + handleDelta(chunks: DeltaChunk[], sid?: string | null): void; + handleDone(data: DoneData, sid?: string | null): void; + handleToolStart(data: ToolStartData, sid?: string | null): void; + handleToolEnd(data: ToolEndData, sid?: string | null): void; + clearTyping(sid?: string | null): void; + clear(sid?: string | null): void; + addUserMessage(text: string, sid?: string | null): void; + addSystemMessage(text: string, sid?: string | null): void; + addHistoryMessage(text: string, sid?: string | null): void; + setLoadBar(text: string | null, sid?: string | null): void; + toggleRowOutput(sid: string | null, callId: string): void; + expandRowContent(sid: string | null, callId: string): void; + toggleGroup(sid: string | null, entryIndex: number): void; +} + +const SID_NULL = "__emrg_null_sid__"; + +function assistantHasText(entry: AssistantEntry): boolean { + return entry.segments.some((seg) => seg.hasText); +} + +/** 更新工具合并组展示状态(chat.js updateToolGroup 数据化) */ +function updateToolGroup(group: ToolGroup): void { + const running = group.rows.some((r) => r.status === "running"); + if (group.rows.length >= 2 && !running) { + const total = group.rows.reduce((acc, r) => acc + (r.elapsed ?? 0), 0); + group.summary = { count: group.rows.length, totalElapsed: total }; + group.barHidden = false; + group.collapsed = !group.userExpanded; + } else { + // 组内仅 1 行 或 存在 running 行 → bar 隐藏、rows 展开(进行中工具实时可见) + group.summary = null; + group.barHidden = true; + group.collapsed = false; + } +} + +export function createTranscriptStore(opts: { t?: TranslateFn } = {}): TranscriptStore { + const t = opts.t ?? ((key: string): string => key); + const sessions = new Map(); + let ownStreamRequestId: string | null = null; + let version = 0; + const listeners = new Set<() => void>(); + + function notify(): void { + version++; + for (const listener of [...listeners]) listener(); + } + + function key(sid?: string | null): string { + return sid || SID_NULL; + } + + function st(sid?: string | null): SessionTranscript { + const k = key(sid); + let s = sessions.get(k); + if (!s) { + s = { + sid: sid || null, + entries: [], + loadBar: null, + groupIndex: new Map(), + toolRowIndex: new Map(), + doneRids: new Set(), + }; + sessions.set(k, s); + } + return s; + } + + /** 变更包装:所有 mutation 走这里统一通知订阅者 */ + function mutate(fn: () => void): void { + fn(); + notify(); + } + + function findRow( + s: SessionTranscript, + callId: string, + ): { row: ToolRow; entry: number; rowIdx: number | null } | undefined { + const loc = s.toolRowIndex.get(callId); + if (!loc) return undefined; + const entry = s.entries[loc.entry]; + if (!entry) return undefined; + if (entry.kind === "tool-row" && entry.row.callId === callId) { + return { row: entry.row, entry: loc.entry, rowIdx: null }; + } + if (entry.kind === "tool-group") { + const row = entry.group.rows[loc.row ?? -1]; + if (row && row.callId === callId) return { row, entry: loc.entry, rowIdx: loc.row }; + } + return undefined; + } + + function handleDelta(chunks: DeltaChunk[], sid?: string | null): void { + mutate(() => { + const s = st(sid); + for (const chunk of chunks) { + const rid = chunk.request_id; + if (!rid || s.doneRids.has(rid)) continue; // rant 14:11:已 done 的流丢弃残留 delta + let entryIndex = s.groupIndex.get(rid); + let entry = entryIndex !== undefined ? s.entries[entryIndex] : undefined; + if (!entry || entry.kind !== "assistant") { + const e: AssistantEntry = { kind: "assistant", rid, isOwn: ownStreamRequestId === rid, segments: [] }; + s.entries.push(e); + s.groupIndex.set(rid, s.entries.length - 1); + } + const as = s.entries[s.groupIndex.get(rid)!] as AssistantEntry; + const active = as.segments[as.segments.length - 1]; + if (!active || active.sealed) { + // rant 21:57:10:上一文本段被工具行“封存”→ 新文本段开新节点 + as.segments.push({ text: "", hasText: false, sealed: false, typing: true }); + } + const seg = as.segments[as.segments.length - 1]; + const content = chunk.content || ""; + if (content) seg.hasText = true; + seg.text += content; + } + }); + } + + function handleDone(data: DoneData, sid?: string | null): void { + mutate(() => { + const s = st(sid); + const rid = data.request_id; + if (rid) { + s.doneRids.add(rid); + if (s.doneRids.size > 500) s.doneRids.clear(); // UUID 不复用,超限即清 + const entryIndex = s.groupIndex.get(rid); + if (entryIndex !== undefined) { + const entry = s.entries[entryIndex]; + if (entry && entry.kind === "assistant") { + for (const seg of entry.segments) seg.typing = false; + } + s.groupIndex.delete(rid); // 渲染完成 → 移除映射(chat.js groupNodes.delete) + } + } + if (data.timeout) { + s.entries.push({ kind: "system", text: t("chat.timeoutWarn") }); + } + // 工具调用次数上限中断(跨项目教训:截断的工作不提示 = 用户拿半成品) + if (data.content && /exceeded/i.test(data.content) && /max|limit|round/i.test(data.content)) { + s.entries.push({ kind: "system", text: t("chat.maxRoundsHint") }); + } + }); + } + + function handleToolStart(data: ToolStartData, sid?: string | null): void { + mutate(() => { + const s = st(sid); + const rid = data.request_id; + if (rid) { + let entryIndex = s.groupIndex.get(rid); + let entry = entryIndex !== undefined ? s.entries[entryIndex] : undefined; + if (!entry || entry.kind !== "assistant") { + // G104:tool_start 也建组(LLM 先出 tool_calls 后出文本) + const e: AssistantEntry = { kind: "assistant", rid, isOwn: ownStreamRequestId === rid, segments: [] }; + s.entries.push(e); + s.groupIndex.set(rid, s.entries.length - 1); + } else if (assistantHasText(entry)) { + // rant 21:57:10:已有文本段之后来了工具 → 封存当前段 + const active = entry.segments[entry.segments.length - 1]; + active.sealed = true; + // rant 21:09:已结束的文本段不再闪烁——封存时移除 typing + active.typing = false; + } + } + const row: ToolRow = { + callId: data.tool_call_id, + toolName: data.tool_name, + status: "running", + intent: data.intent, + outputExpanded: false, + fullExpanded: false, + }; + // rant 21:28:49:连续工具合并 —— 最后条目判定 + // 1) 前一个工具已完成(.tool-row:not(.running))→ 新建 .tool-group,把旧行移入 rows,新行也入 rows + // 2) 已是 .tool-group → 新行直接入其 rows(组收起时新工具 start 自动展开显示 running) + // 3) 其他(文本/用户消息/无条目)→ 独立 .tool-row(文本穿插不合并) + const last = s.entries[s.entries.length - 1]; + if (last && last.kind === "tool-row" && last.row.status !== "running") { + const group: ToolGroup = { + rows: [last.row, row], + collapsed: false, + userExpanded: false, + barHidden: true, + summary: null, + }; + s.entries[s.entries.length - 1] = { kind: "tool-group", group }; + s.toolRowIndex.set(last.row.callId, { entry: s.entries.length - 1, row: 0 }); + s.toolRowIndex.set(row.callId, { entry: s.entries.length - 1, row: 1 }); + updateToolGroup(group); + } else if (last && last.kind === "tool-group") { + const g = last.group; + g.rows.push(row); + g.collapsed = false; // 组收起后新工具 start → 自动展开显示 running 行 + s.toolRowIndex.set(row.callId, { entry: s.entries.length - 1, row: g.rows.length - 1 }); + updateToolGroup(g); + } else { + s.entries.push({ kind: "tool-row", row }); + s.toolRowIndex.set(row.callId, { entry: s.entries.length - 1, row: null }); + } + }); + } + + function handleToolEnd(data: ToolEndData, sid?: string | null): void { + mutate(() => { + const s = st(sid); + const found = findRow(s, data.tool_call_id); + if (!found) return; + const ok = !data.error; + found.row.status = ok ? "done" : "failed"; + // rant 21:08:spinner 停止(数据层 = status 切换);耗时只在 ok 时记录 + if (ok && data.elapsed !== undefined) found.row.elapsed = data.elapsed; + if (data.content) found.row.content = data.content; + // 该行在合并组内 → 更新组摘要(数量 + 总耗时)与收起状态 + if (found.rowIdx !== null) { + const entry = s.entries[found.entry]; + if (entry && entry.kind === "tool-group") updateToolGroup(entry.group); + } + }); + } + + function clearTyping(sid?: string | null): void { + mutate(() => { + const s = st(sid); + for (const e of s.entries) { + if (e.kind === "assistant") { + for (const seg of e.segments) seg.typing = false; + } + } + }); + } + + function clear(sid?: string | null): void { + mutate(() => { + const s = st(sid); + // 清空消息但保留容器(app.js 的 .session-header 属组件层 DOM,不在此模型内) + s.entries = []; + s.groupIndex.clear(); + s.toolRowIndex.clear(); + s.doneRids.clear(); + s.loadBar = null; + }); + } + + return { + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getVersion: () => version, + registerSession: (sid) => { + st(sid); + }, + unregisterSession: (sid) => { + mutate(() => { + sessions.delete(key(sid)); + }); + }, + st, + getEntries: (sid) => st(sid).entries, + getLoadBar: (sid) => st(sid).loadBar, + setOwnStream: (rid) => { + mutate(() => { + ownStreamRequestId = rid; + }); + }, + handleDelta, + handleDone, + handleToolStart, + handleToolEnd, + clearTyping, + clear, + addUserMessage: (text, sid) => { + mutate(() => { + st(sid).entries.push({ kind: "user", text }); + }); + }, + addSystemMessage: (text, sid) => { + mutate(() => { + st(sid).entries.push({ kind: "system", text }); + }); + }, + addHistoryMessage: (text, sid) => { + mutate(() => { + st(sid).entries.push({ kind: "history", text }); + }); + }, + setLoadBar: (text, sid) => { + mutate(() => { + st(sid).loadBar = text; + }); + }, + toggleRowOutput: (sid, callId) => { + mutate(() => { + const found = findRow(st(sid), callId); + if (found) found.row.outputExpanded = !found.row.outputExpanded; + }); + }, + expandRowContent: (sid, callId) => { + mutate(() => { + const found = findRow(st(sid), callId); + if (found) found.row.fullExpanded = true; + }); + }, + toggleGroup: (sid, entryIndex) => { + mutate(() => { + const entry = st(sid).entries[entryIndex]; + if (!entry || entry.kind !== "tool-group") return; + if (entry.group.collapsed) { + entry.group.collapsed = false; + entry.group.userExpanded = true; + } else { + entry.group.collapsed = true; + entry.group.userExpanded = false; + } + }); + }, + }; +}