From 1def53d4f58e96474ce8e5e4082527465273713e Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 17 Oct 2026 19:47:13 +0800 Subject: [PATCH] emrg: a tool group's expansion state changes only when the host clicks it --- Agent.md | 2 +- emrg/gui/renderer/css/components.css | 5 ++ .../src/components/TranscriptView.test.tsx | 25 +++++++--- emrg/gui/renderer/src/lib/transcript.test.ts | 47 ++++++++++++++++--- emrg/gui/renderer/src/lib/transcript.ts | 23 ++++++--- 5 files changed, 80 insertions(+), 22 deletions(-) diff --git a/Agent.md b/Agent.md index 91fb5d87..75aee619 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` (156: 95 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (684: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 28 transcript + 26 TranscriptView + 22 history + 20 historyReplay + 38 composer + 65 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 + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 50 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (685: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 26 TranscriptView + 22 history + 20 historyReplay + 38 composer + 65 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 + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 50 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 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 answers a question 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` (two PRs together; every step of an 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/css/components.css b/emrg/gui/renderer/css/components.css index 4e7c9813..4362c607 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -367,6 +367,11 @@ pre.tool-detail-body { align-items: center; gap: var(--sp-1); font-size: var(--fs-small); + /* 固定一行高:计数与总耗时会随调用次数变长("3 个工具" → "12 个工具"), + 没有固定行高时标签一变长就把整个块重新排版一次——那是自动收起之外的第二处跳动 + (rant 2026-10-17T19:23:08)。 */ + line-height: 18px; + height: 22px; color: var(--text-3); padding: 2px 4px; border-radius: 6px; diff --git a/emrg/gui/renderer/src/components/TranscriptView.test.tsx b/emrg/gui/renderer/src/components/TranscriptView.test.tsx index 8ad0b3bf..273ee5a5 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.test.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.test.tsx @@ -411,7 +411,7 @@ describe("TranscriptView", () => { expect(container.querySelector(".tool-time")).not.toBeInTheDocument(); }); - it("连续工具合并组:摘要 + 收起,bar 点击展开后不再自动收起", () => { + it("连续工具合并组:摘要随调用增长,收起只由点击决定(rant 2026-10-17T19:23:08)", () => { 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"); @@ -419,23 +419,34 @@ describe("TranscriptView", () => { 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"); + // 宿主还没点过 bar:组是展开的,而且此后每一次开始/结束/追加都不改变它—— + // 旧版本在这里断言 `collapsed`,那个断言正是"每个 tool_end 收起、每个 tool_start + // 展开"的跳动被当成正确行为的地方。 + expect(group).not.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(); + store.handleToolStart({ tool_call_id: "t3", tool_name: "edit", request_id: "r1" }, "s1"); }); + expect(container.querySelectorAll(".tool-row")).toHaveLength(3); 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"); + // 点击 bar → 收起(唯一入口) + act(() => { + (container.querySelector(".tool-group-bar") as HTMLElement).click(); + }); + expect(group).toHaveClass("collapsed"); + // 收起之后再来一个工具:仍然收起,不因为 tool_start 就翻开 + act(() => { + store.handleToolStart({ tool_call_id: "t4", tool_name: "bash", request_id: "r1" }, "s1"); + }); + expect(group).toHaveClass("collapsed"); }); it("广播流与 own stream 均不显示 remote 标签(rant 2026-08-28T22:16:36 去掉)", () => { diff --git a/emrg/gui/renderer/src/lib/transcript.test.ts b/emrg/gui/renderer/src/lib/transcript.test.ts index d1c42feb..c83e2063 100644 --- a/emrg/gui/renderer/src/lib/transcript.test.ts +++ b/emrg/gui/renderer/src/lib/transcript.test.ts @@ -59,23 +59,56 @@ describe("工具合并组(rant 21:28:49,镜像 tool-group.test.js)", () => 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) + // 宿主没点过 bar ⇒ 组从头到尾是展开的。这里断言的是 rant 2026-10-17T19:23:08 的规则: + // 展开状态**只**在点击时改变,工具的开始与结束都不动它。旧版本在这一行断言 `true`, + // 而那个 `true` 正是每个 tool_end 收起、每个 tool_start 展开的跳动来源。 + expect(group.collapsed).toBe(false); + // 摘要: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,不再自动收起) + // 点击 bar → 收起(唯一会改变状态的入口) const gi = entries.indexOf(groups[0]!); s.toggleGroup("s1", gi); - expect(group.collapsed).toBe(false); - expect(group.userExpanded).toBe(true); - // 展开后再来新工具并完成 → 不自动收起(user-expanded) + expect(group.collapsed).toBe(true); + expect(group.userExpanded).toBe(false); + // 收起后再来新工具并完成 → 仍然收起(不因为 tool_start 就翻开) s.handleToolStart({ tool_call_id: "t4", tool_name: "bash", request_id: "r1" }, "s1"); + expect(group.collapsed, "tool_start must not expand a collapsed group").toBe(true); s.handleToolEnd({ tool_call_id: "t4", tool_name: "bash", elapsed: 0.5, content: "" }, "s1"); expect(group.rows).toHaveLength(4); // 新工具应并入组 + expect(group.collapsed, "tool_end must not change the state either").toBe(true); + expect(group.summary?.count).toBe(4); // 计数照旧在位更新 + + // 再点一次 → 展开,且保持展开 + s.toggleGroup("s1", gi); expect(group.collapsed).toBe(false); + s.handleToolStart({ tool_call_id: "t5", tool_name: "bash", request_id: "r1" }, "s1"); + s.handleToolEnd({ tool_call_id: "t5", tool_name: "bash", elapsed: 0.1, content: "" }, "s1"); + expect(group.collapsed).toBe(false); + }); + + it("五个连续工具:每个边界都不改变展开状态(rant 2026-10-17T19:23:08 的验收)", () => { + // 记录「可见行数 + bar」这个高度代理在每一个 tool_start / tool_end 上的取值: + // 它必须单调不减,即块永远不会在边界处缩回去。step 为单位,故用数组比对。 + const s = store(); + const heights: number[] = []; + const sample = () => { + const g = groupOf(s.getEntries("s1")); + if (!g) return 0; + return (g.barHidden ? 0 : 1) + (g.collapsed ? 0 : g.rows.length); + }; + for (let i = 1; i <= 5; i++) { + s.handleToolStart({ tool_call_id: `c${i}`, tool_name: "bash", request_id: "r1" }, "s1"); + heights.push(sample()); + s.handleToolEnd({ tool_call_id: `c${i}`, tool_name: "bash", elapsed: 0.2, content: "" }, "s1"); + heights.push(sample()); + } + for (let i = 1; i < heights.length; i++) { + expect(heights[i], `height shrank at step ${i}: ${heights.join(",")}`).toBeGreaterThanOrEqual(heights[i - 1]!); + } + expect(heights[heights.length - 1]).toBe(6); // 5 行 + bar }); it("文本穿插不合并:工具前有文本 → 独立行", () => { diff --git a/emrg/gui/renderer/src/lib/transcript.ts b/emrg/gui/renderer/src/lib/transcript.ts index 0fa84fbe..96f1cbef 100644 --- a/emrg/gui/renderer/src/lib/transcript.ts +++ b/emrg/gui/renderer/src/lib/transcript.ts @@ -262,19 +262,27 @@ export function formatToolArguments(value: unknown): string { } } -/** 更新工具合并组展示状态(chat.js updateToolGroup 数据化) */ +/** 更新工具合并组的**摘要**——只数数,不动展开状态(rant 2026-10-17T19:23:08)。 + * + * 这里曾经按「有没有 running 行」决定收起:`collapsed = !userExpanded`(≥2 行且全部完成) + * 与 `collapsed = false`(有 running 行)。于是**每一个工具边界都翻转一次**——`tool_end` + * 收起、`tool_start` 展开——组的高度在 1 行与 N 行之间来回摆,五个工具摆五次,滚动位置跟着跳。 + * `userExpanded` 只保护「宿主亲手展开过」的组,没被点过的组照样每调一次翻一次;而旧测试 + * 恰恰把这个翻转断言为正确行为,所以套件一直是绿的。 + * + * 规则:**组的展开状态只在宿主点它的 bar 时改变**(`toggleGroup`),别的时候——工具开始、 + * 结束、追加、摘要变大——都不动。新建的组初始是展开的(进行中的工具必须看得见),且此后 + * 不会自己收起:轨道允许一次性的收尾收起,但那是有意的设计选择,不是"每调一次顺手收一下"。 + */ function updateToolGroup(group: ToolGroup): void { - const running = group.rows.some((r) => r.status === "running"); - if (group.rows.length >= 2 && !running) { + if (group.rows.length >= 2) { 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 展开(进行中工具实时可见) + // 组内仅 1 行 → 还没有可折叠的东西,bar 不显示 group.summary = null; group.barHidden = true; - group.collapsed = false; } } @@ -480,7 +488,8 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip } else if (last && last.kind === "tool-group") { const g = last.group; g.rows.push(row); - g.collapsed = false; // 组收起后新工具 start → 自动展开显示 running 行 + // 不展开、不收起了:宿主收起过的组,新工具也保持收起(rant 2026-10-17T19:23:08)。 + // 正在跑的那一行如果要一直看得见,那是另一条"常显"行的事,不是把上面的行翻开的理由。 s.toolRowIndex.set(row.callId, { entry: s.entries.length - 1, row: g.rows.length - 1 }); updateToolGroup(g); } else {