From e993c814600bd358e1585c15c1d685ce3ccae222 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 11 Aug 2026 13:43:32 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20workspace=20panel=20P3.2=20?= =?UTF-8?q?=E2=80=94=20artifact=20tab=20registers=20write/edit=20files=20o?= =?UTF-8?q?nly=20(dedupe=20by=20path,=20improved=20extractFilePath)=20(ran?= =?UTF-8?q?t=202026-08-11T12:20:35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +- README.cn.md | 2 +- README.md | 2 +- emrg/gui/renderer/css/layout.css | 34 +++++++ emrg/gui/renderer/js/result-panel.js | 135 ++++++++++----------------- emrg/gui/test/renderer.smoke.test.js | 79 +++++++++++----- 6 files changed, 142 insertions(+), 114 deletions(-) diff --git a/Agent.md b/Agent.md index b343dd7..f181531 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` (200: 43 daemon_client + 19 conn-manager + 22 app-commands + 79 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` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); 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) @@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (694) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (200: 43 daemon_client + 19 conn-manager + 22 app-commands + 79 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` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 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` 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 34c5a21..b40829e 100644 --- a/README.cn.md +++ b/README.cn.md @@ -147,7 +147,7 @@ EMRG 不只是追赶——它自己追上来。 贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。 -快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(200 项:43 daemon_client + 19 conn-manager + 22 app-commands + 79 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) +快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(202 项:43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/README.md b/README.md index e22794c..d6385fd 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md). -Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (200: 43 daemon_client + 19 conn-manager + 22 app-commands + 79 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) +Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index ec9533d..a3ca71e 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -551,6 +551,40 @@ body.sidebar-collapsed #app { color: var(--text-1); } +/* ── P3.2:产物文件行(write/edit 成功文件,点击打开查看器 Tab) ── */ +.artifact-row { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + cursor: pointer; + overflow: hidden; +} +.artifact-row:hover { + border-color: var(--accent); + background: var(--bg-soft); +} +.artifact-name { + font-size: var(--fs-secondary); + font-weight: 600; + color: var(--text-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: none; + max-width: 55%; +} +.artifact-rel { + font-size: var(--fs-small); + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .result-list { flex: 1; overflow-y: auto; diff --git a/emrg/gui/renderer/js/result-panel.js b/emrg/gui/renderer/js/result-panel.js index 9960d67..81a93dc 100644 --- a/emrg/gui/renderer/js/result-panel.js +++ b/emrg/gui/renderer/js/result-panel.js @@ -10,8 +10,9 @@ * - per-session 容器状态(openedTabsBySid / artifactsBySid / activeTabBySid), * 切换会话隔离(缺口 5;对齐 app.js ensureSessionView 容器模式) * - 拖拽期间 #result-panel.dragging 抑制 width transition(R1-①) - * 产物卡片(WorkBuddy P1)暂保留在「产物」pane 内——P3.2 再改为 write/edit 文件登记, - * 避免框架先于内容上线导致面板空窗。 + * P3.2(PR #668):产物 Tab 改为只登记 write/edit 成功文件(去重按 path、per-session 上限 + * 100、点击打开查看器 Tab);extractFilePath 改进(优先首个绝对路径段,去扩展名依赖 R4-①); + * 移除工具输出卡片模式(决策点 3——bash/read 工具卡不再显示)。 */ const ResultPanel = (() => { @@ -259,7 +260,7 @@ const ResultPanel = (() => { renderArtifacts(); // 产物 pane 按当前会话桶恢复(后台 tool_finished 只入桶不渲染) } - // ── 产物登记(P1 卡片渲染保留;P3.2 改 write/edit 文件登记) ── + // ── 产物登记(P3.2:只登记 write/edit 成功文件,per-session 去重,点击打开查看器 Tab) ── function renderEmpty() { const list = listEl(); if (!list) return; @@ -268,20 +269,34 @@ const ResultPanel = (() => { } } - /** 登记一个产物条目(tool_finished 事件;sid = 事件桥会话,P3.2 消费) */ + /** + * 登记一个产物(tool_finished 事件;sid = 事件桥会话)。 + * P3.2:只登记 write/edit 成功且提取到路径的文件(bash/read 工具卡不再显示); + * 同路径去重(更新既有条目移顶);per-session 上限 100(决策点 3 / R7-⑦)。 + */ function addToolResult(data, sid) { - const arr = artifactsFor(sid || currentSid); - const record = { - tool_name: data.tool_name || "tool", - content: String(data.content || ""), - error: !!data.error, + if (data.error) return; // 只登记成功产物 + const tool = data.tool_name || ""; + if (tool !== "write" && tool !== "edit") return; // 只留 write/edit 文件 + const content = String(data.content || ""); + const path = extractFilePath(tool, content); + if (!path) return; + const key = sid || currentSid; + const arr = artifactsFor(key); + const rec = { + path, + name: String(path).split(/[\\/]/).pop() || path, + tool_name: tool, elapsed: data.elapsed, }; - arr.unshift(record); + // 去重:同路径更新既有条目并移顶 + const idx = arr.findIndex((r) => r.path === path); + if (idx >= 0) arr.splice(idx, 1); + arr.unshift(rec); if (arr.length > 100) arr.pop(); // 后台会话:只入桶不渲染(防污染激活会话产物 pane;切回时由 renderArtifacts 恢复) - if ((sid || null) !== currentSid) return; - renderCard(record); + if ((key || null) !== currentSid) return; + renderArtifacts(); } /** 按当前会话桶重渲染产物 pane(switchSession/init 时从桶恢复 DOM,镜像 renderTabbar 模式) */ @@ -291,91 +306,35 @@ const ResultPanel = (() => { list.innerHTML = ""; const arr = artifactsFor(currentSid); if (arr.length === 0) { renderEmpty(); return; } - for (const rec of arr.slice(0, MAX_ITEMS)) renderCard(rec); + for (const rec of arr.slice(0, MAX_ITEMS)) renderArtifactRow(rec); } - function renderCard(data) { + /** 产物行:文件名 + 相对路径;点击 → 打开查看器 Tab(P3.2,系统工具在查看器头部) */ + function renderArtifactRow(rec) { const list = listEl(); if (!list) return; - // 空状态占位清除 - const empty = list.querySelector(".result-empty"); - if (empty) empty.remove(); - - const card = el("div", { class: "result-item" }); - - // 标题行:工具名 + 耗时 - const head = el("div", { class: "result-head" }); - const name = el("span", { class: "result-tool" }, data.tool_name || "tool"); - head.appendChild(name); - if (data.elapsed !== undefined) { - head.appendChild(el("span", { class: "result-elapsed" }, `${data.elapsed.toFixed(1)}s`)); - } - head.appendChild(el("span", { class: "result-chevron" }, "⌄")); - card.appendChild(head); - - const ok = !data.error; - card.classList.add(ok ? "done" : "failed"); - - // 尝试提取文件路径(write/bash 输出中的生成文件) - const content = String(data.content || ""); - const filePath = extractFilePath(data.tool_name, content); - - if (filePath) { - // 文件条目:点击打开 - const fileRow = el("div", { class: "result-file" }); - const namePart = el("span", { class: "result-filename" }, filePath.split(/[\\/]/).pop()); - fileRow.appendChild(namePart); - fileRow.addEventListener("click", async () => { - const res = await window.emrg.openFile({ filePath }); - if (res && res.ok === false) { - // 打开失败提示(文件可能已删除) - fileRow.title = res.error || (window.EMRG_I18N ? window.EMRG_I18N.t("panel.openFailed") : "无法打开文件"); - } - }); - card.appendChild(fileRow); - } - - // 内容摘要(可展开) - const preview = content.slice(0, 200); - if (preview.trim()) { - const body = el("div", { class: "result-body hidden" }, preview); - card.appendChild(body); - // 复制按钮 - const copyBtn = el("button", { class: "result-copy", type: "button" }, _t("chat.copyCode")); - copyBtn.addEventListener("click", (e) => { - e.stopPropagation(); - try { - navigator.clipboard.writeText(content).then(() => { - copyBtn.textContent = _t("panel.copied"); - setTimeout(() => { copyBtn.textContent = _t("chat.copyCode"); }, 1500); - }); - } catch { /* clipboard unavailable */ } - }); - body.appendChild(copyBtn); - // 点击头部展开/收起 - head.addEventListener("click", () => { - body.classList.toggle("hidden"); - card.classList.toggle("expanded", !body.classList.contains("hidden")); - }); - } - - // 裁剪到 MAX_ITEMS - while (list.children.length >= MAX_ITEMS) { - list.removeChild(list.firstChild); - } - list.appendChild(card); + const row = el("div", { class: "artifact-row", dataset: { path: rec.path } }); + const name = el("span", { class: "artifact-name" }, rec.name); + row.appendChild(name); + const rel = el("span", { class: "artifact-rel" }, rec.path); + row.appendChild(rel); + row.addEventListener("click", () => openFileTab(currentSid, rec.path)); + list.appendChild(row); } - /** 从工具输出提取生成的文件路径(write 工具 / bash 中的 >> 重定向 / 路径模式) */ + /** 从工具输出提取生成的文件路径(P3.2 改进 R4-①:优先首个绝对路径段,去扩展名依赖) */ function extractFilePath(toolName, content) { if (!content) return ""; - const lines = content.split("\n"); if (toolName === "write" || toolName === "edit") { - // 常见格式:Wrote file: /path 或 /path/to/file.txt - const m = content.match(/(?:Wrote|wrote|Written|created|已写入|写入)[^\n::]*[::]\s*([^\s\n]+)/); - if (m) return cleanPath(m[1]); - const fm = content.match(/([^\s\n]+\/[^\s\n]+\.(?:py|js|ts|md|txt|json|yml|yaml|toml|sh|css|html|png|jpg|svg|log))/); - if (fm) return cleanPath(fm[1]); + // write: "Created /abs/path (N characters)" / "Updated /abs/path (N chars)" + // edit: "Made 1 replacement in /abs/path" / "Made 3 replacements in /abs/path" + // 取首个 / 开头的绝对路径段(Makefile/.env/Dockerfile 等无扩展名文件也命中) + const m = content.match(/\/[^\s()]+/); + if (m) return cleanPath(m[0]); + // 兜底:旧关键词格式 + const km = content.match(/(?:Wrote|wrote|Written|created|已写入|写入)[^\n::]*[::]\s*([^\s\n]+)/); + if (km) return cleanPath(km[1]); + return ""; } if (toolName === "bash") { // bash 输出通常不带路径;带 "Created" 或重定向时尝试 diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 5e50439..e8d217d 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -886,30 +886,30 @@ test("对话列表键盘导航:输入控件内不劫持(e.target 守卫,te assert.ok(src.includes("e.target.closest"), "应使用 e.target 守卫"); }); -test("WorkBuddy P1:tool_finished → 产物卡片登记到结果面板", async () => { +test("P3.2:tool_finished 只登记 write/edit 成功文件(bash 不登记)", async () => { const { ctx, els } = makeSandbox(); await tick(); // 空状态 const empty = vm.runInContext('document.getElementById("result-list").innerHTML', ctx); assert.ok(String(empty).includes("还没有产物"), "应有空状态占位"); - // tool_finished → ResultPanel.addToolResult + // write 成功 → 产物行登记(提取 /tmp/hello.py) await vm.runInContext(` - ResultPanel.addToolResult({ tool_call_id: "t1", tool_name: "write", content: "Wrote file: /tmp/hello.py\\nHello", elapsed: 0.5 }); + ResultPanel.addToolResult({ tool_call_id: "t1", tool_name: "write", content: "Created /tmp/hello.py (12 characters)", elapsed: 0.5 }); `, ctx); const items = vm.runInContext('document.getElementById("result-list").children.length', ctx); - assert.ok(items >= 1, `应登记产物卡片,实际 ${items}`); - // write 输出中的文件路径被识别(卡片 children 中含 .result-file) - const hasFile = vm.runInContext(` - (function(){ - const list = document.getElementById("result-list"); - for (const c of list.children) { - if (c.className && c.className.includes("result-file")) return true; - if (c.children) for (const g of c.children) if (g.className && g.className.includes("result-file")) return true; - } - return false; - })() + assert.strictEqual(items, 1, "write 成功应登记 1 条产物"); + const rowPath = vm.runInContext('document.getElementById("result-list").children[0].dataset.path', ctx); + assert.strictEqual(rowPath, "/tmp/hello.py", "产物行应带提取的路径"); + // bash 工具 → 不登记(决策点 3:产物 Tab 只留 write/edit 文件) + await vm.runInContext(` + ResultPanel.addToolResult({ tool_call_id: "t2", tool_name: "bash", content: "ls output here", elapsed: 0.3 }); + `, ctx); + assert.strictEqual(vm.runInContext('document.getElementById("result-list").children.length', ctx), 1, "bash 工具不得登记为产物"); + // 失败产物 → 不登记 + await vm.runInContext(` + ResultPanel.addToolResult({ tool_call_id: "t3", tool_name: "write", content: "Error writing file: /x.py", error: true }); `, ctx); - assert.ok(hasFile, "write 产物应显示文件条目"); + assert.strictEqual(vm.runInContext('document.getElementById("result-list").children.length', ctx), 1, "失败产物不得登记"); }); test("WorkBuddy P1:ResultPanel 折叠切换(⌘\ 与按钮)", async () => { @@ -1051,9 +1051,9 @@ test("P2 框架:后台会话 tool_finished 只入桶不渲染(防污染激 test("P2 框架:switchSession 按 sid 重渲染产物 pane(桶→DOM 恢复)", async () => { const { ctx } = makeSandbox(); await tick(); - // 两会话各自登记(初始非激活 → 只入桶) - await vm.runInContext('ResultPanel.addToolResult({ tool_name: "bash", content: "s1-out", elapsed: 0.1 }, "s1")', ctx); - await vm.runInContext('ResultPanel.addToolResult({ tool_name: "bash", content: "s2-out", elapsed: 0.2 }, "s2")', ctx); + // 两会话各自登记(初始非激活 → 只入桶;P3.2 只登记 write/edit 文件) + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "write", content: "Created /p1/a.py (5 characters)", elapsed: 0.1 }, "s1")', ctx); + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "write", content: "Created /p2/b.py (5 characters)", elapsed: 0.2 }, "s2")', ctx); // 递归收集 list 文本(mock 元素 textContent 是属性,不入 innerHTML) const collectText = `(function(){ function collect(node) { @@ -1066,13 +1066,48 @@ test("P2 框架:switchSession 按 sid 重渲染产物 pane(桶→DOM 恢复 // 切到 s2 → pane 只显示 s2 的记录 await vm.runInContext('ResultPanel.switchSession("s2")', ctx); const text2 = vm.runInContext(collectText, ctx); - assert.ok(String(text2).includes("s2-out"), "s2 pane 应显示 s2 产物"); - assert.ok(!String(text2).includes("s1-out"), "s2 pane 不得显示 s1 产物"); + assert.ok(String(text2).includes("/p2/b.py"), "s2 pane 应显示 s2 产物"); + assert.ok(!String(text2).includes("/p1/a.py"), "s2 pane 不得显示 s1 产物"); // 切回 s1 → pane 只显示 s1 的记录(s2 卡片不残留) await vm.runInContext('ResultPanel.switchSession("s1")', ctx); const text1 = vm.runInContext(collectText, ctx); - assert.ok(String(text1).includes("s1-out"), "s1 pane 应显示 s1 产物"); - assert.ok(!String(text1).includes("s2-out"), "s1 pane 不得显示 s2 产物"); + assert.ok(String(text1).includes("/p1/a.py"), "s1 pane 应显示 s1 产物"); + assert.ok(!String(text1).includes("/p2/b.py"), "s1 pane 不得显示 s2 产物"); +}); + +test("P3.2:同路径去重(更新既有条目移顶)+ per-session 上限 100", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext('ResultPanel.switchSession("s1")', ctx); + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "write", content: "Created /a.py (5 characters)", elapsed: 0.1 }, "s1")', ctx); + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "write", content: "Created /b.py (5 characters)", elapsed: 0.2 }, "s1")', ctx); + // 同路径再写 → 去重(2 条,/a.py 移到顶部) + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "write", content: "Created /a.py (9 characters)", elapsed: 0.3 }, "s1")', ctx); + const paths = vm.runInContext(` + (function(){ return [...document.getElementById("result-list").children].map(c => c.dataset.path); })() + `, ctx); + // vm 跨 realm 数组原型不同 → deepStrictEqual 失败;用 join 比较 + assert.strictEqual(paths.join(","), "/a.py,/b.py", "同路径去重并移顶"); + // 上限 100:填满后最旧被淘汰 + for (let i = 0; i < 105; i++) { + await vm.runInContext(`ResultPanel.addToolResult({ tool_name: "write", content: "Created /f${i}.py (5 characters)", elapsed: 0.1 }, "s1")`, ctx); + } + const n = vm.runInContext('document.getElementById("result-list").children.length', ctx); + assert.ok(n <= 100, `per-session 上限 100,实际 ${n}`); +}); + +test("P3.2:无扩展名文件(Makefile/.env)提取 + 点击产物行打开查看器 Tab", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext('ResultPanel.switchSession("s1")', ctx); + // edit 输出 "Made 1 replacement in /proj/Makefile"(无扩展名也命中) + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "edit", content: "Made 1 replacement in /proj/Makefile", elapsed: 0.2 }, "s1")', ctx); + const row = vm.runInContext('document.getElementById("result-list").children[0]', ctx); + assert.strictEqual(row.dataset.path, "/proj/Makefile", "无扩展名文件应提取"); + // 点击 → 打开查看器 Tab(P2.2 框架) + row.click(); + await tick(); + assert.ok([...els["result-tabbar"].children].some((c) => c.dataset.path === "/proj/Makefile"), "点击产物行应打开查看器 Tab"); }); test("P3:文件树渲染根 + 根自动展开懒加载", async () => {