From d613dad0037230f0cc40b72e5b47987c14ce4594 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 21:40:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20GUI=20chat=20visual=20optimization?= =?UTF-8?q?=20=E2=80=94=20alignment/scroll/timing/compact/consecutive-tool?= =?UTF-8?q?=20grouping=20(rant=202026-08-13T21:28:49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +- emrg/gui/renderer/css/components.css | 139 +++++++++++++++- emrg/gui/renderer/css/layout.css | 5 +- emrg/gui/renderer/js/chat.js | 90 +++++++++- emrg/gui/renderer/js/i18n.js | 16 ++ emrg/gui/test/tool-group.test.js | 236 +++++++++++++++++++++++++++ 6 files changed, 480 insertions(+), 10 deletions(-) create mode 100644 emrg/gui/test/tool-group.test.js diff --git a/Agent.md b/Agent.md index 8f722d6..8fd95af 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (239: 44 daemon_client + 19 conn-manager + 22 app-commands + 117 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (241: 44 daemon_client + 19 conn-manager + 22 app-commands + 117 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -119,7 +119,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (801) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (239: 44 daemon_client + 19 conn-manager + 22 app-commands + 117 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (241: 44 daemon_client + 19 conn-manager + 22 app-commands + 117 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index acb684a..41ab2fa 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -48,6 +48,11 @@ } .msg.user { align-self: flex-end; + /* rant 21:28:49:flex 容器中 auto margin 优先于 align-self——.session-view > * 的 + margin-left/right:auto(居中 760px)盖掉了 align-self:flex-end → 用户气泡被居中 + 而非右对齐。margin-left:auto + margin-right:0(特异性 0,2,0 覆盖 0,1,0)恢复右对齐。 */ + margin-left: auto; + margin-right: 0; background: var(--accent-soft); color: var(--text-1); border-radius: var(--radius-bubble); @@ -217,9 +222,9 @@ display: flex; align-items: center; gap: var(--sp-2); - font-size: var(--fs-secondary); + font-size: var(--fs-small); color: var(--text-2); - padding: 4px 2px; + padding: 2px 2px; border-radius: 8px; cursor: pointer; user-select: none; @@ -232,9 +237,9 @@ } .tool-row .tool-spinner { display: inline-block; - width: 14px; - height: 14px; - border: 2px solid var(--amber-soft); + width: 12px; + height: 12px; + border: 1.5px solid var(--amber-soft); border-top-color: var(--amber); border-radius: 50%; animation: spin 0.8s linear infinite; @@ -286,6 +291,44 @@ margin: 0 0 6px 2px; } +/* rant 21:28:49:工具耗时(成功时 label 后 · 3.2s) */ +.tool-time { + font-size: var(--fs-aux); + color: var(--text-3); + margin-left: 4px; +} + +/* ── 连续工具合并组(方案 A:⌄ N 个工具执行 · Ts) ─────── */ +.tool-group { + display: flex; + flex-direction: column; +} +.tool-group-bar { + display: flex; + align-items: center; + gap: var(--sp-1); + font-size: var(--fs-small); + color: var(--text-3); + padding: 2px 4px; + border-radius: 6px; + cursor: pointer; + user-select: none; + transition: background-color var(--dur-fast) var(--ease); +} +.tool-group-bar:hover { + background: var(--bg-soft); +} +.tool-group-bar .tool-group-chev { + font-size: 10px; + transition: transform var(--dur-med) var(--ease); +} +.tool-group.collapsed .tool-group-rows { + display: none; +} +.tool-group:not(.collapsed) .tool-group-bar .tool-group-chev { + transform: rotate(180deg); +} + /* ── 空状态欢迎屏 ─────────────────────── */ #empty-state { flex: 1; @@ -812,6 +855,92 @@ dialog::backdrop { text-align: center; padding: var(--sp-2); } + +/* ── Rant 管理(rant 2026-08-13:标题 + 分列 + markdown 渲染) ── */ +.workspace-view-title { + font-size: var(--fs-title); + font-weight: 600; + color: var(--text-1); + margin: 0 0 var(--sp-3); +} +.rant-head { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: var(--fs-aux); + color: var(--text-3); + border-bottom: 1px solid var(--border); + user-select: none; +} +.rant-row { + cursor: pointer; +} +.rant-row:hover { + border-color: var(--accent); +} +.rant-col-time { + width: 150px; + flex-shrink: 0; + font-size: var(--fs-aux); + color: var(--text-3); +} +.rant-col-project { + width: 90px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--fs-aux); +} +.rant-col-status { + width: 76px; + flex-shrink: 0; +} +.rant-col-progress { + flex: 1.2; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--fs-aux); + color: var(--text-3); +} +.rant-col-content { + flex: 2; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--fs-secondary); + color: var(--text-2); +} +.rant-detail .rant-md { + max-width: 100%; + padding: var(--sp-2) 0; +} +.rant-progress { + font-size: var(--fs-aux); + color: var(--text-3); + border-top: 1px dashed var(--border); + margin-top: 6px; + padding-top: 6px; +} +/* 状态徽标配色:待处理灰 / 进行中琥珀 / 已完成绿 */ +.task-badge.badge-muted { + color: var(--text-3); + border-color: var(--border); +} +.task-badge.badge-warn { + color: var(--amber); + border-color: var(--amber-soft); + background: var(--amber-soft); +} +.task-badge.badge-done { + color: var(--accent); + border-color: var(--accent-soft); + background: var(--accent-soft); +} .task-form { margin-top: 8px; padding: var(--sp-3); diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 44fd9ac..6b1f103 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -253,9 +253,10 @@ overflow-y: auto; display: none; flex-direction: column; - gap: var(--sp-5); + gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); - scroll-behavior: smooth; + /* rant 21:28:49:去掉 smooth——JS scrollTop=scrollHeight 也走平滑动画,工具行快速 + 连续追加时滚不到底 → 最新内容不可见。JS 跳转全部即时;用户手动滚动不受影响。 */ } .session-view.active { display: flex; diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 3a79e3d..ef9c9f4 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -265,6 +265,33 @@ const Chat = (() => { } } + /** rant 21:28:49:工具合并组 —— 组内工具行容器 + 状态 */ + function updateToolGroup(group, sid) { + const rowsEl = group.querySelector(".tool-group-rows"); + const bar = group.querySelector(".tool-group-bar"); + if (!rowsEl || !bar) return; + const rows = rowsEl.children.filter((c) => c.classList && c.classList.contains("tool-row")); + const running = rows.some((r) => r.classList.contains("running")); + const userExpanded = group.dataset.userExpanded === "1"; + if (rows.length >= 2 && !running) { + // ≥2 行全部完成 → bar 显示 + 摘要(数量 + 总耗时);非手动展开则收起 + const total = rows.reduce((acc, r) => acc + (parseFloat(r.dataset.elapsed) || 0), 0); + const summary = bar.querySelector(".tool-group-summary"); + if (summary) { + summary.textContent = EMRG_Copy._t("chat.toolGroupSummary", { + count: rows.length, + time: total.toFixed(1) + "s", + }); + } + bar.classList.remove("hidden"); + group.classList.toggle("collapsed", !userExpanded); + } else { + // 组内仅 1 行 或 存在 running 行 → bar 隐藏、rows 展开(进行中工具实时可见) + bar.classList.add("hidden"); + group.classList.remove("collapsed"); + } + } + /** 工具友好状态行(进行中 → 完成/失败,默认折叠,点开展示原始输出) */ function handleToolStart(data, sid) { const { groupNodes, toolRows } = st(sid); @@ -300,7 +327,59 @@ const Chat = (() => { row.classList.toggle("expanded", !out.classList.contains("hidden")); } }); - append(row, sid); + + // rant 21:28:49:连续工具合并 —— 会话容器最后一个子节点判定 + // 1) 前一工具已完成(.tool-row:not(.running))→ 新建 .tool-group,把旧行移入 rows,新行也入 rows + // 2) 已是 .tool-group → 新行直接入其 rows(组收起时新工具 start 自动展开显示 running) + // 3) 其他(文本/用户消息/无节点)→ 独立 .tool-row(现状不变;文本穿插不合并) + const cv = chatContainer(sid); + const last = cv.children.length ? cv.children[cv.children.length - 1] : null; + let appendedToGroup = false; + if (last && last.classList) { + if (last.classList.contains("tool-row") && !last.classList.contains("running")) { + const rowsEl = el("div", { class: "tool-group-rows" }); + const bar = el("div", { class: "tool-group-bar hidden" }); + bar.appendChild(el("span", { class: "tool-group-chev" }, "⌄")); + bar.appendChild(el("span", { class: "tool-group-summary" }, "")); + const grp = el("div", { class: "tool-group" }); + grp.appendChild(bar); + grp.appendChild(rowsEl); + // bar 点击 → 展开/收起 rows + chevron 旋转 + 组标 user-expanded(手动展开后不再自动收起) + bar.addEventListener("click", () => { + const wasCollapsed = grp.classList.contains("collapsed"); + if (wasCollapsed) { + grp.classList.remove("collapsed"); + grp.dataset.userExpanded = "1"; + } else { + grp.classList.add("collapsed"); + delete grp.dataset.userExpanded; // 手动收起后新工具完成仍自动收起(rule 5) + } + }); + // 移旧行入组(真实 DOM appendChild 即移动;mock 需先 remove 防残留) + if (last.parentNode) last.remove(); + rowsEl.appendChild(last); + rowsEl.appendChild(row); + cv.appendChild(grp); + appendedToGroup = true; + } else if (last.classList.contains("tool-group")) { + const rowsEl = last.querySelector(".tool-group-rows"); + if (rowsEl) { + rowsEl.appendChild(row); + last.classList.remove("collapsed"); // 组收起后新工具 start → 自动展开显示 running 行 + appendedToGroup = true; + } + } + } + if (appendedToGroup) { + scrollToBottom(sid); + App.updateEmptyState?.(); + const rowsEl = row.parentNode; + if (rowsEl && rowsEl.classList.contains("tool-group-rows")) { + updateToolGroup(rowsEl.parentNode, sid); + } + } else { + append(row, sid); + } toolRows.set(data.tool_call_id, row); } @@ -327,6 +406,10 @@ const Chat = (() => { const elapsed = data.elapsed !== undefined ? `${data.elapsed.toFixed(1)}s` : ""; if (elapsed && ok) { row.title = EMRG_Copy._t("chat.elapsed", { s: elapsed }); + // rant 21:28:49:工具耗时可见 —— label 后 · 3.2s(失败不加) + const time = el("span", { class: "tool-time" }, `· ${elapsed}`); + if (label) label.after(time); + row.dataset.elapsed = String(data.elapsed); // 供合并组摘要求和 } // G91/G131:content 截断 2000 字符 + 展开全文 const content = data.content || ""; @@ -344,6 +427,11 @@ const Chat = (() => { } } scrollToBottom(sid); + // rant 21:28:49:该行在合并组内 → 更新组摘要(数量 + 总耗时)与收起状态 + const rowsEl = row.parentNode; + if (rowsEl && rowsEl.classList && rowsEl.classList.contains("tool-group-rows")) { + updateToolGroup(rowsEl.parentNode, sid); + } } return { diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index fad8278..4b09939 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -33,6 +33,7 @@ const I18N = (() => { "nav.settings": "设置", // 项目面板(rant 14:10:14 P5) + "projects.title": "项目管理", "projects.hint": "查看会话 / 添加项目 / 删除(保留磁盘数据)", "projects.add": "+ 添加项目", "projects.empty": "暂无项目(点击「添加项目」注册工作目录)", @@ -252,6 +253,12 @@ const I18N = (() => { "rants.filterPending": "待处理", "rants.filterInProgress": "进行中", "rants.filterCompleted": "已完成", + "rants.title": "Rant 管理", + "rants.colTime": "时间", + "rants.colProject": "项目", + "rants.colStatus": "状态", + "rants.colProgress": "进度", + "rants.colContent": "内容", "rants.new": "+ 新建 Rant", "rants.message": "内容", "rants.placeholder": "给 EMRG 的反馈或改进建议…", @@ -336,6 +343,7 @@ const I18N = (() => { "chat.maxRoundsHint": "⚠️ 这次任务因为工具调用次数上限中断了,结果可能不完整——输入“继续”我可以接着做。", "chat.elapsed": "耗时 {s}", "chat.expand": "展开全文", + "chat.toolGroupSummary": "{count} 个工具执行 · {time}", // 时间分组(utils.js/sidebar.js) "util.groupToday": "今天", "util.groupYesterday": "昨天", @@ -456,6 +464,7 @@ const I18N = (() => { "nav.settings": "Settings", // Projects panel (rant 14:10:14 P5) + "projects.title": "Project Management", "projects.hint": "View sessions / add / remove (disk data kept)", "projects.add": "+ Add project", "projects.empty": "No projects yet (click “Add project” to register a working directory)", @@ -675,6 +684,12 @@ const I18N = (() => { "rants.filterPending": "Pending", "rants.filterInProgress": "In progress", "rants.filterCompleted": "Completed", + "rants.title": "Rant Management", + "rants.colTime": "Time", + "rants.colProject": "Project", + "rants.colStatus": "Status", + "rants.colProgress": "Progress", + "rants.colContent": "Content", "rants.new": "+ New rant", "rants.message": "Message", "rants.placeholder": "Feedback or improvement ideas for EMRG…", @@ -759,6 +774,7 @@ const I18N = (() => { "chat.maxRoundsHint": "⚠️ This task hit the tool-call limit and may be incomplete — type \"continue\" and I'll keep going.", "chat.elapsed": "took {s}", "chat.expand": "Expand full text", + "chat.toolGroupSummary": "{count} tool calls · {time}", // Time groups (utils.js/sidebar.js) "util.groupToday": "Today", "util.groupYesterday": "Yesterday", diff --git a/emrg/gui/test/tool-group.test.js b/emrg/gui/test/tool-group.test.js new file mode 100644 index 0000000..216a015 --- /dev/null +++ b/emrg/gui/test/tool-group.test.js @@ -0,0 +1,236 @@ +"use strict"; +/** + * tool-group.test.js — 连续工具合并组(rant 2026-08-13T21:28:49 方案 A) + * 独立测试文件:与 renderer.smoke.test.js 分开,避免与其他并行周期的 + * 在途改动(rant 21:36:01 GUI Rant 面板优化)相互污染。 + * 最小沙箱只加载 chat.js 依赖链:utils → i18n → copywriting → chat。 + */ +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const RENDERER_JS = path.join(__dirname, "..", "renderer", "js"); + +// ── DOM mock(与 renderer.smoke.test.js 同构:classList 同步到 className) ── +function makeEl(id) { + const node = { + id, + children: [], + dataset: {}, + style: {}, + attributes: {}, + _cls: new Set(), + classList: { + _set() { node._cls = new Set((node.className || "").split(/\s+/).filter(Boolean)); }, + add(...cs) { node.classList._set(); for (const c of cs) node._cls.add(c); node._update(); }, + remove(c) { node.classList._set(); node._cls.delete(c); node._update(); }, + toggle(c, force) { + node.classList._set(); + const want = force === undefined ? !node._cls.has(c) : !!force; + if (want) node._cls.add(c); else node._cls.delete(c); + node._update(); + }, + contains(c) { return (node.className || "").split(/\s+/).includes(c); }, + }, + _update() { node.className = [...node._cls].join(" "); }, + className: "", + textContent: "", + title: "", + scrollTop: 0, + scrollHeight: 0, + clientHeight: 100, + appendChild(c) { c.parentNode = this; this.children.push(c); return c; }, + addEventListener(type, fn) { this._listeners = this._listeners || {}; (this._listeners[type] = this._listeners[type] || []).push(fn); }, + remove() { + if (this.parentNode) { + const i = this.parentNode.children.indexOf(this); + if (i >= 0) this.parentNode.children.splice(i, 1); + } + }, + querySelector(sel) { + if (!sel || !sel.startsWith(".")) return null; + const cls = sel.slice(1); + const stack = [...(this.children || [])]; + while (stack.length) { + const n = stack.shift(); + if ((n.className || "").split(/\s+/).includes(cls)) return n; + stack.push(...(n.children || [])); + } + return null; + }, + querySelectorAll(sel) { + if (!sel || !sel.startsWith(".")) return []; + const cls = sel.slice(1); + const out = []; + const stack = [...(this.children || [])]; + while (stack.length) { + const n = stack.shift(); + if ((n.className || "").split(/\s+/).includes(cls)) out.push(n); + stack.push(...(n.children || [])); + } + return out; + }, + closest() { return null; }, + after(c) { + if (!this.parentNode) return; + c.parentNode = this.parentNode; + const i = this.parentNode.children.indexOf(this); + if (i >= 0) this.parentNode.children.splice(i + 1, 0, c); + else this.parentNode.children.push(c); + }, + insertBefore(c) { c.parentNode = this; this.children.unshift(c); return c; }, + click() { (this._listeners && this._listeners.click || []).forEach((fn) => fn({ preventDefault() {} })); }, + }; + return node; +} + +/** 构造最小沙箱:只加载 chat.js 依赖链 */ +function makeSandbox(overrides = {}) { + const workspace = makeEl("workspace"); + const els = { workspace }; + const document = { + getElementById: (id) => els[id] || makeEl(id), + createElement: (tag) => makeEl(tag), + createTextNode: (t) => ({ text: t }), + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, + body: { appendChild() {}, removeChild() {}, classList: { add() {}, remove() {} } }, + documentElement: { setAttribute() {}, removeAttribute() {} }, + }; + const win = { + console, + setTimeout, + clearTimeout, + Date, + Math, + JSON, + String, + Number, + Object, + Array, + RegExp, + Map, + Set, + Promise, + requestAnimationFrame: (cb) => cb(), + requestIdleCallback: (cb) => cb(), + navigator: { language: "zh-CN" }, + localStorage: { + _d: {}, + getItem(k) { return Object.prototype.hasOwnProperty.call(this._d, k) ? this._d[k] : null; }, + setItem(k, v) { this._d[k] = String(v); }, + removeItem(k) { delete this._d[k]; }, + }, + DOMPurify: { sanitize: (x) => x }, + marked: null, + hljs: null, + emrgMarkdown: { renderMarkdown: async () => "", streamProject: () => false, streamFinalize: async () => {} }, + App: { + state: { sessionId: "s1", ownStreamRequestId: null }, + updateEmptyState() {}, + }, + // i18n/copywriting 的刷新钩子 + EMRG_I18N: null, + EMRG_Copy: null, + EMRG_Chat: null, + ...overrides, + }; + win.window = win; + win.document = document; + const ctx = vm.createContext(win); + for (const f of ["utils", "i18n", "copywriting", "chat"]) { + const code = fs.readFileSync(path.join(RENDERER_JS, f + ".js"), "utf8"); + vm.runInContext(code, ctx, { filename: "renderer/js/" + f + ".js" }); + } + return { ctx, win, els, document }; +} + +const tick = () => new Promise((r) => setTimeout(r, 20)); + +test("工具合并组:连续工具(无文本穿插)合并 + 摘要 + 收起/展开", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext( + 'Chat.registerContainer("s1", document.getElementById("workspace")); App.state.sessionId = "s1";', + ctx + ); + await tick(); + // 工具 1 完成 + await vm.runInContext('Chat.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1")', ctx); + await tick(); + await vm.runInContext('Chat.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 0.4, content: "" }, "s1")', ctx); + await tick(); + // 工具 2 完成 + await vm.runInContext('Chat.handleToolStart({ tool_call_id: "t2", tool_name: "read", request_id: "r1" }, "s1")', ctx); + await tick(); + await vm.runInContext('Chat.handleToolEnd({ tool_call_id: "t2", tool_name: "read", elapsed: 1.2, content: "" }, "s1")', ctx); + await tick(); + // 工具 3 完成 + await vm.runInContext('Chat.handleToolStart({ tool_call_id: "t3", tool_name: "edit", request_id: "r1" }, "s1")', ctx); + await tick(); + await vm.runInContext('Chat.handleToolEnd({ tool_call_id: "t3", tool_name: "edit", elapsed: 0.3, content: "" }, "s1")', ctx); + await tick(); + + const ws = els["workspace"]; + // 容器应只有 1 个 .tool-group(3 行全部收编),无独立 .tool-row + const groups = ws.querySelectorAll(".tool-group"); + assert.strictEqual(groups.length, 1, `应合并为 1 个组,实际 ${groups.length}`); + const rows = ws.querySelectorAll(".tool-row"); + assert.strictEqual(rows.length, 3, `组内应有 3 个工具行,实际 ${rows.length}`); + // 组收起(全部完成且未手动展开) + assert.ok(groups[0].classList.contains("collapsed"), "全部完成后应自动收起"); + // 摘要:3 个工具执行 · 1.9s(0.4+1.2+0.3) + const summary = groups[0].querySelector(".tool-group-summary"); + assert.ok(summary, "组应有摘要"); + assert.ok(summary.textContent.includes("3"), `摘要应含数量 3,实际 ${summary.textContent}`); + assert.ok(summary.textContent.includes("1.9"), `摘要应含总耗时 1.9s,实际 ${summary.textContent}`); + // 每行有 .tool-time(· 0.4s 等) + const times = ws.querySelectorAll(".tool-time"); + assert.strictEqual(times.length, 3, `3 行都应有耗时,实际 ${times.length}`); + assert.ok(times[0].textContent.includes("0.4"), `行1 耗时 0.4s,实际 ${times[0].textContent}`); + + // bar 点击 → 展开(user-expanded,不再自动收起) + const bar = groups[0].querySelector(".tool-group-bar"); + assert.ok(bar, "组应有 bar"); + bar.click(); + await tick(); + assert.ok(!groups[0].classList.contains("collapsed"), "点击 bar 后应展开"); + assert.strictEqual(groups[0].dataset.userExpanded, "1", "展开后应标 user-expanded"); + // 展开后再来新工具并完成 → 不自动收起(user-expanded) + await vm.runInContext('Chat.handleToolStart({ tool_call_id: "t4", tool_name: "bash", request_id: "r1" }, "s1")', ctx); + await tick(); + await vm.runInContext('Chat.handleToolEnd({ tool_call_id: "t4", tool_name: "bash", elapsed: 0.5, content: "" }, "s1")', ctx); + await tick(); + assert.ok(!groups[0].classList.contains("collapsed"), "user-expanded 后不再自动收起"); + const rowsAfter = ws.querySelectorAll(".tool-row"); + assert.strictEqual(rowsAfter.length, 4, `新工具应并入组,实际 ${rowsAfter.length}`); +}); + +test("文本穿插不合并:工具前有文本 → 独立行", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext( + 'Chat.registerContainer("s1", document.getElementById("workspace")); App.state.sessionId = "s1";', + ctx + ); + await tick(); + // 先来一个工具并完成 + await vm.runInContext('Chat.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1")', ctx); + await tick(); + await vm.runInContext('Chat.handleToolEnd({ tool_call_id: "t1", tool_name: "bash", elapsed: 0.2, content: "" }, "s1")', ctx); + await tick(); + // 文本 delta(穿插)→ 封存当前文本段 + await vm.runInContext('Chat.handleDelta([{ request_id: "r1", text: "思考中…" }], "s1")', ctx); + await tick(); + // 再来工具 → 因上一节点是文本,独立行 + await vm.runInContext('Chat.handleToolStart({ tool_call_id: "t2", tool_name: "read", request_id: "r2" }, "s1")', ctx); + await tick(); + const ws = els["workspace"]; + const groups = ws.querySelectorAll(".tool-group"); + assert.strictEqual(groups.length, 0, `文本穿插后不应合并组,实际 ${groups.length} 组`); + const rows = ws.querySelectorAll(".tool-row"); + assert.strictEqual(rows.length, 2, `两个工具应各自独立,实际 ${rows.length}`); +}); From d84de1e4fabfb19b2db5d91f6d746d24f7564d0d Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 22:00:42 +0800 Subject: [PATCH 2/2] emrg: strip orphaned rant-panel i18n keys + CSS from GUI chat visual PR (fix #771 review) Remove 7 orphaned i18n keys (projects.title, rants.title, rants.col*) and 12 unused CSS classes (.workspace-view-title, .rant-*, .task-badge.badge-*) accidentally included from the parallel rant/projects panel restyle. These belong to the separate rant-panel PR; keep only the chat visual opt (alignment/scroll/timing/compact/grouping). No test count change. --- emrg/gui/renderer/css/components.css | 85 ---------------------------- emrg/gui/renderer/js/i18n.js | 14 ----- 2 files changed, 99 deletions(-) diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index 41ab2fa..0ff469f 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -856,91 +856,6 @@ dialog::backdrop { padding: var(--sp-2); } -/* ── Rant 管理(rant 2026-08-13:标题 + 分列 + markdown 渲染) ── */ -.workspace-view-title { - font-size: var(--fs-title); - font-weight: 600; - color: var(--text-1); - margin: 0 0 var(--sp-3); -} -.rant-head { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - font-size: var(--fs-aux); - color: var(--text-3); - border-bottom: 1px solid var(--border); - user-select: none; -} -.rant-row { - cursor: pointer; -} -.rant-row:hover { - border-color: var(--accent); -} -.rant-col-time { - width: 150px; - flex-shrink: 0; - font-size: var(--fs-aux); - color: var(--text-3); -} -.rant-col-project { - width: 90px; - flex-shrink: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--fs-aux); -} -.rant-col-status { - width: 76px; - flex-shrink: 0; -} -.rant-col-progress { - flex: 1.2; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--fs-aux); - color: var(--text-3); -} -.rant-col-content { - flex: 2; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--fs-secondary); - color: var(--text-2); -} -.rant-detail .rant-md { - max-width: 100%; - padding: var(--sp-2) 0; -} -.rant-progress { - font-size: var(--fs-aux); - color: var(--text-3); - border-top: 1px dashed var(--border); - margin-top: 6px; - padding-top: 6px; -} -/* 状态徽标配色:待处理灰 / 进行中琥珀 / 已完成绿 */ -.task-badge.badge-muted { - color: var(--text-3); - border-color: var(--border); -} -.task-badge.badge-warn { - color: var(--amber); - border-color: var(--amber-soft); - background: var(--amber-soft); -} -.task-badge.badge-done { - color: var(--accent); - border-color: var(--accent-soft); - background: var(--accent-soft); -} .task-form { margin-top: 8px; padding: var(--sp-3); diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 4b09939..f98ec9c 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -33,7 +33,6 @@ const I18N = (() => { "nav.settings": "设置", // 项目面板(rant 14:10:14 P5) - "projects.title": "项目管理", "projects.hint": "查看会话 / 添加项目 / 删除(保留磁盘数据)", "projects.add": "+ 添加项目", "projects.empty": "暂无项目(点击「添加项目」注册工作目录)", @@ -253,12 +252,6 @@ const I18N = (() => { "rants.filterPending": "待处理", "rants.filterInProgress": "进行中", "rants.filterCompleted": "已完成", - "rants.title": "Rant 管理", - "rants.colTime": "时间", - "rants.colProject": "项目", - "rants.colStatus": "状态", - "rants.colProgress": "进度", - "rants.colContent": "内容", "rants.new": "+ 新建 Rant", "rants.message": "内容", "rants.placeholder": "给 EMRG 的反馈或改进建议…", @@ -464,7 +457,6 @@ const I18N = (() => { "nav.settings": "Settings", // Projects panel (rant 14:10:14 P5) - "projects.title": "Project Management", "projects.hint": "View sessions / add / remove (disk data kept)", "projects.add": "+ Add project", "projects.empty": "No projects yet (click “Add project” to register a working directory)", @@ -684,12 +676,6 @@ const I18N = (() => { "rants.filterPending": "Pending", "rants.filterInProgress": "In progress", "rants.filterCompleted": "Completed", - "rants.title": "Rant Management", - "rants.colTime": "Time", - "rants.colProject": "Project", - "rants.colStatus": "Status", - "rants.colProgress": "Progress", - "rants.colContent": "Content", "rants.new": "+ New rant", "rants.message": "Message", "rants.placeholder": "Feedback or improvement ideas for EMRG…",