From 60dc566d4a1997f040412052ffbcb6e7dafb483a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 3 Nov 2026 15:15:52 +0800 Subject: [PATCH] emrg: a streaming token re-renders only the entry that changed --- Agent.md | 2 +- .../src/components/TranscriptView.test.tsx | 119 +++++++++++++- .../src/components/TranscriptView.tsx | 74 +++++++-- .../renderer/src/lib/historyReplay.test.ts | 7 +- emrg/gui/renderer/src/lib/transcript.test.ts | 106 ++++++++++++- emrg/gui/renderer/src/lib/transcript.ts | 150 ++++++++++++++++-- 6 files changed, 421 insertions(+), 37 deletions(-) diff --git a/Agent.md b/Agent.md index 595296e2..01972795 100644 --- a/Agent.md +++ b/Agent.md @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (172: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash + 4 switch-session) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (755: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 60 daemonBridge + 9 DaemonBridgeProvider + 52 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (764: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 34 transcript + 32 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 60 daemonBridge + 9 DaemonBridgeProvider + 52 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/gui/renderer/src/components/TranscriptView.test.tsx b/emrg/gui/renderer/src/components/TranscriptView.test.tsx index 742caf47..0e5ef883 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.test.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.test.tsx @@ -562,7 +562,7 @@ describe("TranscriptView", () => { }); // 更早一页插到最前 → DOM 变高 400(2 条 → 1200) act(() => { - store.prependEntries([{ kind: "user", text: "older" }], "s1"); + store.prependEntries([{ kind: "user", id: "t-prepended-older", text: "older" }], "s1"); }); expect(viewport.scrollTop).toBe(400); // 0 + (1200 - 800) }); @@ -628,3 +628,120 @@ describe("消息时刻", () => { expect(container.querySelector(".msg-time")).toBeNull(); }); }); + +/** + * 流式隔离:一个 token 只重渲染正在变化的那一条。 + * + * 断言数的是**每条条目各自的重渲染次数**。`useSyncExternalStore` 的 getSnapshot 只在该 + * 条目的组件渲染时被调用,所以「某个 id 的 getEntryVersion 被调了几次」就是它被重渲染了 + * 几次 —— 这正是宿主看见的成本:旧实现每来一个 token 就把整条会话的条目全部渲染一遍, + * 会话越长越慢。 + * + * 判别力(两向都量过):把 `TranscriptView` 的订阅改回 `store.getVersion`(父组件每帧 + * 重渲染、条目元素整批重建),下面第一条与第三条立刻变红 —— 那时每个条目的 getSnapshot + * 都会被各调一次。只加 `React.memo` 挡不住它:那挡的是 props 未变的子组件,而这里父组件 + * 的每帧重渲染会把新元素交给每一个条目。 + */ +describe("流式只重渲染正在变化的那一条", () => { + /** 把 getEntryVersion 包成计数器:每条条目被渲染几次,就记几次 */ + function withRenderCounter(store: TranscriptStore): { + store: TranscriptStore; + rendersOf: (id: string) => number; + reset: () => void; + } { + const renders = new Map(); + const wrapped: TranscriptStore = { + ...store, + getEntryVersion: (id) => { + renders.set(id, (renders.get(id) ?? 0) + 1); + return store.getEntryVersion(id); + }, + }; + return { + store: wrapped, + rendersOf: (id) => renders.get(id) ?? 0, + reset: () => renders.clear(), + }; + } + + /** 若干条已结束的历史 + 一条刚开流的助手条目 */ + function historyThenStream(history = 2): { base: TranscriptStore; liveId: string; otherIds: string[] } { + const base = createTranscriptStore({ t: (k) => k }); + for (let n = 0; n < history; n += 1) { + base.addUserMessage(`q${n}`, "s1"); + base.handleDelta([{ request_id: `r${n}`, content: `a${n}` }], "s1"); + base.handleDone({ request_id: `r${n}` }, "s1"); + } + base.handleDelta([{ request_id: "live", content: "hello" }], "s1"); + const entries = base.getEntries("s1"); + return { + base, + liveId: entries[entries.length - 1].id, + otherIds: entries.slice(0, -1).map((e) => e.id), + }; + } + + it("一个 token 只重渲染正在写的那一条;其余历史条目一次都不重渲染", () => { + const { base, liveId, otherIds } = historyThenStream(); + const { store, rendersOf, reset } = withRenderCounter(base); + setup(store, "s1"); + // 非空主体:没有历史条目时「其余条目零渲染」什么都证明不了 + expect(otherIds.length).toBeGreaterThan(1); + const structure = base.getStructureVersion(); + reset(); + + act(() => { + base.handleDelta([{ request_id: "live", content: " world" }], "s1"); + }); + + expect(rendersOf(liveId)).toBeGreaterThan(0); + for (const id of otherIds) expect(rendersOf(id)).toBe(0); + // 另一半:文本增长不算结构变化,列表不必重读 + expect(base.getStructureVersion()).toBe(structure); + }); + + it("会话有多长都不影响每帧:50 条历史里一个 token 仍然只渲染那一条", () => { + const { base, liveId, otherIds } = historyThenStream(50); + const { store, rendersOf, reset } = withRenderCounter(base); + setup(store, "s1"); + expect(otherIds.length).toBe(100); // 50 轮 → 每条提问一条回复,加一条在流式里 + reset(); + + act(() => { + base.handleDelta([{ request_id: "live", content: "!" }], "s1"); + }); + + expect(rendersOf(liveId)).toBeGreaterThan(0); + // 长会话的每帧成本 = 1 条,不是 101 条 + expect(otherIds.reduce((acc, id) => acc + rendersOf(id), 0)).toBe(0); + }); + + it("条目集合变化时列表照旧更新:新条目出现在 DOM 里(结构通道没被焊死)", async () => { + const { base } = historyThenStream(); + const { store } = withRenderCounter(base); + setup(store, "s1"); + + await act(async () => { + base.addUserMessage("brand new", "s1"); + }); + + await waitFor(() => expect(screen.getByText("brand new")).toBeInTheDocument()); + }); + + it("两次 delta 的文本都进了渲染产物(订阅下沉之后流式没有冻住)", () => { + const base = createTranscriptStore({ t: (k) => k }); + const { store } = withRenderCounter(base); + setup(store, "s1"); + + act(() => { + base.handleDelta([{ request_id: "live", content: "hello" }], "s1"); + }); + expect(screen.getByText("hello")).toBeInTheDocument(); + + act(() => { + base.handleDelta([{ request_id: "live", content: " world" }], "s1"); + }); + // 第二个 token 的文本必须出现在屏幕上:订阅下沉后若某一帧没重渲染,流式就冻在那里了 + expect(screen.getByText("hello world")).toBeInTheDocument(); + }); +}); diff --git a/emrg/gui/renderer/src/components/TranscriptView.tsx b/emrg/gui/renderer/src/components/TranscriptView.tsx index 77726fd3..1c323a33 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.tsx @@ -62,14 +62,17 @@ export function TranscriptView({ loading = false, onLoadOlder, }: TranscriptViewProps) { - // 版本号快照:每次 store 变更 +1(getSnapshot 稳定引用,满足 useSyncExternalStore 要求) - const version = useSyncExternalStore(store.subscribe, store.getVersion); + // 版本号快照:订阅的是**结构**版本 —— 条目集合变了才 +1。流式的文本增长不动它,所以 + // 每来一个 token 都不用把整条会话重新渲染一遍;条目自己的版本由 EntryView 单独订阅 + // (`useEntryVersion`)。订阅仍然走 `store.subscribe`:它只是触发一次取值,值没变 + // React 就跳过渲染,每帧成本因此与条目数无关。 + const structureVersion = useSyncExternalStore(store.subscribe, store.getStructureVersion); const { t } = useI18n(); const md = useMemo(() => renderer ?? createMarkdownRenderer({ t }), [renderer, t]); const entries = store.getEntries(sid); const loadBar = store.getLoadBar(sid); - void version; // 版本号变化 → 重新读取 entries(同一数组引用被原地变更) + void structureVersion; // 版本号变化 → 重新读取 entries(同一数组引用被原地变更) // ── 滚动跟随(rant 2026-08-28T22:36:18:React 迁移丢失 vanilla 的 autoScroll/回到底部) ── // React 版之前无任何 scroll/autoScroll 追踪 → 新消息不断落到底部遮罩区,需手动滚动。 @@ -103,18 +106,10 @@ export function TranscriptView({ return () => el.removeEventListener("scroll", onScroll, { capture: true }); }, [updateAutoScroll, hasMore, loading, onLoadOlder]); - // 新消息 append/流入 → 若 autoScroll 为 true 则滚到底。 - // 依赖 version(每次 store 变更 +1)而非 entries.length:流式文本是原地 append - // 进已有 assistant 段(数组长度不变),依赖 length 会让流式期间不跟随 → 新消息 - // 落到底部遮罩区。version 变化覆盖 delta/tool/done 全部写入,autoScroll 为真即贴底。 - useEffect(() => { - const el = viewportRef.current; - if (!el) return; - if (autoScrollRef.current) { - el.scrollTop = el.scrollHeight; - } - // 首次无条目时不误滚;version 变更即触发 - }, [version]); + // 新消息 append/流入 → 若 autoScroll 为 true 则滚到底。这一件事订阅的是每次变更都动的 + // `getVersion`(流式文本是原地 append 进已有段,结构不变),所以它住在独立的 + // `AutoScroll` 里:每个 token 只重渲染那一棵返回 null 的子树,条目列表不在它的渲染路径上。 + // 旧版把它放在本组件里、依赖 version —— 于是每帧重渲染整条会话,正是要修的那个成本。 const scrollToBottom = () => { const el = viewportRef.current; @@ -170,7 +165,7 @@ export function TranscriptView({ updateAutoScroll(el); } } - }, [version, prepends, sidKey, updateAutoScroll]); + }, [structureVersion, prepends, sidKey, updateAutoScroll]); return (
@@ -196,8 +191,10 @@ export function TranscriptView({ ) ) : null} {entries.map((entry, i) => ( - + ))} + {/* 跟随滚动:订阅每次变更(含每个 token),但它只重渲染自己 */} + {!atBottom ? (