diff --git a/Agent.md b/Agent.md index d597bd6e..e6713d2d 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` (158: 97 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` (690: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 28 transcript + 26 TranscriptView + 22 history + 23 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 + 52 daemonBridge + 9 DaemonBridgeProvider + 45 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` (691: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 26 TranscriptView + 22 history + 23 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 + 52 daemonBridge + 9 DaemonBridgeProvider + 45 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/components/TranscriptView.tsx b/emrg/gui/renderer/src/components/TranscriptView.tsx index d8937eed..a37f7254 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.tsx @@ -32,7 +32,8 @@ import { MarkdownText } from "./MarkdownText"; * 渲染文本,避免破坏块语法解析。 * - 工具行:running 转圈 → done ✓ / failed;耗时 · Ns(仅成功);输出默认隐藏, * 点击行展开(G91/G131 >2000 字符截断 + 展开全文按钮)。 - * - 合并组:bar 摘要(chat.toolGroupSummary)+ 收起/展开,user-expanded 后不再自动收起。 + * - 合并组:bar 摘要(chat.toolGroupSummary)+ 收起/展开,且**只**在宿主点 bar 时改变—— + * 工具边界既不自动展开也不自动收起(rant 2026-10-17T19:23:08)。 * - 本组件不含滚动/欢迎屏副作用(Batch 5 接线时由宿主容器负责)。 */ @@ -466,10 +467,7 @@ function ToolGroupView({ sid: string | null; }) { return ( -
+
store.toggleGroup(sid, entryIndex)} diff --git a/emrg/gui/renderer/src/lib/transcript.test.ts b/emrg/gui/renderer/src/lib/transcript.test.ts index d1c42feb..55b73a9b 100644 --- a/emrg/gui/renderer/src/lib/transcript.test.ts +++ b/emrg/gui/renderer/src/lib/transcript.test.ts @@ -59,23 +59,55 @@ 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); + // 收起后再来新工具并完成 → 仍然收起(不因为 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..e7d958d3 100644 --- a/emrg/gui/renderer/src/lib/transcript.ts +++ b/emrg/gui/renderer/src/lib/transcript.ts @@ -13,8 +13,9 @@ import type { TranslateFn } from "./utils"; * - 文本段封存(rant 21:57:10):助手文本段之后来工具 → 封存当前段(移除 typing), * 后续 delta 开新段,保持 TUI 交错顺序。 * - 工具合并组(rant 21:28:49):前一个工具已完成(.tool-row:not(.running))→ 建组收编; - * 已是组 → 新行并入;组收起后新工具 start 自动展开显示 running 行;全部完成且 ≥2 行 - * → bar 显示摘要(数量 + 总耗时)并自动收起(除非手动展开 user-expanded,rule 5)。 + * 已是组 → 新行并入;≥2 行 → bar 显示摘要(数量 + 总耗时)。**展开状态只在宿主点 bar 时 + * 改变**(rant 2026-10-17T19:23:08):工具开始、结束、追加、摘要变大都不动它。这条规则 + * 取代了原先的「有 running 行就展开、全部完成且 ≥2 行就收起(除非 user-expanded)」。 * - done 收尾:typing 停止、doneRids 记入(>500 清空防长期运行增长)、timeout/maxRounds * 提示系统消息(文案经注入 t 解析,与 copywriting.ts 同模式)。 * @@ -48,11 +49,14 @@ export interface ToolRow { export interface ToolGroup { rows: ToolRow[]; collapsed: boolean; - /** 手动展开后不再自动收起(rule 5);折叠后再手动收起清除 */ - userExpanded: boolean; - /** 组内 <2 行或有 running 行 → bar 隐藏(进行中工具实时可见) */ + /** + * 组内 <2 行 → bar 隐藏(还不成一个可折叠的组)。 + * + * 「或有 running 行」那半句随自动收起一起去掉了:现在 ≥2 行就显示 bar,**包括还在跑的 + * 时候**——这是本改动的第二个可见后果,摘要会在运行中就出现。 + */ barHidden: boolean; - /** ≥2 行全部完成 → 摘要(数量 + 总耗时);否则 null */ + /** ≥2 行 → 摘要(数量 + 总耗时),不再要求全部完成;否则 null */ summary: { count: number; totalElapsed: number } | null; } @@ -262,19 +266,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; } } @@ -462,14 +474,14 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip }; // rant 21:28:49:连续工具合并 —— 最后条目判定 // 1) 前一个工具已完成(.tool-row:not(.running))→ 新建 .tool-group,把旧行移入 rows,新行也入 rows - // 2) 已是 .tool-group → 新行直接入其 rows(组收起时新工具 start 自动展开显示 running) + // 2) 已是 .tool-group → 新行直接入其 rows(**不**动展开状态:组收起时新工具也留在 + // 收起里;展开只由宿主点 bar 决定,rant 2026-10-17T19:23:08) // 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, }; @@ -480,7 +492,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 { @@ -629,13 +642,8 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip 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; - } + // 唯一会改变展开状态的地方(rant 2026-10-17T19:23:08):一个布尔量,一个写入点。 + entry.group.collapsed = !entry.group.collapsed; }); }, };