diff --git a/Agent.md b/Agent.md index e99143ca..eb6a3cb9 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` (255: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 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` (825) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (255: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 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 7b9af41f..837fc9de 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -927,6 +927,16 @@ dialog::backdrop { color: var(--text-3); word-break: break-all; } +/* rant 10:41:43:rant 详情 markdown —— 【】标题预处理后的 h4 层次 + 嵌套列表缩进可见 */ +.rant-md h4 { + margin: 10px 0 4px; + font-size: 13px; + color: var(--text-1); +} +.rant-md li > ul, +.rant-md li > ol { + margin-left: 1em; +} .task-actions { display: flex; gap: 4px; diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index dbaeeb80..d2f7fa0e 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -1305,16 +1305,20 @@ const Dialogs = (() => { const msg = r.message || ""; const firstLine = (msg.split("\n").find((l) => l.trim() !== "") || "").replace(/^#{1,6}\s+/, "").replace(/^[>*\-\s]+/, ""); row.appendChild(el("span", { class: "rant-col-content" }, firstLine || "—")); - // 详情展开(rant 21:36:01:完整 message 按 markdown 渲染 + 进度显示) + // 详情展开/收起(rant 10:41:43:点击已展开的本行 → 收起,不再无条件重建) row.addEventListener("click", async () => { - const detail = list.querySelector(".rant-detail"); - if (detail) detail.remove(); + const existing = row.nextElementSibling; + const isSelfDetail = existing && existing.classList.contains("rant-detail"); + // 先移除所有已展开的详情(含其他行的) + list.querySelectorAll(".rant-detail").forEach((d) => d.remove()); + if (isSelfDetail) return; // 点自己已展开 → 收起 const detailRow = el("div", { class: "rant-detail", style: "padding:6px 8px;border-top:1px solid var(--border);font-size:var(--fs-secondary);" }); detailRow.appendChild(el("div", { class: "rant-meta" }, `${ts || "—"} · ${r.project || "—"} · ${stText}`)); const md = el("div", { class: "msg-body rant-md" }); try { if (window.emrgMarkdown && window.emrgMarkdown.renderMarkdown) { - md.innerHTML = await window.emrgMarkdown.renderMarkdown(msg); + // rant 10:41:43:【】标题预处理(markdown 视觉层次;不破坏原文) + md.innerHTML = await window.emrgMarkdown.renderMarkdown(preprocessRantMarkdown(msg)); } else { md.textContent = msg; } @@ -1333,6 +1337,20 @@ const Dialogs = (() => { } } + // rant 10:41:43:rant 详情 markdown 预处理 —— 行首 【xxx】 段标记映射为标题级 + // (【任务】→####),让整篇有视觉层次;仅短行(≤60 字符)转标题,长内容行保持正文。 + // 原文保留(只加 #### 前缀,不删除/改写原文)。 + function preprocessRantMarkdown(text) { + if (!text) return text || ""; + return String(text).split("\n").map((line) => { + const t = line.trim(); + if (/^【[^】]{1,24}】/.test(t) && t.length <= 60) { + return `#### ${t}`; + } + return line; + }).join("\n"); + } + function openRantForm() { const form = $("rant-form"); if (!form) return; @@ -1564,6 +1582,7 @@ const Dialogs = (() => { showProjectSessionsInPanel, // rant 14:10:14 P5:项目面板内嵌会话列表(测试复用) initRantPanel, // rant 14:10:14 P4:rant 面板初始化 renderRantList, // rant 14:10:14 P4:rant 列表渲染(测试/刷新复用) + preprocessRantMarkdown, // rant 10:41:43:rant 详情 markdown 【】标题预处理(测试复用) setRantFilter, // rant 14:10:14 P4:rant 状态筛选(测试复用) openRantForm, // rant 14:10:14 P4:新建 rant 表单(测试复用) submitRantForm, // rant 14:10:14 P4:rant 表单提交(测试复用) diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index c3b47ea8..361d3de1 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -102,6 +102,13 @@ function makeEl(id) { if (i >= 0) this.parentNode.children.splice(i + 1, 0, c); else this.parentNode.children.push(c); }, + get nextElementSibling() { + // 忠实 DOM:下一兄弟节点(rant 10:41:43 点击收起依赖 row.nextElementSibling) + if (!this.parentNode) return null; + const i = this.parentNode.children.indexOf(this); + if (i < 0) return null; + return this.parentNode.children[i + 1] || null; + }, remove() { // 真实脱离父节点(chat.js handleToolEnd 移除 .tool-spinner) if (this.parentNode) { @@ -3270,6 +3277,56 @@ test("rant 14:10:14 P4:rant 面板列表(5 列 + 状态徽标三态 + 筛选 assert.strictEqual(sent.message, "希望支持 X"); }); +test("rant 10:41:43:preprocessRantMarkdown —— 【】段标记映射标题 + 原文保留 + 长行不转", () => { + const { ctx } = makeSandbox(); + const pp = (s) => vm.runInContext(`EMRG_Dialogs.preprocessRantMarkdown(${JSON.stringify(s)})`, ctx); + // 短行 【xxx】 → #### 标题(原文保留,仅加前缀) + const out1 = pp("【任务】\n宿主要求:显示任务状态。\n【修改方案】\n加倒计时。"); + assert.ok(out1.includes("#### 【任务】"), `【任务】应转 h4 标题:${out1}`); + assert.ok(out1.includes("#### 【修改方案】"), `【修改方案】应转 h4:${out1}`); + assert.ok(out1.includes("宿主要求:显示任务状态。"), "正文行保持原文"); + // 长行(>60 字符,如带完整设计的 rant 标题)→ 不转标题,保持正文 + const longLine = "【GUI 任务管理:显示任务当前状态 + 下次运行倒计时——完整设计如下,照抄实施,勿需再读文档】"; + const out2 = pp(longLine); + assert.strictEqual(out2, longLine, "长行不应转标题"); + // 空/非字符串安全 + assert.strictEqual(pp(""), "", "空串安全"); + assert.strictEqual(pp(null), "", "null 安全"); +}); + +test("rant 10:41:43:rant 详情点击收起 —— 再点已展开行 → 收起;点其他行 → 换展开", async () => { + const rants = [ + { timestamp: "2026-08-15T10:00:00", project: "emrg", status: "pending", progress: null, message: "【任务】\n第一条" }, + { timestamp: "2026-08-15T11:00:00", project: "emrg", status: "pending", progress: null, message: "【任务】\n第二条" }, + ]; + const { ctx, els } = makeSandbox({ + listRants: async () => rants, + listProjects: async () => [], + }); + await tick(); + await vm.runInContext("App.openRantsPanel()", ctx); + await tick(); + const list = els["rant-list"]; + const rows = list.children.filter((c) => c.className.includes("task-row")); + assert.strictEqual(rows.length, 2); + // 点行0 → 展开 + rows[0].click(); + await tick(); + assert.ok(list.querySelector(".rant-detail"), "点行0应展开详情"); + // 再点行0 → 收起(修复前:删+建 → 永远展开) + rows[0].click(); + await tick(); + assert.strictEqual(list.querySelector(".rant-detail"), null, "再点已展开行应收起"); + // 点行0 → 展开;点行1 → 旧的收起、行1 展开(行为保持) + rows[0].click(); + await tick(); + assert.ok(list.querySelector(".rant-detail"), "行0展开"); + rows[1].click(); + await tick(); + const details = list.children.filter((c) => c.className.includes("rant-detail")); + assert.strictEqual(details.length, 1, "切换查看不同 rant → 旧的收起新的展开(仅 1 个详情)"); +}); + test("rant 21:36:01/21:38:25/21:46:53:三面板标题 + Rant 列头 + 项目 hint 移除 + i18n.apply 保留控件", async () => { const htmlSrc = fs.readFileSync(path.join(__dirname, "..", "renderer", "index.html"), "utf-8"); const i18nSrc = fs.readFileSync(path.join(__dirname, "..", "renderer", "js", "i18n.js"), "utf-8");