diff --git a/Agent.md b/Agent.md index 2c2e1d2..09d2b38 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` (237: 44 daemon_client + 19 conn-manager + 22 app-commands + 115 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` (238: 44 daemon_client + 19 conn-manager + 22 app-commands + 116 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); 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` (237: 44 daemon_client + 19 conn-manager + 22 app-commands + 115 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` (238: 44 daemon_client + 19 conn-manager + 22 app-commands + 116 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` (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/layout.css b/emrg/gui/renderer/css/layout.css index 0bcdcb7..e291479 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -260,6 +260,21 @@ .session-view.active { display: flex; } +/* 会话标题栏:项目/名称(id) 或 项目/id,滚动置顶、全宽贴边(负 margin 抵消 .session-view padding) */ +.session-header { + position: sticky; + top: 0; + z-index: 5; + flex-shrink: 0; + margin: calc(-1 * var(--sp-5)) calc(-1 * var(--sp-5)) var(--sp-3) !important; + padding: var(--sp-3) var(--sp-5); + width: auto !important; + max-width: none !important; + font-size: var(--fs-secondary); + color: var(--text-2); + background: var(--bg); + border-bottom: 1px solid var(--border); +} /* P3 finalize:会话连接断开标记(P4 每会话容器可见;激活会话另走全局横幅) */ .session-view.disconnected { opacity: 0.55; @@ -756,8 +771,6 @@ body.sidebar-collapsed #app { flex: 1; overflow-y: auto; padding: var(--sp-3); - display: flex; - flex-direction: column; gap: var(--sp-2); } .result-empty { diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index a467c4a..82dfad0 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -553,12 +553,41 @@ const App = (() => { let view = [...$("workspace").children].find((c) => c.dataset?.sid === sid); if (!view) { view = el("div", { class: "session-view", id: "session-view-" + sid, dataset: { sid } }); + // 顶部标题栏(项目/名称(id) 或 项目/id);Chat.clear 保留它,消息节点在其后追加 + view.appendChild(el("div", { class: "session-header" })); $("workspace").appendChild(view); } Chat.registerContainer(sid, view); // 幂等:重复注册覆盖同一元素 return view; } + /** 会话标题栏显示的项目名:openSessions 优先,回退全局 projectDir 的 basename */ + function sessionProjectName(sid) { + const os = state.openSessions.find((s) => s.sid === sid); + if (os && os.projectName) return os.projectName; + const dir = state.projectDir || ""; + const parts = dir.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] || dir; + } + + // 会话视图顶部标题栏:项目/名称(id) 或 项目/id(有 title 时带 (id) 后缀) + function renderSessionHeader(sid) { + if (!sid) return; + const view = [...$("workspace").children].find((c) => c.dataset?.sid === sid); + if (!view) return; + let header = view.querySelector(".session-header"); + if (!header) { + header = el("div", { class: "session-header" }); + view.insertBefore(header, view.firstChild); + } + const cur = state.sessions.find((s) => s.session_id === sid) || {}; + const project = sessionProjectName(sid); + const title = cur.title && cur.title !== sid ? cur.title : ""; + const text = title ? `${project}/${title}(${sid})` : `${project}/${sid}`; + header.textContent = text; + header.title = text; // 悬停完整信息 + } + // rant 18:55:09 v0.2:工作区视图互斥(激活会话视图 → 同步隐藏所有面板视图 + 清导航高亮) function activateSessionView(sid) { for (const p of VIEWS) { @@ -575,6 +604,7 @@ const App = (() => { if (child.classList) child.classList.remove("active"); } view.classList.add("active"); + renderSessionHeader(sid); // 标题栏随激活/重命名刷新 if (!existed) Chat.scrollToBottom(sid); setWorkspaceChrome("sessions"); // 恢复输入区 + 成果面板(含 back-to-bottom 按位置恢复) state.activeView = "sessions"; @@ -875,6 +905,8 @@ const App = (() => { const sessions = await window.emrg.listSessions(); state.sessions = sessions; Sidebar.render(sessions); + Sidebar.renderOpenSessions(state.openSessions); // 打开会话区 title 同步刷新 + renderSessionHeader(state.sessionId); // 激活会话标题栏同步刷新 } catch { /* 忽略 */ } } diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 86759ba..3a79e3d 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -95,7 +95,12 @@ const Chat = (() => { function clear(sid) { const key = sid || null; - chatContainer(key).innerHTML = ""; + const cv = chatContainer(key); + // 清空消息节点但保留 .session-header 标题栏(app.js 每个 .session-view 顶部固定) + for (const node of [...cv.children]) { + if (node.classList && node.classList.contains("session-header")) continue; + node.remove(); + } st(key).groupNodes.clear(); st(key).toolRows.clear(); st(key).doneRids.clear(); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index ebe60c3..092aca5 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -247,6 +247,9 @@ function makeSandbox(overrides = {}) { /** 等 microtask 完成 */ const tick = () => new Promise((r) => setTimeout(r, 20)); +/** 消息节点数:排除 .session-header 标题栏(app.js 每会话视图顶部固定) */ +const msgCount = (el) => (el.children || []).filter((c) => !(c.className || "").split(/\s+/).includes("session-header")).length; + test("8 模块按序加载且全局符号解析", () => { const { ctx } = makeSandbox(); const out = vm.runInContext( @@ -987,6 +990,19 @@ test("P2 框架:右栏 Tab 栏渲染 + 切换 pane 显隐", async () => { assert.ok(els["result-list"].classList.contains("active"), "切回产物 pane"); }); +test("rant 20:49:45:产物 pane 显示由 .result-pane 控制——.result-list 不得重声明 display", () => { + // CSS 源级断言(沙箱无 CSS 引擎)。根因:.result-list 曾声明 display:flex(同特异性 0,1,0 + // 后定义胜出)覆盖 .result-pane{display:none},导致文件 tab 下产物 pane 恒显示。 + const css = fs.readFileSync(path.join(RENDERER_CSS, "layout.css"), "utf8"); + // 定位独立 .result-list 规则(行首选择器,排除 #result-panel.collapsed .result-list 前缀形态) + const start = css.indexOf("\n.result-list {") + 1; + const end = css.indexOf("\n.result-empty {", start); // 下一个独立规则 + const block = css.slice(start, end > start ? end : start + 400); + assert.ok(start > 0, ".result-list 独立规则应存在"); + assert.ok(!/display\s*:/.test(block), ".result-list 不得重声明 display(显隐由 .result-pane/.result-pane.active 控制)"); + assert.ok(!/flex-direction\s*:/.test(block), ".result-list 不得重声明 flex-direction(.result-pane.active 已提供)"); +}); + test("P2 框架:resizer 拖拽改宽度 + .dragging 抑制 transition", async () => { const { ctx, win, els } = makeSandbox(); await tick(); @@ -1710,8 +1726,8 @@ test("P3 s2: activateSessionView 建独立容器并切换 display(仅激活可 assert.strictEqual(els["workspace"].children.length, 2, "both views live under the #workspace wrapper"); // 无 sid 渲染 → 落激活会话容器(slice 2 回退链第二跳) ctx.EMRG_Chat.addSystemMessage("to-active"); - assert.strictEqual(vb.children.length, 1, "unsid'd node goes to active session view"); - assert.strictEqual(va.children.length, 0, "inactive view untouched (state preserved)"); + assert.strictEqual(msgCount(vb), 1, "unsid'd node goes to active session view"); + assert.strictEqual(msgCount(va), 0, "inactive view untouched (state preserved)"); }); test("P3 s2: Chat.clear 只清目标容器——无 sid 清激活,带 sid 定向清", async () => { @@ -1728,15 +1744,15 @@ test("P3 s2: Chat.clear 只清目标容器——无 sid 清激活,带 sid 定 ); const va = ctx.EMRG_Chat.chatContainer("sess-a"); const vb = ctx.EMRG_Chat.chatContainer("sess-b"); - assert.strictEqual(va.children.length, 1, "sess-a has its node"); - assert.strictEqual(vb.children.length, 1, "sess-b has its node"); + assert.strictEqual(msgCount(va), 1, "sess-a has its node"); + assert.strictEqual(msgCount(vb), 1, "sess-b has its node"); // 无 sid clear(/clear 的既有调用形态)→ 只清激活容器 ctx.EMRG_Chat.clear(); - assert.strictEqual(vb.children.length, 0, "active view cleared"); - assert.strictEqual(va.children.length, 1, "inactive view retained (切回继续看到原消息)"); + assert.strictEqual(msgCount(vb), 0, "active view cleared"); + assert.strictEqual(msgCount(va), 1, "inactive view retained (切回继续看到原消息)"); // 带 sid clear → 定向清 ctx.EMRG_Chat.clear("sess-a"); - assert.strictEqual(va.children.length, 0, "targeted clear empties sess-a"); + assert.strictEqual(msgCount(va), 0, "targeted clear empties sess-a"); }); test("P3 s2: 未注册 sid 的事件落激活容器,状态桶仍按 sid 隔离", async () => { @@ -1750,7 +1766,7 @@ test("P3 s2: 未注册 sid 的事件落激活容器,状态桶仍按 sid 隔离 const vx = ctx.EMRG_Chat.chatContainer("sess-x"); // 广播流(sid=sess-other 无独立容器,P4 前过渡)→ 节点落激活容器、状态入自己桶 ctx.EMRG_Chat.handleDelta([{ request_id: "r1", content: "fallback", done: false, delta: true }], "sess-other"); - assert.strictEqual(vx.children.length, 1, "unregistered sid renders into active view"); + assert.strictEqual(msgCount(vx), 1, "unregistered sid renders into active view"); assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-other").size, 1, "state bucket keyed by own sid"); assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-x").size, 0, "active bucket untouched by other sid"); });