From ba8b59ac570cbff174a2f7f63c0adc3d8159d437 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 7 Aug 2026 14:22:06 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20fix=20GUI=20message=20display=20bugs=20?= =?UTF-8?q?=E2=80=94=20empty-state=20overlay,=20typing=20cursor=20residue,?= =?UTF-8?q?=20false=20from-other-client=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rant 14:11 (host-verified screenshots) reported three GUI display bugs after the first message in a session: 1. Welcome screen (#empty-state) stayed visible on top of the message area — updateEmptyState() was only evaluated on session switch/new, never after append/clear. Fix: Chat.append()/clear() call App.updateEmptyState?.() so welcome visibility always follows chat content. 2. Typing cursor (▍) never disappeared + own replies mislabeled "from other client" — G122 16ms delta batching: done/error/cancelled bypass the buffer, so buffered deltas flushed after done reached the renderer, which then created an orphan node with ownStreamRequestId already null (false label) and no done ever arriving (cursor forever). Fix (main.js): flush deltaBuf synchronously before terminal events (webContents.send preserves order). Defense (chat.js): doneRids set drops stale deltas; cancelled/error clear typing on in-flight nodes. +3 renderer smoke regression tests; docs synced (npm test 88 -> 91). --- Agent.md | 4 +-- README.cn.md | 2 +- README.md | 2 +- emrg/gui/main.js | 26 +++++++++----- emrg/gui/renderer/js/app.js | 2 ++ emrg/gui/renderer/js/chat.js | 21 ++++++++++- emrg/gui/test/renderer.smoke.test.js | 52 ++++++++++++++++++++++++++++ 7 files changed, 96 insertions(+), 13 deletions(-) diff --git a/Agent.md b/Agent.md index 6e72d9d8..e6169d14 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - 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` (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (508) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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/README.cn.md b/README.cn.md index fbe78f10..f0ac6103 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(58 项:22 daemon_client + 7 integration + 25 renderer smoke + 4 app-commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(91 项:22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 804bc7e7..133ee551 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 70f1dd00..0b20c7eb 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -556,21 +556,31 @@ vision = false // G122:message_delta 16ms 批量推送 let deltaBuf = []; let deltaTimer = null; + // rant 14:11:冲刷 delta 缓冲——终态事件(done/error/cancelled)直通不走缓冲, + // 若残留 delta 在 16ms 定时器之后才 flush,会晚于终态到达渲染层 → + // handleDelta 找不到 group 节点 → 建孤儿节点(误标"来自其他客户端")+ 光标永不消失。 + const flushDeltaBuf = () => { + if (deltaTimer) { + clearTimeout(deltaTimer); + deltaTimer = null; + } + if (deltaBuf.length && win && !win.isDestroyed()) { + const chunks = deltaBuf; + deltaBuf = []; + win.webContents.send("emrg:event", { type: "message_delta", data: { chunks } }); + } + }; client.onEvent((type, data) => { if (type === "message_delta") { deltaBuf.push(data); if (!deltaTimer) { - deltaTimer = setTimeout(() => { - const chunks = deltaBuf; - deltaBuf = []; - deltaTimer = null; - if (win && !win.isDestroyed()) { - win.webContents.send("emrg:event", { type: "message_delta", data: { chunks } }); - } - }, 16); + deltaTimer = setTimeout(flushDeltaBuf, 16); } return; } + if (type === "done" || type === "error" || type === "cancelled") { + flushDeltaBuf(); // 终态前先清空缓冲:delta 保证不晚于终态(webContents.send 保序) + } if (type === "done") { // 仅自有流的 done 释放 G65 锁(广播 done 不影响);timeout 兜底同样只清自有 if (data.request_id === ownStreamRequestId || (data.timeout && ownStream)) { diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 267fb132..73de2d60 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -935,6 +935,7 @@ const App = (() => { ResultPanel.addToolResult(data); break; case "cancelled": + Chat.clearTyping(); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清) state.busy = false; state.ownStreamRequestId = null; setComposerDisabled(false); @@ -1003,6 +1004,7 @@ const App = (() => { state.ownStreamRequestId = null; setComposerDisabled(false); } else { + Chat.clearTyping(); // rant 14:11:流式错误时移除在途节点 typing 光标 Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") })); } } diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 1bafb51e..4699000c 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -9,6 +9,8 @@ const Chat = (() => { const groupNodes = new Map(); // tool_call_id → 工具行 DOM 节点 const toolRows = new Map(); + // rant 14:11:已 done 的 request_id(UUID 不复用)——残留 delta 直接丢弃,防孤儿节点 + const doneRids = new Set(); /** 复制代码按钮(设计 §3.3):事件委托在聊天区,CSP 无内联 handler */ function initCodeCopy() { @@ -53,6 +55,8 @@ const Chat = (() => { function append(node) { $("chat-view").appendChild(node); scrollToBottom(); + // rant 14:11:任何消息增删都重新评估欢迎屏显隐(此前只在切会话时评估 → 首条消息后欢迎屏不隐藏) + App.updateEmptyState?.(); } function scrollToBottom() { @@ -64,6 +68,8 @@ const Chat = (() => { $("chat-view").innerHTML = ""; groupNodes.clear(); toolRows.clear(); + doneRids.clear(); + App.updateEmptyState?.(); // rant 14:11:清空(切会话/新会话)也同步欢迎屏显隐 } /** 用户消息:右对齐柔和气泡 */ @@ -96,7 +102,7 @@ const Chat = (() => { function handleDelta(chunks) { for (const chunk of chunks) { const rid = chunk.request_id; - if (!rid) continue; + if (!rid || doneRids.has(rid)) continue; // rant 14:11:已 done 的流丢弃残留 delta,不建孤儿节点 let node = groupNodes.get(rid); if (!node) { const isOwn = App.state.ownStreamRequestId === rid; @@ -110,9 +116,21 @@ const Chat = (() => { } } + /** 取消/错误收尾:移除所有在途节点的 typing 光标(cancelled 事件无 request_id,只能全清) */ + function clearTyping() { + for (const node of groupNodes.values()) { + const body = node.querySelector(".msg-body") || node; + body.classList.remove("typing"); + } + } + /** done:整体 Markdown 渲染(requestIdleCallback 调度,G127) */ function handleDone(data) { const rid = data.request_id; + if (rid) { + doneRids.add(rid); + if (doneRids.size > 500) doneRids.clear(); // UUID 不复用,超限即清防长期运行增长 + } const node = groupNodes.get(rid); if (node) { const body = node.querySelector(".msg-body") || node; @@ -216,6 +234,7 @@ const Chat = (() => { handleDone, handleToolStart, handleToolEnd, + clearTyping, get groupNodes() { return groupNodes; }, diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index e5e56cd9..a1c2404d 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -210,6 +210,58 @@ test("流式 delta 追加 + 工具行 running→done 状态流转", async () => assert.ok(r.toolRowClass.includes("done"), `工具行应 done,实际 ${r.toolRowClass}`); }); +test("rant 14:11:首条消息后欢迎屏立即隐藏(append 同步 updateEmptyState)", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + EMRG_Chat.addUserMessage("hello"); + return { + emptyHidden: $("empty-state").classList.contains("hidden"), + msgCount: $("chat-view").children.length, + }; + })()`, ctx); + assert.strictEqual(r.msgCount, 1, "消息应已追加"); + assert.strictEqual(r.emptyHidden, true, "有消息时欢迎屏应隐藏(欢迎屏不得叠在消息区上方)"); +}); + +test("rant 14:11:done 后残留 delta 被丢弃,不建孤儿节点", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "你" }]); + EMRG_Chat.handleDone({ request_id: "rid-1" }); + const afterDone = $("chat-view").children.length; + // 模拟 16ms 批量定时器在 done 之后才 flush 的残留 delta(G122 竞态) + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "残留" }]); + const afterStale = $("chat-view").children.length; + return { afterDone, afterStale }; + })()`, ctx); + assert.strictEqual(r.afterDone, 1, "done 前 1 个节点"); + assert.strictEqual(r.afterStale, 1, "残留 delta 应被丢弃,不得产生孤儿节点(否则误标来自其他客户端 + 光标残留)"); +}); + +test("rant 14:11:cancelled 事件清除在途节点 typing 光标", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "部分" }]); + const node = $("chat-view").children[0]; + const body = node.querySelector(".msg-body") || node; + body.classList.add("typing"); + const typingBefore = body.classList.contains("typing"); + App.handleEvent({ type: "cancelled" }); + const typingAfter = body.classList.contains("typing"); + return { typingBefore, typingAfter }; + })()`, ctx); + assert.strictEqual(r.typingBefore, true, "取消前应处于 typing 态"); + assert.strictEqual(r.typingAfter, false, "取消后 typing 光标(▍)应移除"); +}); + test("多模型管理:modelDetails 加载渲染 + saveSettings 传 models 数组", async () => { let saved = null; const { ctx, els } = makeSandbox({