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` (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 <branch>`, 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 <N>` (did the merge land a tree its reviews named)
Expand Down
5 changes: 5 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 18 additions & 7 deletions emrg/gui/renderer/src/components/TranscriptView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,31 +411,42 @@ 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");
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");
// 宿主还没点过 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 去掉)", () => {
Expand Down
8 changes: 3 additions & 5 deletions emrg/gui/renderer/src/components/TranscriptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 接线时由宿主容器负责)。
*/

Expand Down Expand Up @@ -466,10 +467,7 @@ function ToolGroupView({
sid: string | null;
}) {
return (
<div
className={`tool-group ${group.collapsed ? "collapsed" : ""}`}
data-user-expanded={group.userExpanded ? "1" : undefined}
>
<div className={`tool-group ${group.collapsed ? "collapsed" : ""}`}>
<div
className={`tool-group-bar ${group.barHidden ? "hidden" : ""}`}
onClick={() => store.toggleGroup(sid, entryIndex)}
Expand Down
46 changes: 39 additions & 7 deletions emrg/gui/renderer/src/lib/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("文本穿插不合并:工具前有文本 → 独立行", () => {
Expand Down
52 changes: 30 additions & 22 deletions emrg/gui/renderer/src/lib/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 同模式)。
*
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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,
};
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
});
},
};
Expand Down
Loading