Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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 <N>` (did the merge land a tree its reviews named)
Expand Down
119 changes: 118 additions & 1 deletion emrg/gui/renderer/src/components/TranscriptView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});
Expand Down Expand Up @@ -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<string, number>();
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();
});
});
74 changes: 57 additions & 17 deletions emrg/gui/renderer/src/components/TranscriptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 追踪 → 新消息不断落到底部遮罩区,需手动滚动。
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -170,7 +165,7 @@ export function TranscriptView({
updateAutoScroll(el);
}
}
}, [version, prepends, sidKey, updateAutoScroll]);
}, [structureVersion, prepends, sidKey, updateAutoScroll]);

return (
<div className="transcript-view" data-testid="transcript-view" ref={viewportRef}>
Expand All @@ -196,8 +191,10 @@ export function TranscriptView({
)
) : null}
{entries.map((entry, i) => (
<EntryView key={i} entry={entry} index={i} t={t} md={md} store={store} sid={sid} />
<EntryView key={entry.id} entry={entry} index={i} t={t} md={md} store={store} sid={sid} />
))}
{/* 跟随滚动:订阅每次变更(含每个 token),但它只重渲染自己 */}
<AutoScroll store={store} viewportRef={viewportRef} autoScrollRef={autoScrollRef} />
{!atBottom ? (
<button
type="button"
Expand All @@ -213,6 +210,47 @@ export function TranscriptView({
);
}

/**
* 流式跟随滚动(rant 2026-08-28T22:36:18:React 迁移丢失 vanilla 的 autoScroll/回到底部)。
*
* 单独成一个组件是为了**成本**,不是为了美观:它必须对每个 token 都反应一次(流式文本是
* 原地 append,条目集合不变),于是它订阅每次变更都动的 `getVersion`。它返回 null,所以
* 每个 token 重渲染的只有这一棵子树;放在 `TranscriptView` 里订阅就是每帧重渲染整条会话。
*/
function AutoScroll({
store,
viewportRef,
autoScrollRef,
}: {
store: TranscriptStore;
viewportRef: { current: HTMLDivElement | null };
autoScrollRef: { current: boolean };
}) {
const version = useSyncExternalStore(store.subscribe, store.getVersion);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
// 首次无条目时不误滚;version 变更即触发(覆盖 delta/tool/done 全部写入)
if (autoScrollRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [version, viewportRef, autoScrollRef]);
return null;
}

/**
* 条目自己的版本号 —— 订阅**下沉到条目级**。
*
* `getEntryVersion` 只在这一条被写过时才变,所以一次 delta 只通知它自己的监听器:会话里
* 有 5 条还是有 500 条,每帧重渲染的都只有正在变的那一条。父组件读的是
* `getStructureVersion`,条目文本增长不动它,整棵树因此也不跟着重渲染。
*/
function useEntryVersion(store: TranscriptStore, id: string): number {
const subscribe = useCallback((listener: () => void) => store.subscribeEntry(id, listener), [store, id]);
const get = useCallback(() => store.getEntryVersion(id), [store, id]);
return useSyncExternalStore(subscribe, get);
}

interface EntryViewProps {
entry: TranscriptEntry;
index: number;
Expand All @@ -223,6 +261,8 @@ interface EntryViewProps {
}

function EntryView({ entry, index, t, md, store, sid }: EntryViewProps): ReactNode {
// 这一条的语言:只有它自己被写过时才重渲染(流式隔离的落点)
useEntryVersion(store, entry.id);
switch (entry.kind) {
case "user":
// Stage 1(rant 2026-08-28T14:07:29):用户消息为 markdown 字符串(tiptap 序列化),
Expand Down
7 changes: 6 additions & 1 deletion emrg/gui/renderer/src/lib/historyReplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ const SID = "s1";
* 没有了(rant 2026-09-29T15:52:49 要求 3),条目形状里不再有可剥的东西。
*/
function comparable(entries: TranscriptEntry[]): unknown {
return JSON.parse(JSON.stringify(entries, (k, v) => (k === "rid" ? undefined : v)));
// `rid` 与 `id` 都随来路而定,都不是内容:前者由回放按「一条用户记录开一轮」合成,
// 后者是 **store 局部**的条目标识(发号器造一条 +1,两条来路各造一次必然不同号)。
// 这里要验的是「同一段记录喂进来,条目内容逐项一致」,所以两样坐标一并剥掉。
return JSON.parse(
JSON.stringify(entries, (k, v) => (k === "rid" || k === "id" ? undefined : v)),
);
}

/** 一条工具调用在两条来路上的公共来源:`args` 会以对象进实时帧、以 JSON 字符串进记录。 */
Expand Down
Loading
Loading