From 9cac2952128126119a47a2c0ec43a97544d0769d Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 11 Aug 2026 13:14:21 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20workspace=20panel=20P2=20framewor?= =?UTF-8?q?k=20=E2=80=94=20resizable=20panel=20+=20tab=20bar=20+=20per-ses?= =?UTF-8?q?sion=20tab=20state=20(rant=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 | 106 +++++++++++- emrg/gui/renderer/index.html | 18 ++- emrg/gui/renderer/js/app.js | 4 +- emrg/gui/renderer/js/i18n.js | 8 + emrg/gui/renderer/js/result-panel.js | 233 ++++++++++++++++++++++++--- emrg/gui/test/app-commands.test.js | 4 + emrg/gui/test/renderer.smoke.test.js | 106 ++++++++++++ 10 files changed, 456 insertions(+), 31 deletions(-) diff --git a/Agent.md b/Agent.md index c8086018..2d0abe8c 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` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (193: 43 daemon_client + 19 conn-manager + 22 app-commands + 72 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` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (193: 43 daemon_client + 19 conn-manager + 22 app-commands + 72 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 19f6bf3a..00d74264 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`(188 项:43 daemon_client + 19 conn-manager + 22 app-commands + 67 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`(193 项:43 daemon_client + 19 conn-manager + 22 app-commands + 72 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/README.md b/README.md index 2dd21e0d..1aaf6680 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` (188: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 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` (193: 43 daemon_client + 19 conn-manager + 22 app-commands + 72 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 e130991c..c97c9518 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -9,6 +9,7 @@ display: flex; height: 100vh; width: 100vw; + position: relative; /* #result-resizer 绝对定位锚点(P2 框架) */ } /* ── 侧边栏 ─────────────────────────────── */ @@ -317,15 +318,24 @@ body.sidebar-collapsed #app { min-width: 40px; border-left: 1px solid var(--border); } +/* P2 框架:拖拽调整宽度期间抑制 width transition(R1-①:transition 与 mousemove 冲突) */ +#result-panel.dragging { + transition: none; +} #result-panel.collapsed .result-list { display: none; /* 只藏内容区 */ } +#result-panel.collapsed .result-pane { + display: none; /* 折叠时所有 pane 隐藏(含激活中的文件 pane) */ +} #result-panel.collapsed .result-header { display: flex; justify-content: center; padding: var(--sp-3) 0; } -#result-panel.collapsed .result-title { +#result-panel.collapsed .result-title, +#result-panel.collapsed .result-tabs, +#result-panel.collapsed .result-tabbar { display: none; /* 窄条只留 toggle 按钮 */ } #result-panel.collapsed .result-toggle { @@ -359,6 +369,100 @@ body.sidebar-collapsed #app { background: var(--bg-soft); } +/* ── P2 框架:Tab 栏(文件 / 产物 + 打开文件 Tab) ── */ +.result-tabs { + display: flex; + align-items: center; + gap: var(--sp-1); + overflow-x: auto; + scrollbar-width: none; +} +.result-tabs::-webkit-scrollbar { display: none; } +.result-tab { + background: none; + border: none; + padding: var(--sp-1) var(--sp-3); + font-size: var(--fs-secondary); + color: var(--text-3); + cursor: pointer; + border-radius: 6px; + white-space: nowrap; +} +.result-tab:hover { + color: var(--text-1); + background: var(--bg-soft); +} +.result-tab.active { + color: var(--text-1); + background: var(--bg-soft); + font-weight: 600; +} +/* 打开文件 Tab 条(P3 查看器接入;每文件一个可关闭 Tab) */ +.result-tabbar { + display: none; + flex-direction: row; + gap: var(--sp-1); + padding: var(--sp-1) var(--sp-3); + border-bottom: 1px solid var(--border); + overflow-x: auto; + scrollbar-width: none; +} +.result-tabbar.has-tabs { display: flex; } +.result-tabbar::-webkit-scrollbar { display: none; } +.result-filetab { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: 2px var(--sp-2); + font-size: var(--fs-small); + color: var(--text-2); + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + white-space: nowrap; + max-width: 160px; + overflow: hidden; +} +.result-filetab.active { + color: var(--text-1); + border-color: var(--accent); +} +.filetab-close { + color: var(--text-3); + font-size: 12px; + line-height: 1; + padding: 0 2px; + border-radius: 4px; +} +.filetab-close:hover { + color: var(--text-1); + background: var(--bg-hover); +} + +/* ── P2 框架:内容 pane 切换(激活 pane 显示) ── */ +.result-pane { display: none; } +.result-pane.active { display: flex; flex-direction: column; } + +/* ── 宽度拖拽手柄 ── */ +#result-resizer { + position: absolute; + top: 0; + bottom: 0; + width: 6px; + right: 277px; /* JS 随宽度更新(right = panelWidth - 3) */ + cursor: col-resize; + z-index: 40; +} +#result-resizer:hover { + background: var(--accent); + opacity: 0.35; +} +#result-resizer.dragging { + background: var(--accent); + opacity: 0.5; +} + .result-list { flex: 1; overflow-y: auto; diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 7c0c886a..7b9d4ed8 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -85,14 +85,26 @@ - + + +
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 209194f6..10e8730a 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -631,6 +631,7 @@ const App = (() => { // P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId // P3 slice 2:激活该会话容器(状态保留,不 Chat.clear——切回继续看到原消息/流式现场) activateSessionView(sid); + ResultPanel.switchSession(sid); // P2 框架:右栏 Tab/产物状态按 sid 隔离 updateEmptyState(); if (res.error === "session_not_found") { Chat.addSystemMessage(_t("app.deletedSwitch")); @@ -671,6 +672,7 @@ const App = (() => { state.sessionId = res.session_id; state.drafts.set(res.session_id, ""); // 新会话无草稿 activateSessionView(state.sessionId); // P3 slice 2:新会话独立容器(空) + ResultPanel.switchSession(state.sessionId); // P2 框架:新会话右栏状态复位 Chat.clear(state.sessionId); // 新会话从空开始(容器可能被复用) updateEmptyState(); // 欢迎屏即反馈 await refreshSessions(); @@ -1172,7 +1174,7 @@ const App = (() => { break; case "tool_finished": Chat.handleToolEnd(data, sid); - ResultPanel.addToolResult(data); + ResultPanel.addToolResult(data, sid); // P2 框架:产物登记按事件 sid 归类(P3.2 消费) break; case "cancelled": Chat.clearTyping(sid); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清) diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index c90905bc..00958519 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -47,6 +47,10 @@ const I18N = (() => { "result.title": "产物", "result.collapse": "折叠/展开 (⌘\\)", "result.empty": "还没有产物,对话中生成的文件会出现在这里", + "result.tabFiles": "文件", + "result.tabArtifacts": "产物", + "result.filesEmpty": "文件浏览器即将推出", + "result.resize": "拖拽调整宽度", // 设置 "settings.title": "设置", @@ -377,6 +381,10 @@ const I18N = (() => { "result.title": "Artifacts", "result.collapse": "Collapse/expand (⌘\\)", "result.empty": "No artifacts yet — files generated in the conversation will appear here", + "result.tabFiles": "Files", + "result.tabArtifacts": "Artifacts", + "result.filesEmpty": "File browser coming soon", + "result.resize": "Drag to resize", // Settings "settings.title": "Settings", diff --git a/emrg/gui/renderer/js/result-panel.js b/emrg/gui/renderer/js/result-panel.js index b27b1b1c..1cc67059 100644 --- a/emrg/gui/renderer/js/result-panel.js +++ b/emrg/gui/renderer/js/result-panel.js @@ -1,42 +1,208 @@ "use strict"; /** - * result-panel.js — 结果面板(右栏 Artifacts,WorkBuddy P1,rant 21:35) + * result-panel.js — 结果面板(右栏工作区,rant 2026-08-11T12:20:35 workspace panel) * - * 监听 tool_finished 事件,把工具输出/生成文件登记为产物卡片: - * - 工具输出(bash/read/write)→ 折叠卡片,可展开 + 复制 - * - 文件路径(write 产物)→ 文件条目,点击用系统默认程序打开 - * 三栏布局:sidebar | chat | result-panel;⌘\ 折叠/展开;窄屏自动隐藏。 + * P2 slice 1(框架层,PR #664): + * - Tab 栏:文件 / 产物 两个静态 Tab + 打开文件 Tab(P3 查看器接入,API 已就绪: + * openFileTab/closeFileTab/activateTab,上限 8 淘汰最旧、同路径去重复用) + * - 可拖拽调整宽度(#result-resizer,绝对定位不进 flex 流 R6-②); + * panelWidth 与 collapsed 分离持久化(localStorage 两个键) + * - per-session 容器状态(openedTabsBySid / artifactsBySid / activeTabBySid), + * 切换会话隔离(缺口 5;对齐 app.js ensureSessionView 容器模式) + * - 拖拽期间 #result-panel.dragging 抑制 width transition(R1-①) + * 产物卡片(WorkBuddy P1)暂保留在「产物」pane 内——P3.2 再改为 write/edit 文件登记, + * 避免框架先于内容上线导致面板空窗。 */ const ResultPanel = (() => { - const MAX_ITEMS = 50; + const MAX_ITEMS = 50; // 产物卡片上限(P1 遗留,P3.2 改 per-session 100) + const MAX_OPEN_TABS = 8; // 打开文件 Tab 上限(决策点 6) + const DEFAULT_WIDTH = 280; + const MIN_WIDTH = 240; + const MAX_WIDTH_RATIO = 0.45; // 不超过视口 45% + const LS_WIDTH = "emrg.resultPanel.panelWidth"; + const LS_COLLAPSED = "emrg.resultPanel.collapsed"; - function panel() { - return $("result-panel"); + // per-session 状态(P2.2 缺口 5:容器模式) + const openedTabsBySid = new Map(); // sid -> { tabs: [{path,name}], active: tabId } + const artifactsBySid = new Map(); // sid -> 登记记录(P3.2 消费) + let currentSid = null; + + function panel() { return $("result-panel"); } + function listEl() { return $("result-list"); } + function filesEl() { return $("result-files"); } + function tabbarEl() { return $("result-tabbar"); } + function resizerEl() { return $("result-resizer"); } + + // ── 宽度 / 折叠(分离持久化) ── + function getWidth() { + const p = panel(); + if (p && p.style && p.style.width) { + const n = parseInt(p.style.width, 10); + if (!Number.isNaN(n)) return n; + } + return storedWidth(); + } + function storedWidth() { + try { + const v = parseInt(localStorage.getItem(LS_WIDTH) || "", 10); + if (!Number.isNaN(v)) return clampWidth(v); + } catch { /* ignore */ } + return DEFAULT_WIDTH; } - function listEl() { - return $("result-list"); + function clampWidth(w) { + const vw = (window.innerWidth || 1200) * MAX_WIDTH_RATIO; + return Math.round(Math.min(Math.max(w, MIN_WIDTH), Math.max(MIN_WIDTH, vw))); + } + function setWidth(w) { + const p = panel(); + if (!p) return; + const cw = clampWidth(w); + p.style.width = cw + "px"; + try { localStorage.setItem(LS_WIDTH, String(cw)); } catch { /* ignore */ } + updateResizerPos(); } - function isCollapsed() { const p = panel(); return p ? p.classList.contains("collapsed") : true; } - function setCollapsed(collapsed) { const p = panel(); if (!p) return; + if (collapsed) { + // 折叠:先固化当前宽度到持久化,再应用窄条(inline width 覆盖 CSS 的 40px 需显式设置) + setWidth(getWidth()); + p.style.width = "40px"; + } else { + // 展开:恢复持久化宽度(勿读 style.width——折叠时为 40px) + setWidth(storedWidth()); + } p.classList.toggle("collapsed", collapsed); - try { - localStorage.setItem("emrg.resultPanel.collapsed", collapsed ? "1" : "0"); - } catch { /* ignore */ } + try { localStorage.setItem(LS_COLLAPSED, collapsed ? "1" : "0"); } catch { /* ignore */ } + updateResizerPos(); } - function toggle() { setCollapsed(!isCollapsed()); } - /** 空状态 */ + // ── 拖拽调整宽度(R1-①:.dragging 抑制 transition) ── + function updateResizerPos() { + const r = resizerEl(); + if (!r) return; + if (isCollapsed()) { r.style.display = "none"; return; } + r.style.display = "block"; + r.style.right = (getWidth() - 3) + "px"; + } + function initResizer() { + const r = resizerEl(); + const p = panel(); + if (!r || !p) return; + let drag = null; + r.addEventListener("mousedown", (e) => { + drag = { startX: e.clientX, startWidth: getWidth() }; + p.classList.add("dragging"); + r.classList.add("dragging"); + const onMove = (ev) => { + if (!drag) return; + setWidth(drag.startWidth - (ev.clientX - drag.startX)); + }; + const onUp = () => { + drag = null; + p.classList.remove("dragging"); + r.classList.remove("dragging"); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }); + } + + // ── Tab 管理(静态 文件/产物 + 打开文件 Tab,per-session) ── + function tabIdFor(path) { return "file:" + path; } + function stateFor(sid) { + const key = sid || null; + let s = openedTabsBySid.get(key); + if (!s) { s = { tabs: [], active: "artifacts" }; openedTabsBySid.set(key, s); } + return s; + } + function artifactsFor(sid) { + const key = sid || null; + let a = artifactsBySid.get(key); + if (!a) { a = []; artifactsBySid.set(key, a); } + return a; + } + + function renderTabbar() { + const bar = tabbarEl(); + if (!bar) return; + const st = stateFor(currentSid); + bar.innerHTML = ""; + if (st.tabs.length === 0) { bar.classList.remove("has-tabs"); return; } + bar.classList.add("has-tabs"); + for (const t of st.tabs) { + const active = st.active === tabIdFor(t.path); + const btn = el("div", { class: "result-filetab" + (active ? " active" : ""), dataset: { path: t.path } }, t.name); + const close = el("span", { class: "filetab-close", dataset: { path: t.path } }, "×"); + close.addEventListener("click", (e) => { + e.stopPropagation(); + closeFileTab(currentSid, t.path); + }); + btn.appendChild(close); + btn.addEventListener("click", () => activateTab(tabIdFor(t.path))); + bar.appendChild(btn); + } + } + + /** 激活 Tab("files" | "artifacts" | "file:");sid 缺省 = 当前会话 */ + function activateTab(tabId, sid) { + const st = stateFor(sid !== undefined ? sid : currentSid); + st.active = tabId; + // 后台会话:只更新状态,不动全局 DOM(切回时由 switchSession 恢复) + if ((sid !== undefined ? sid : currentSid) !== currentSid) return; + const filesBtn = $("result-tab-files"); + const artBtn = $("result-tab-artifacts"); + if (filesBtn) filesBtn.classList.toggle("active", tabId === "files"); + if (artBtn) artBtn.classList.toggle("active", tabId === "artifacts"); + const fp = filesEl(), lp = listEl(); + if (fp) fp.classList.toggle("active", tabId === "files"); + if (lp) lp.classList.toggle("active", tabId === "artifacts"); + renderTabbar(); + } + + /** 打开文件 Tab:同路径去重(激活既有)/ 上限 8 淘汰最旧 */ + function openFileTab(sid, path) { + const st = stateFor(sid); + const existing = st.tabs.find((t) => t.path === path); + if (existing) { activateTab(tabIdFor(path), sid); return existing; } + const name = String(path).split(/[\\/]/).pop() || path; + const tab = { path, name }; + st.tabs.push(tab); + while (st.tabs.length > MAX_OPEN_TABS) st.tabs.shift(); + if ((sid || null) === currentSid) renderTabbar(); + activateTab(tabIdFor(path), sid); + return tab; + } + function closeFileTab(sid, path) { + const st = stateFor(sid); + const idx = st.tabs.findIndex((t) => t.path === path); + if (idx < 0) return; + st.tabs.splice(idx, 1); + if (st.active === tabIdFor(path)) { + st.active = st.tabs.length ? tabIdFor(st.tabs[st.tabs.length - 1].path) : "artifacts"; + } + if ((sid || null) === currentSid) { renderTabbar(); activateTab(st.active, sid); } + } + + /** 切换会话 → Tab 状态按 sid 隔离(缺口 5) */ + function switchSession(sid) { + currentSid = sid || null; + stateFor(currentSid); + renderTabbar(); + activateTab(stateFor(currentSid).active); + } + + // ── 产物登记(P1 卡片渲染保留;P3.2 改 write/edit 文件登记) ── function renderEmpty() { const list = listEl(); if (!list) return; @@ -45,8 +211,20 @@ const ResultPanel = (() => { } } - /** 登记一个产物条目(tool_finished 事件) */ - function addToolResult(data) { + /** 登记一个产物条目(tool_finished 事件;sid = 事件桥会话,P3.2 消费) */ + function addToolResult(data, sid) { + const arr = artifactsFor(sid || currentSid); + arr.unshift({ + tool_name: data.tool_name || "tool", + content: String(data.content || ""), + error: !!data.error, + elapsed: data.elapsed, + }); + if (arr.length > 100) arr.pop(); + renderCard(data); + } + + function renderCard(data) { const list = listEl(); if (!list) return; // 空状态占位清除 @@ -144,6 +322,11 @@ const ResultPanel = (() => { function init() { const p = panel(); if (!p) return; + // 静态 Tab 点击(文件 / 产物) + const filesBtn = $("result-tab-files"); + const artBtn = $("result-tab-artifacts"); + if (filesBtn) filesBtn.addEventListener("click", () => activateTab("files")); + if (artBtn) artBtn.addEventListener("click", () => activateTab("artifacts")); // 折叠按钮 const btn = $("result-toggle"); if (btn) btn.addEventListener("click", toggle); @@ -161,14 +344,20 @@ const ResultPanel = (() => { if (mq.addEventListener) mq.addEventListener("change", onMq); if (mq.matches) setCollapsed(true); } - // 恢复折叠状态 + // 恢复折叠状态 + 宽度(分离持久化) try { - if (localStorage.getItem("emrg.resultPanel.collapsed") === "1") setCollapsed(true); + if (localStorage.getItem(LS_COLLAPSED) === "1") setCollapsed(true); + else setWidth(storedWidth()); } catch { /* ignore */ } + // 拖拽手柄 + initResizer(); + window.addEventListener("resize", updateResizerPos); + renderTabbar(); + activateTab(stateFor(currentSid).active); renderEmpty(); } - return { init, addToolResult, toggle, isCollapsed }; + return { init, addToolResult, toggle, isCollapsed, switchSession, openFileTab, closeFileTab, activateTab, getWidth, setWidth }; })(); // ⚠️ 必须暴露到 window:app.js 作为独立