From b006aedfbebee596f85400c2ff04dd4d57b1ceac Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 17:43:51 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20disconnected=20event=20per-sid=20?= =?UTF-8?q?isolation=20=E2=80=94=20P3=20finalize=20(GUI=20multi-session=20?= =?UTF-8?q?rant=20P3)?= 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 | 11 ++++ emrg/gui/renderer/js/app.js | 32 +++++++++-- emrg/gui/renderer/js/chat.js | 5 ++ emrg/gui/renderer/js/i18n.js | 2 + emrg/gui/test/renderer.smoke.test.js | 83 ++++++++++++++++++++++++++++ 8 files changed, 131 insertions(+), 10 deletions(-) diff --git a/Agent.md b/Agent.md index 493ee93..c826a40 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - 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` (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); 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) @@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (680) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — 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 8a9fc2b..cd65f0b 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(153 项:43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(156 项:43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index babce42..45b11ed 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (156: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 2c0ca39..2490b48 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -135,6 +135,17 @@ .session-view.active { display: flex; } +/* P3 finalize:会话连接断开标记(P4 每会话容器可见;激活会话另走全局横幅) */ +.session-view.disconnected { + opacity: 0.55; +} +.session-view.disconnected::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient(45deg, transparent 0 8px, rgba(128, 128, 128, 0.08) 8px 16px); +} #chat-view > *:not(.session-view), .session-view > * { width: 100%; diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index ac0f172..1a3906c 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -39,7 +39,7 @@ const App = (() => { function sidState(sid) { const key = sid || state.sessionId || "default"; if (!state.sessionsBySid.has(key)) { - state.sessionsBySid.set(key, { busy: false, ownStreamRequestId: null, mode: "auto", autoScroll: true }); + state.sessionsBySid.set(key, { busy: false, ownStreamRequestId: null, mode: "auto", autoScroll: true, disconnected: false }); } return state.sessionsBySid.get(key); } @@ -603,6 +603,10 @@ const App = (() => { if (!opts.silent) { Chat.addSystemMessage(_t("app.switched"), sid); } + // P3 finalize:切入断线会话 → 提示自动重连中(状态保留,不打断输入——G89) + if (sidState(sid).disconnected) { + Chat.addSystemMessage(_t("app.sessionDisconnected"), sid); + } updateEmptyState(); Sidebar.highlight(sid); setComposerDisabled(false); // 防御性:独立调用 switchSession 也确保输入框可用 @@ -1074,15 +1078,26 @@ const App = (() => { Sidebar.render(data.sessions || []); break; case "disconnected": - updateConnectionDot("red"); - showBanner(EMRG_Copy.COPY.disconnected); - // G89:断连时恢复输入条(不能依赖 30s 超时兜底)——仅当激活会话断连 - // P3 slice 1:断连释放该事件所属会话的锁 + // P3 finalize:断连按 sid 隔离——后台会话断连不影响全局 UI(无横幅/红点), + // 仅激活会话(或无 sid 的单会话过渡期)断连显示全局横幅 + 红点。 { const sst = sidState(sid); sst.busy = false; sst.ownStreamRequestId = null; - if (!sid || sid === state.sessionId) setComposerDisabled(false); + sst.disconnected = true; // P3 finalize:该会话条目标断线(P4 恢复 UI 用) + const isActive = !sid || sid === state.sessionId; + if (isActive) { + updateConnectionDot("red"); + showBanner(EMRG_Copy.COPY.disconnected); + // G89:断连时恢复输入条(不能依赖 30s 超时兜底) + setComposerDisabled(false); + } + // 容器标断线(仅当该 sid 有**独立注册**容器——chatContainer 对未注册 + // sid 回退激活容器,打错标;P4 openSessions 后每会话都有注册容器) + if (Chat.hasContainer(sid)) { + const cv = Chat.chatContainer(sid); + if (cv && cv.classList) cv.classList.add("disconnected"); + } } // P3:广播分组缓存清理按会话隔离(DOM 保留;仅清该会话 Map 引用;无 sid → 默认桶) Chat.groupNodesFor(sid).clear(); @@ -1133,6 +1148,11 @@ const App = (() => { if (data.connected) { updateConnectionDot("green"); hideBanner(); + // P3 finalize:重连成功 → 清全部会话断线标记 + 容器 .disconnected 类 + for (const entry of state.sessionsBySid.values()) entry.disconnected = false; + for (const child of $("chat-view").children) { + if (child.classList && child.classList.contains("disconnected")) child.classList.remove("disconnected"); + } if (data.server_id) state.serverId = data.server_id; if (data.model) state.model = data.model; state.evolutionCount = data.evolution_count ?? state.evolutionCount; diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 9f5cfb4..3bb0b11 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -36,6 +36,10 @@ const Chat = (() => { containers.delete(sid); sessionState.delete(sid); // 会话关闭 → 释放其分组/工具行/已 done 状态 } + /** 该 sid 是否有**独立注册**容器(区别于 chatContainer 的回退链——断线标记只打真实容器) */ + function hasContainer(sid) { + return containers.has(sid); + } /** 复制代码按钮(设计 §3.3):事件委托在聊天区,CSP 无内联 handler */ function initCodeCopy() { @@ -318,6 +322,7 @@ const Chat = (() => { clearTyping, registerContainer, unregisterContainer, + hasContainer, chatContainer, // P3:会话级状态访问器(sid=null → 旧版单会话桶,兼容既有调用方) groupNodesFor: (sid) => st(sid).groupNodes, diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 093037d..e8103c3 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -288,6 +288,7 @@ const I18N = (() => { "app.triggered": "已触发任务 {n}。", "app.deletedSwitch": "这个对话已被删除,已帮你切到最近的对话。", "app.switched": "已切换对话。", + "app.sessionDisconnected": "该会话连接已断开,正在自动重连…", "app.switchFailed": "切换对话失败了:{msg}", "app.newFailed": "新建对话失败了:{msg}", "app.deleteFailed": "删除失败了:{msg}", @@ -580,6 +581,7 @@ const I18N = (() => { "app.triggered": "Task {n} triggered.", "app.deletedSwitch": "This conversation was deleted — switched to the most recent one.", "app.switched": "Conversation switched.", + "app.sessionDisconnected": "This session's connection is lost — reconnecting automatically…", "app.switchFailed": "Failed to switch: {msg}", "app.newFailed": "Failed to create conversation: {msg}", "app.deleteFailed": "Delete failed: {msg}", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 7a1ca69..29d05bf 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1133,3 +1133,86 @@ test("P3 s2: 未注册 sid 的事件落激活容器,状态桶仍按 sid 隔离 assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-x").size, 0, "active bucket untouched by other sid"); }); +// ── P3 finalize(rant 15:07:19):disconnected 按 sid 隔离 + 断线标记 ──── + +test("P3 fin: 后台会话断连不触发全局横幅;仅激活会话断连显示", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + els["conn-banner"].classList.add("hidden"); // index.html 初始 hidden + await vm.runInContext( + 'App.state.sessionId = "sess-active";' + + 'App.activateSessionView("sess-active");' + + 'App.activateSessionView("sess-bg");' + + 'App.state.sessionId = "sess-active";', + ctx + ); + // 后台会话断连:有独立注册容器 → 标 .disconnected;无全局横幅/红点 + await vm.runInContext('App.handleEvent({ type: "disconnected", sid: "sess-bg", data: {} });', ctx); + const vb = ctx.EMRG_Chat.chatContainer("sess-bg"); + assert.strictEqual(vb.classList.contains("disconnected"), true, "bg container marked disconnected"); + assert.strictEqual(els["conn-banner"].classList.contains("hidden"), true, "no global banner for bg disconnect"); + // 激活会话断连:全局横幅显示 + 容器标断线 + 输入条恢复(G89) + await vm.runInContext('App.handleEvent({ type: "disconnected", sid: "sess-active", data: {} });', ctx); + const va = ctx.EMRG_Chat.chatContainer("sess-active"); + assert.strictEqual(va.classList.contains("disconnected"), true, "active container marked disconnected"); + assert.strictEqual(els["conn-banner"].classList.contains("hidden"), false, "global banner shown for active disconnect"); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-bg").disconnected, true, "bg entry flagged"); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-active").disconnected, true, "active entry flagged"); +}); + +test("P3 fin: status connected 清除全部断线标记 + 容器类", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-a";' + + 'App.activateSessionView("sess-a");' + + 'App.activateSessionView("sess-b");' + + 'App.handleEvent({ type: "disconnected", sid: "sess-a", data: {} });' + + 'App.handleEvent({ type: "disconnected", sid: "sess-b", data: {} });', + ctx + ); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-a").disconnected, true); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-b").disconnected, true); + await vm.runInContext('App.handleEvent({ type: "status", data: { connected: true } });', ctx); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-a").disconnected, false, "flag cleared on reconnect"); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-b").disconnected, false, "bg flag cleared on reconnect"); + const va = ctx.EMRG_Chat.chatContainer("sess-a"); + const vb = ctx.EMRG_Chat.chatContainer("sess-b"); + assert.strictEqual(va.classList.contains("disconnected"), false, "container class removed"); + assert.strictEqual(vb.classList.contains("disconnected"), false, "bg container class removed"); + assert.strictEqual(els["conn-banner"].classList.contains("hidden"), true, "banner hidden after reconnect"); +}); + +test("P3 fin: 未注册容器 sid 断连不打标;切到断线会话提示重连", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-active";' + + 'App.activateSessionView("sess-active");', + ctx + ); + const va = ctx.EMRG_Chat.chatContainer("sess-active"); + const lenBefore = va.children.length; + // 未注册容器(且非激活)的 sid 断连:不打 .disconnected(防 chatContainer 回退误标激活容器) + await vm.runInContext('App.handleEvent({ type: "disconnected", sid: "sess-ghost", data: {} });', ctx); + assert.strictEqual(va.classList.contains("disconnected"), false, "active container not falsely marked"); + assert.strictEqual(va.children.length, lenBefore, "no node added to active container"); + // 断线会话切入 → 提示自动重连(switchSession 走 mock IPC) + await vm.runInContext( + 'App.state.sessionId = "sess-d";' + // 经 defineProperty setter 触发 sidState get-or-create 建条目 + 'App.state.busy = false;' + + 'App.state.sessions = [{ session_id: "sess-d" }];' + + 'App.state.sessionsBySid.get("sess-d").disconnected = true;', + ctx + ); + await vm.runInContext('App.switchSession("sess-d", { silent: true });', ctx); + await tick(); + const vd = ctx.EMRG_Chat.chatContainer("sess-d"); + assert.ok(vd.children.length >= 1, "switch renders reconnect notice into session container"); + assert.ok( + (vd.children[vd.children.length - 1].textContent || "").includes("重连") || (vd.children[vd.children.length - 1].textContent || "").includes("reconnect"), + "reconnect notice text present" + ); +}); + +