From 3849d3a56974f0b1f5dc1d3f64106e368a67518f Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 22:22:48 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20multi-session=20deviations=20fix?= =?UTF-8?q?=20=E2=80=94=20new-chat/open-session=20entries=20+=20per-sessio?= =?UTF-8?q?n=20drafts=20(rant=202026-08-10T21:59:11)?= 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 | 23 +++++++++++ emrg/gui/renderer/index.html | 2 + emrg/gui/renderer/js/app.js | 33 ++++++++++++++- emrg/gui/renderer/js/i18n.js | 4 ++ emrg/gui/test/renderer.smoke.test.js | 62 +++++++++++++++++++++++++++- emrg/server/evolution_prompt.md | 1 + 9 files changed, 126 insertions(+), 7 deletions(-) diff --git a/Agent.md b/Agent.md index 7a67b86..037bbdf 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` (179: 43 daemon_client + 19 conn-manager + 22 app-commands + 59 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` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 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` (687) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (179: 43 daemon_client + 19 conn-manager + 22 app-commands + 59 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` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 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` 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 3fe09ff..4ac1845 100644 --- a/README.cn.md +++ b/README.cn.md @@ -144,7 +144,7 @@ EMRG 不只是追赶——它自己追上来。 贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。 -快速检查:`uv run pytest tests/ -v`(当前 687 项)· `cd emrg/gui && npm test`(179 项:43 daemon_client + 19 conn-manager + 22 app-commands + 59 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) +快速检查:`uv run pytest tests/ -v`(当前 687 项)· `cd emrg/gui && npm test`(183 项:43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) --- diff --git a/README.md b/README.md index 886fab4..6f21ba0 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,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 687 items) · `cd emrg/gui && npm test` (179: 43 daemon_client + 19 conn-manager + 22 app-commands + 59 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) +Quick checks: `uv run pytest tests/ -v` (currently 687 items) · `cd emrg/gui && npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) --- diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index b9d5a0c..e130991 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -80,6 +80,29 @@ box-shadow: var(--shadow-md); } +/* B2(rant 21:59:11):"打开会话"入口——新对话按钮下的轻量 ghost 按钮 */ +.open-chat-btn { + margin: calc(-1 * var(--sp-1)) var(--sp-3) var(--sp-3); + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 8px var(--sp-3); + background: transparent; + color: var(--text-2); + font-size: var(--fs-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-btn); + cursor: pointer; + transition: + background-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.open-chat-btn:hover { + background: var(--bg-soft); + color: var(--text-1); +} + #conv-list { flex: 1; overflow-y: auto; diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index dfb90ed..12aa42f 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -25,6 +25,8 @@
边工作边学习,越用越懂你
+ + diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 91aacad..acfc2ec 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -35,6 +35,9 @@ const App = (() => { cmdMenu: { items: [], index: -1 }, // WorkBuddy P2(rant 21:35):工作模式 ask(纯对话)/ auto(默认,自动执行工具) mode: "auto", + // B3(rant 21:59:11):每会话输入框草稿(浏览器 tab 式状态保留)——切换会话保存旧 + // sid 草稿、恢复新 sid 草稿;发送成功即清除该 sid 草稿 + drafts: new Map(), // sid → 草稿文本 }; // P3 slice 1:会话条目访问器(get-or-create)。无 sid/未激活 → 归入当前激活会话。 @@ -144,6 +147,8 @@ const App = (() => { Chat.addUserMessage(text); input.value = ""; input.style.height = "auto"; + // B3:消息已发送 → 清除该会话草稿 + state.drafts.delete(state.sessionId); // G143:send 前预生成 requestId 并标记自有流——消除 IPC 往返竞态窗口 const requestId = genRequestId(); state.ownStreamRequestId = requestId; @@ -594,14 +599,30 @@ const App = (() => { return view; } + // B3(rant 21:59:11):会话切换时保留/恢复输入框草稿(浏览器 tab 式状态) + function saveDraft(sid) { + if (!sid) return; + state.drafts.set(sid, $("input").value); + } + function restoreDraft(sid) { + const input = $("input"); + input.value = sid ? (state.drafts.get(sid) || "") : ""; + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 150) + "px"; + } + async function switchSession(sid, opts = {}) { // G65:busy 即自有流进行中/发送中(IPC 往返窗口内 ownStreamRequestId 尚未赋值) if (state.busy) { Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); return; } try { + // B3:离开前保存当前会话草稿 + saveDraft(state.sessionId); const res = await window.emrg.switchSession({ sessionId: sid, projectPath: opts.projectPath }); state.sessionId = sid; + // B3:恢复目标会话草稿 + restoreDraft(sid); // P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId // P3 slice 2:激活该会话容器(状态保留,不 Chat.clear——切回继续看到原消息/流式现场) activateSessionView(sid); @@ -639,14 +660,18 @@ const App = (() => { return; } try { + // B3:离开前保存当前会话草稿;新会话从空草稿开始 + saveDraft(state.sessionId); const res = await window.emrg.newSession({ projectPath: opts.projectPath }); state.sessionId = res.session_id; + state.drafts.set(res.session_id, ""); // 新会话无草稿 activateSessionView(state.sessionId); // P3 slice 2:新会话独立容器(空) Chat.clear(state.sessionId); // 新会话从空开始(容器可能被复用) updateEmptyState(); // 欢迎屏即反馈 await refreshSessions(); Sidebar.highlight(state.sessionId); setComposerDisabled(false); // 防御性:独立调用 newSession 也确保输入框可用 + restoreDraft(state.sessionId); // 新会话草稿为空 → 清空输入框 } catch (e) { Chat.addSystemMessage(_t("app.newFailed", { msg: e.message })); } @@ -1277,7 +1302,10 @@ const App = (() => { function bindUi() { $("send-btn").addEventListener("click", sendMessage); $("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {})); - $("new-chat-btn").addEventListener("click", newSession); + // B1(rant 21:59:11):侧边栏"+ 新对话"→ 项目选择弹窗(选项目新建 / 新建项目),不再直接新建 + $("new-chat-btn").addEventListener("click", () => Dialogs.showNewSessionDialog()); + // B2(rant 21:59:11):侧边栏"打开会话"入口 → 两步弹窗(选项目 → 选会话) + $("open-chat-btn")?.addEventListener("click", () => Dialogs.showOpenSessionDialog()); Dialogs.initOpenSessionDialog(); // P5:打开会话对话框绑定 Dialogs.initNewSessionDialog(); // P5 slice 2:新建会话对话框绑定 $("open-session-new-session")?.addEventListener("click", () => Dialogs.showNewSessionDialog()); // P5 slice 2:打开弹窗 → 新建会话 @@ -1408,7 +1436,8 @@ const App = (() => { if (e.metaKey || e.ctrlKey) { if (e.key === "n") { e.preventDefault(); - newSession(); + // B1(rant 21:59:11):⌘N 与侧边栏按钮一致 → 项目选择弹窗 + Dialogs.showNewSessionDialog(); } else if (e.key === "b") { e.preventDefault(); document.body.classList.toggle("sidebar-collapsed"); diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index ce4b84f..43c57a7 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -19,6 +19,8 @@ const I18N = (() => { // 侧边栏 "sidebar.newChat": "+ 新对话", "sidebar.newChatTitle": "新对话 (⌘N)", + "sidebar.openChat": "打开会话", + "sidebar.openChatTitle": "打开会话(跨项目)", "sidebar.settings": "设置", "sidebar.statusTitle": "连接状态", "sidebar.backToBottom": "回到底部", @@ -344,6 +346,8 @@ const I18N = (() => { // Sidebar "sidebar.newChat": "+ New chat", "sidebar.newChatTitle": "New chat (⌘N)", + "sidebar.openChat": "Open session", + "sidebar.openChatTitle": "Open session (cross-project)", "sidebar.settings": "Settings", "sidebar.statusTitle": "Connection status", "sidebar.backToBottom": "Back to bottom", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 8f18862..b089a6a 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -109,7 +109,7 @@ const ELEMENT_IDS = [ "chat-view", "input", "send-btn", "stop-btn", "conv-list", "open-sessions", "open-sessions-label", "status-dot", "settings-btn", "open-session-dialog", "open-session-list", "open-session-title", "open-session-desc", "open-session-new", "open-session-cancel", "open-session-new-session", "new-session-dialog", "new-session-list", "new-session-new", "new-session-cancel", - "conn-banner", "empty-state", "model-switcher", "model-switcher-label", "brand-star", "new-chat-btn", + "conn-banner", "empty-state", "model-switcher", "model-switcher-label", "brand-star", "new-chat-btn", "open-chat-btn", "settings-dialog", "settings-cancel", "settings-save", "set-api-key", "set-base-url", "set-project-dir", "set-model", "pick-dir-btn", "theme-options", "welcome-dialog", "welcome-api-key", "welcome-base-url", "welcome-model", "welcome-project-dir", "welcome-pick-btn", "welcome-save", "confirm-dialog", @@ -1542,3 +1542,63 @@ test("P6: 打开会话弹窗项目行显示最近活跃提示(latest_session_a const texts1 = [...pick1.children].map((c) => c.textContent || ""); assert.ok(!texts1.some((t) => t.includes("前") || t.includes("ago")), "no activity hint when absent"); }); + +// ── rant 21:59:11:GUI 多会话实现偏差修正(B1-B3) ── + +test("B1: 侧边栏'+ 新对话'按钮 → 项目选择弹窗(不再直接新建)", async () => { + const newCalls = []; + const { els } = makeSandbox({ + listProjects: async () => [], + newSession: async (payload) => { newCalls.push(payload); return { session_id: "s2" }; }, + }); + await tick(); + els["new-chat-btn"].click(); + await tick(); + assert.strictEqual(els["new-session-dialog"].open, true, "new-session dialog opened (not direct newSession)"); + assert.strictEqual(newCalls.length, 0, "newSession NOT called directly by the button"); +}); + +test("B2: 侧边栏'打开会话'按钮 → 两步弹窗(项目→会话)", async () => { + const { els } = makeSandbox({ listProjects: async () => [] }); + await tick(); + els["open-chat-btn"].click(); + await tick(); + assert.strictEqual(els["open-session-dialog"].open, true, "open-session dialog opened"); +}); + +test("B3: 切换会话保存/恢复输入框草稿(每会话 draft,浏览器 tab 式)", async () => { + const { ctx, els } = makeSandbox({ switchSession: async () => ({}) }); + await tick(); + // 在 s1 输入草稿 → 切到 s2(s2 无草稿 → 清空)→ 切回 s1(草稿恢复) + await vm.runInContext("App.state.sessionId = 's1'", ctx); + els["input"].value = "s1 draft"; + await vm.runInContext("App.switchSession('s2')", ctx); + await tick(); + assert.strictEqual(els["input"].value, "", "s2 has no draft → input cleared"); + await vm.runInContext("App.switchSession('s1')", ctx); + await tick(); + assert.strictEqual(els["input"].value, "s1 draft", "s1 draft restored after switching back"); +}); + +test("B3: 发送消息清除该会话草稿;新会话从空草稿开始", async () => { + const { ctx, els } = makeSandbox({ + sendMessage: async () => ({}), + newSession: async () => ({ session_id: "s-new" }), + }); + await tick(); + // 发送后草稿删除 + await vm.runInContext("App.state.sessionId = 's1'; App.state.drafts.set('s1', 'draft')", ctx); + els["input"].value = "hello"; + els["send-btn"].click(); + await tick(); + const hasDraft = await vm.runInContext("App.state.drafts.has('s1')", ctx); + assert.strictEqual(hasDraft, false, "draft cleared after send"); + // 新建会话 → 新会话草稿为空 → 输入框清空 + await vm.runInContext("App.state.sessionId = 's-old'; App.state.drafts.set('s-old', 'old draft')", ctx); + els["input"].value = "old draft"; + await vm.runInContext("App.newSession()", ctx); + await tick(); + assert.strictEqual(els["input"].value, "", "new session input cleared"); + const newDraft = await vm.runInContext("App.state.drafts.get('s-new')", ctx); + assert.strictEqual(newDraft, "", "new session starts with empty draft"); +}); diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 9695068..99cc78f 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -402,6 +402,7 @@ When reading rants, follow these rules: > - GUI project-row recent-activity hints (#646 GUI 多会话 rant 2026-08-10T15:07:19 验收体验补完:打开/新建会话弹窗项目行显示"最近活跃"相对时间——utils.js 新增 `relTime(iso)`(ISO → "刚刚/N 分钟前/N 小时前/N 天前",走 i18n zh/en,缺失/非法输入返回空串);dialogs.js showOpenSessionDialog + showNewSessionDialog 项目行消费 daemon `latest_session_at`(#644 新增字段)追加 hint span(无字段不显示);i18n zh/en 4 键(relTime.*);+2 测试 renderer.smoke 57→59(relTime 六态:刚刚/分钟/小时/天/空/非法 + 弹窗行活动提示/无字段不显示);GUI 176→178 三文档同步;681 pytest 全绿;"按最近活跃排序"从隐式排序变为可见提示,宿主可感知排序依据) ✅ > - Windows release zip asset (#649 宿主 rant 2026-08-10T20:10:41:Windows 发布资产除 exe 安装包外再提供 zip 压缩版——`build-release.yml` Windows job 在 Make installer 后新增 "Zip installer (Windows only)" 步骤(find `EMRG-*-windows-x64.exe` → PowerShell `Compress-Archive -Force` → bash `test -s` 校验非空)+ upload-artifact glob 加 `dist/artifacts/*.zip`(release job `files: artifacts/*` 自动带上);actionlint 1.7.12 全工作流验证通过;**tag-push 构建教训**:tag 触发的工作流读 tag ref 上的 workflow 文件——v0.2.21 tag 早于 #649 合并 → 该版本 release 无 zip(6 资产符合原始 release rant 验收),zip 自 v0.2.22 起随 CI 产出;验证=CI Windows job 全绿 + actionlint clean;workflow-only 改动,测试计数不变) ✅ > - GUI packaged local-module whitelist guard (#651 宿主 rant 2026-08-10T20:37:08:v0.2.21 Windows 打包版启动崩溃 `Cannot find module './conn-manager'`——main.js:15/16 require 的 **conn-manager.js 和 gui-state.js 都不在 electron-builder `files` 白名单**(多会话 P2/P4 新增模块忘更新,与 #612 vendor 同根因类);修复=①package.json build.files 加 `conn-manager.js` + `gui-state.js`;②test/build-config.test.js 新增**通用守卫**:扫描 main.js/preload.js 每个本地 `require("./x")` 断言被 files 白名单覆盖(支持 `x.js` 与 `dir/**` 形态;正反两态验证——删白名单项即红并命名缺失模块)——**#612/#651 整个"新增模块忘加白名单"类闭合**;GUI 178→179(build-config 3→4)三文档同步;验证=GUI 179/179 ✓ pytest 681 ✓ doc guard 3/3 ✓;打包版自 v0.2.22 起修复) ✅ +> - Mid-turn queue injection P1 (#655 宿主 rant 2026-08-10T21:55:37:tool loop 进行中发新消息 → **daemon 排队注入**(对齐 codex steer_input)——busy 时新消息入 per-session FIFO 队列(`_session_pending`),当前 round(LLM 请求+全部工具执行)结束后、下一轮 LLM 请求前注入;**不打断工具执行、不丢消息**;busy 分支不再回 "session busy" error → `task_queued`(含 position);注入走 `_inject_pending_messages`(原子 pop 防并发 append 丢失 + `append_message` 持久化保 auto-compact + `steer_committed` 广播);round 预算=注入轮不消耗(stop/Case3/循环用尽均先重查 pending 再 return);wrapper finally:正常结束 → `queued_requeue`(request_ids,客户端自动重发)、cancel/异常/clear/delete → `queued_cancelled`(cancel_event 判定防误重发);Ask mode 注入轮空工具集;clear/delete_session 清队列;协议帧 4 个 daemon→client 广播(P2 GUI / P3 TUI 客户端侧后续 rant);pytest 681→687(+7 e2e)、GUI 179 不变;合并 535efd2) ✅ #### 2.2 Latest GitHub code changes