From 7828d40608a81b539a72a897ba47a53eccfc0bbc Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 8 Aug 2026 21:34:10 +0800 Subject: [PATCH] emrg: GUI fixes for tool spinner, typing cursor, markdown prefix (rants 21:08/21:09/21:10) --- Agent.md | 4 +- README.md | 2 +- emrg/gui/renderer/css/components.css | 4 + emrg/gui/renderer/js/chat.js | 13 +++- emrg/gui/test/renderer.smoke.test.js | 107 ++++++++++++++++++++++++--- 5 files changed, 116 insertions(+), 14 deletions(-) diff --git a/Agent.md b/Agent.md index f256429..01a0184 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` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (96: 22 daemon_client + 22 app-commands + 27 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` (634) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (93: 22 daemon_client + 22 app-commands + 24 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` (96: 22 daemon_client + 22 app-commands + 27 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.md b/README.md index 1b34278..f21604a 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 (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (96: 22 daemon_client + 22 app-commands + 27 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/renderer/css/components.css b/emrg/gui/renderer/css/components.css index d6bc01c..054a1a5 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -227,6 +227,10 @@ animation: spin 0.8s linear infinite; flex-shrink: 0; } +/* rant 21:08:工具完成后 spinner 不再转圈(JS 亦移除元素,CSS 兜底防闪烁) */ +.tool-row:not(.running) .tool-spinner { + display: none; +} .tool-row.running { color: var(--amber); } diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 20f469a..2fc6831 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -147,10 +147,14 @@ const Chat = (() => { for (const node of group.nodes) { const body = node.querySelector(".msg-body") || node; body.classList.remove("typing"); - const text = body.textContent; + // rant 21:10:✦ 标记是元素而非文本——流式时 body.textContent 含 "✦ " 前缀, + // 直接整体 render 会让 "✦ # Title" 等块语法(标题/列表/代码围栏)解析失败 + // (前缀不在行首 → marked 不识别)。渲染前剥离前缀,渲染后重新插入标记保持视觉一致。 + const text = body.textContent.replace(/^✦\s*/, ""); const render = () => { window.emrgMarkdown.renderMarkdown(text).then((html) => { body.innerHTML = html; + body.insertBefore(el("span", { class: "msg-assistant-mark" }, "✦ "), body.firstChild); scrollToBottom(); }); }; @@ -187,6 +191,10 @@ const Chat = (() => { } else if (group.hasText) { // rant 21:57:10:已有文本段之后来了工具 → 封存当前段,后续 delta 新建段(保持 TUI 交错顺序) group.sealed = true; + // rant 21:09:已结束的文本段不再闪烁——封存时移除其 typing 光标, + // 让 ▍ 只保留在最新一段文本后面("前面的文本都结束了还显示闪烁光标")。 + const prevBody = group.node.querySelector(".msg-body") || group.node; + prevBody.classList.remove("typing"); } } const phrases = EMRG_Copy.toolPhrases(data.tool_name); @@ -210,6 +218,9 @@ const Chat = (() => { function handleToolEnd(data) { const row = toolRows.get(data.tool_call_id); if (!row) return; + // rant 21:08:工具执行完成后 spinner 必须停止——移除转圈元素(CSS 亦有 + // .tool-row:not(.running) 隐藏兜底),只保留 ✓ 完成标记,防止"对号前面一直转圈"。 + row.querySelector(".tool-spinner")?.remove(); const ok = !data.error; row.classList.remove("running"); row.classList.add(ok ? "done" : "failed"); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 80e14e7..03c17e0 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -25,10 +25,12 @@ function makeEl(id) { attributes: {}, _cls: new Set(), classList: { - add(c) { node._cls.add(c); node._update(); }, - remove(c) { node._cls.delete(c); node._update(); }, - toggle(c) { node._cls.has(c) ? node._cls.delete(c) : node._cls.add(c); node._update(); }, - contains(c) { return node._cls.has(c); }, + // className 为唯一事实源:el() 直接赋值 className 后 classList 操作不得清空既有类 + _set() { node._cls = new Set((node.className || "").split(/\s+/).filter(Boolean)); }, + add(c) { node.classList._set(); node._cls.add(c); node._update(); }, + remove(c) { node.classList._set(); node._cls.delete(c); node._update(); }, + toggle(c) { node.classList._set(); node._cls.has(c) ? node._cls.delete(c) : node._cls.add(c); node._update(); }, + contains(c) { return (node.className || "").split(/\s+/).includes(c); }, }, _update() { node.className = [...node._cls].join(" "); }, className: "", @@ -43,17 +45,34 @@ function makeEl(id) { scrollHeight: 0, clientHeight: 100, open: false, - appendChild(c) { this.children.push(c); return c; }, + appendChild(c) { c.parentNode = this; this.children.push(c); return c; }, addEventListener() {}, - querySelector() { return null; }, + querySelector(sel) { + // 最小类选择器搜索(chat.js 用 ".msg-body"/".tool-spinner"):DFS 子节点 + 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() { return []; }, closest() { return null; }, showModal() { this.open = true; }, close() { this.open = false; }, setAttribute(k, v) { this.attributes[k] = v; if (k === "value") this.value = v; }, removeAttribute(k) { delete this.attributes[k]; }, - insertBefore(c) { this.children.unshift(c); return c; }, - remove() {}, + insertBefore(c) { c.parentNode = this; this.children.unshift(c); return c; }, + remove() { + // 真实脱离父节点(chat.js handleToolEnd 移除 .tool-spinner) + if (this.parentNode) { + const i = this.parentNode.children.indexOf(this); + if (i >= 0) this.parentNode.children.splice(i, 1); + } + }, focus() {}, select() {}, }; @@ -229,14 +248,14 @@ test("rant 21:57:10:交替文本/工具按顺序交错展示(每段文本独 const kinds = []; const texts = []; const isToolRow = (c) => - c.children.length > 0 && (c.children[0].className || "").includes("tool-spinner"); + (c.className || "").includes("tool-row"); // 用行类而非 spinner 子元素(rant 21:08 后 spinner 完成即移除) for (let i = 0; i < children.length; i++) { const c = children[i]; if (isToolRow(c)) { kinds.push("tool"); } else { kinds.push("text"); - texts.push(c.textContent); + texts.push((c.querySelector(".msg-body") || c).textContent); // 文本在 body 子节点 } } const beforeDone = children.length; @@ -261,6 +280,74 @@ test("rant 21:57:10:交替文本/工具按顺序交错展示(每段文本独 assert.strictEqual(r.typingAfter, 0, "done 后所有文本段 typing 光标应移除"); }); +test("rant 21:08:工具完成后 spinner 停止(元素移除,不再转圈)", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t1", tool_name: "read" }); + const row = $("chat-view").children[1]; // children[0] 是助手文本节点,[1] 是工具行 + const spinnerBefore = (row.querySelector(".tool-spinner") !== null); + EMRG_Chat.handleToolEnd({ tool_call_id: "t1", tool_name: "read", content: "ok", elapsed: 0.3 }); + const spinnerAfter = (row.querySelector(".tool-spinner") !== null); + return { spinnerBefore, spinnerAfter, cls: row.className }; + })()`, ctx); + assert.strictEqual(r.spinnerBefore, true, "工具运行中应有 spinner"); + assert.strictEqual(r.spinnerAfter, false, "工具完成后 spinner 应移除(不得一直转圈)"); + assert.ok(r.cls.includes("done"), `工具行应 done,实际 ${r.cls}`); +}); + +test("rant 21:09:文本段被工具封存后 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 firstNode = $("chat-view").children[0]; + const firstBody = firstNode.querySelector(".msg-body") || firstNode; + const typingBefore = firstBody.classList.contains("typing"); + EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t1", tool_name: "read" }); + const typingAfter = firstBody.classList.contains("typing"); + return { typingBefore, typingAfter }; + })()`, ctx); + assert.strictEqual(r.typingBefore, true, "封存前第一段应有 typing 光标(流式中)"); + assert.strictEqual(r.typingAfter, false, "封存后第一段 typing 光标应移除(光标只留在最新文本段)"); +}); + +test("rant 21:10:done 渲染剥离 ✦ 前缀(标题/列表/代码围栏不被前缀破坏)", async () => { + const { ctx } = makeSandbox(); + await tick(); + const p = vm.runInContext(`(async function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "# 标题\\n- 列表项\\n\\n\\u0060\\u0060\\u0060python\\nprint(1)\\n\\u0060\\u0060\\u0060" }]); + const node = $("chat-view").children[0]; + const body = node.querySelector(".msg-body") || node; + // 模拟真实 DOM:body 内先有 ✦ 标记 span(textContent 含前缀) + body.textContent = "✦ " + body.textContent; + let captured = null; + window.marked = { + use: () => {}, + parse: async (t) => { captured = t; return "

标题

"; }, + }; + EMRG_Chat.handleDone({ request_id: "rid-1" }); + await new Promise((res) => setTimeout(res, 10)); // 等 renderMarkdown microtask + return { + renderedHasHeader: body.innerHTML.includes("h1"), + renderedHasMark: (body.children[0]?.className || "").includes("msg-assistant-mark"), + capturedStartsClean: String(captured).startsWith("# 标题"), + capturedHasPrefix: String(captured).includes("✦"), + }; + })()`, ctx); + const r = await p; + assert.strictEqual(r.capturedStartsClean, true, "传入 marked 的文本应以 # 开头(✦ 前缀已剥离)"); + assert.strictEqual(r.capturedHasPrefix, false, "传入 marked 的文本不得含 ✦ 前缀"); + assert.ok(r.renderedHasHeader, "剥离前缀后 # 标题渲染为 h1(块语法不被破坏)"); + assert.strictEqual(r.renderedHasMark, true, "渲染后 ✦ 标记应重新插入(元素而非文本)"); +}); + test("rant 14:11:首条消息后欢迎屏立即隐藏(append 同步 updateEmptyState)", async () => { const { ctx } = makeSandbox(); await tick();