diff --git a/Agent.md b/Agent.md index ea9ce0be..493ee933 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` (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - 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` - **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` (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 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` (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` 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 cc900a4c..8a9fc2b9 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 测试(150 项:43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) +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) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index fdcfeb54..babce42d 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 (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 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 (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) ``` 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 468f01f6..2c0ca395 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -112,17 +112,31 @@ background: var(--bg); } -/* 聊天滚动区:居中单栏最大 760px,左右留白 ≥24px */ +/* 聊天区包装(P3 slice 2:多会话容器——每会话一个 .session-view,display 切换) */ #chat-view { flex: 1; - overflow-y: auto; + position: relative; + overflow: hidden; display: flex; flex-direction: column; +} + +/* 每会话滚动容器:只有激活会话可见,切换 display 保留状态(浏览器 tab 效果) */ +.session-view { + position: absolute; + inset: 0; + overflow-y: auto; + display: none; + flex-direction: column; gap: var(--sp-5); padding: var(--sp-5) var(--sp-5) var(--sp-4); scroll-behavior: smooth; } -#chat-view > * { +.session-view.active { + display: flex; +} +#chat-view > *:not(.session-view), +.session-view > * { width: 100%; max-width: 760px; margin-left: auto; @@ -231,7 +245,8 @@ body.sidebar-collapsed #app { #sidebar { display: none; } - #chat-view { + /* #chat-view 现为绝对定位包装(padding 移入 .session-view,避免滚动条内缩) */ + .session-view { padding: var(--sp-4) var(--sp-4) var(--sp-3); } #composer-wrap { diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index da55374c..ac0f172d 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -164,7 +164,7 @@ const App = (() => { return; } await window.emrg.clearSession({ sessionId: state.sessionId }); - Chat.clear(); + Chat.clear(state.sessionId); Chat.addSystemMessage(_t("app.cleared")); break; case "/compact": @@ -323,7 +323,7 @@ const App = (() => { async function doRewind(recordIndex) { try { const res = await window.emrg.rewindSession({ sessionId: state.sessionId, recordIndex }); - Chat.clear(); + Chat.clear(state.sessionId); Chat.addSystemMessage(_t("app.rewound", { index: recordIndex, n: res.removedCount ?? 0 })); } catch (e) { Chat.addSystemMessage(_t("app.rewindFailed", { msg: e.message })); @@ -553,6 +553,30 @@ const App = (() => { } // ── 会话 ───────────────────────────────── + // P3 slice 2:每会话一个 .session-view 容器(浏览器 tab 效果——切换 display 保留状态) + function ensureSessionView(sid) { + if (!sid) return $("chat-view"); + // 在 wrapper 内按 dataset.sid 查找既有容器(不依赖 getElementById——测试沙箱对 + // 未知 id 返回新 mock,且 mock 的 .id 不入 attributes;dataset 双端一致) + let view = [...$("chat-view").children].find((c) => c.dataset?.sid === sid); + if (!view) { + view = el("div", { class: "session-view", id: "chat-view-" + sid, dataset: { sid } }); + $("chat-view").appendChild(view); + } + Chat.registerContainer(sid, view); // 幂等:重复注册覆盖同一元素 + return view; + } + + function activateSessionView(sid) { + const view = ensureSessionView(sid); + for (const child of $("chat-view").children) { + if (child.classList) child.classList.remove("active"); + } + view.classList.add("active"); + Chat.scrollToBottom(sid); + return view; + } + async function switchSession(sid, opts = {}) { // G65:busy 即自有流进行中/发送中(IPC 往返窗口内 ownStreamRequestId 尚未赋值) if (state.busy) { Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); @@ -562,8 +586,8 @@ const App = (() => { const res = await window.emrg.switchSession({ sessionId: sid }); state.sessionId = sid; // P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId - // (G110 旧语义=清全局自有流标记;per-sid 后由各会话条目自己管理,P4 切回继续生成) - Chat.clear(); + // P3 slice 2:激活该会话容器(状态保留,不 Chat.clear——切回继续看到原消息/流式现场) + activateSessionView(sid); updateEmptyState(); if (res.error === "session_not_found") { Chat.addSystemMessage(_t("app.deletedSwitch")); @@ -577,7 +601,7 @@ const App = (() => { } // G13:v1 不加载历史(G12) if (!opts.silent) { - Chat.addSystemMessage(_t("app.switched")); + Chat.addSystemMessage(_t("app.switched"), sid); } updateEmptyState(); Sidebar.highlight(sid); @@ -595,7 +619,8 @@ const App = (() => { try { const res = await window.emrg.newSession(); state.sessionId = res.session_id; - Chat.clear(); + activateSessionView(state.sessionId); // P3 slice 2:新会话独立容器(空) + Chat.clear(state.sessionId); // 新会话从空开始(容器可能被复用) updateEmptyState(); // 欢迎屏即反馈 await refreshSessions(); Sidebar.highlight(state.sessionId); @@ -612,6 +637,10 @@ const App = (() => { } try { await window.emrg.deleteSession({ sessionId: sid }); + // P3 slice 2:删除会话 → 释放其容器(若已打开) + Chat.unregisterContainer(sid); + const view = [...$("chat-view").children].find((c) => c.dataset?.sid === sid); + if (view) view.remove(); if (state.sessionId === sid) { const remaining = state.sessions.filter((s) => s.session_id !== sid); if (remaining.length > 0) { @@ -959,8 +988,9 @@ const App = (() => { // ── 空状态欢迎屏 ─────────────────────── function updateEmptyState() { const empty = $("empty-state"); - const chatView = $("chat-view"); - empty.classList.toggle("hidden", chatView.children.length > 0); + // P3 slice 2:检查**激活会话**容器(wrapper 恒含 session-view 子节点,不能数 wrapper) + const activeView = Chat.chatContainer(state.sessionId); + empty.classList.toggle("hidden", activeView.children.length > 0); } // ── 输入条状态 ───────────────────────── @@ -1297,6 +1327,7 @@ const App = (() => { switchSession, newSession, deleteSession, + activateSessionView, // P3 slice 2:激活会话容器(display 切换;导出供测试) refreshSessions, showConvMenu, handleEvent, diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 4ca831e5..9f5cfb4b 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -20,9 +20,12 @@ const Chat = (() => { return sessionState.get(key); } - /** 该会话的渲染容器:已注册的独立容器优先,否则默认聊天区(P3 过渡期行为) */ + /** 该会话的渲染容器:已注册的独立容器优先,其次当前激活会话的容器(无 sid 事件落激活会话,P3 slice 2),最后回退默认聊天区(单会话过渡期行为)。 */ function chatContainer(sid) { - return containers.get(sid) || $("chat-view"); + if (containers.has(sid)) return containers.get(sid); + const active = App.state?.sessionId; + if (active && containers.has(active)) return containers.get(active); + return $("chat-view"); } /** P4 起:为新打开的会话注册独立容器;关闭时 unregister 清引用 */ diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 178b547a..7a1ca69d 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -36,7 +36,13 @@ function makeEl(id) { _update() { node.className = [...node._cls].join(" "); }, className: "", textContent: "", - innerHTML: "", + _html: "", + get innerHTML() { return this._html; }, + set innerHTML(v) { + this._html = v; + // 忠实 DOM:innerHTML = "" 清空子节点(Chat.clear 依赖;P3 s2 容器隔离判别) + if (v === "") { while (this.children.length) this.children[0].remove(); } + }, value: "", disabled: false, title: "", @@ -1060,3 +1066,70 @@ test("P3 s1: cancelled 带 sid → 只清该会话条目;无 sid → 清激活 await vm.runInContext('App.handleEvent({ type: "cancelled", data: {} });', ctx); assert.strictEqual(ctx.App.state.busy, false, "no-sid cancelled clears active"); }); + +// ── P3 slice 2(rant 15:07:19):每会话 .session-view 容器 + display 切换 ── + +test("P3 s2: activateSessionView 建独立容器并切换 display(仅激活可见)", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-a";' + + 'App.activateSessionView("sess-a");' + + 'App.state.sessionId = "sess-b";' + + 'App.activateSessionView("sess-b");', + ctx + ); + // 沙箱 getElementById 对未知 id 返回新 mock —— 视图经 Chat.chatContainer(sid) 取实际对象 + const va = ctx.EMRG_Chat.chatContainer("sess-a"); + const vb = ctx.EMRG_Chat.chatContainer("sess-b"); + assert.notStrictEqual(va, vb, "two distinct per-session views"); + assert.strictEqual(va.classList.contains("active"), false, "sess-a deactivated after switch"); + assert.strictEqual(vb.classList.contains("active"), true, "sess-b is the active view"); + assert.strictEqual(els["chat-view"].children.length, 2, "both views live under the #chat-view 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)"); +}); + +test("P3 s2: Chat.clear 只清目标容器——无 sid 清激活,带 sid 定向清", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-a";' + + 'App.activateSessionView("sess-a");' + + 'App.state.sessionId = "sess-b";' + + 'App.activateSessionView("sess-b");' + + 'EMRG_Chat.addSystemMessage("msg-a", "sess-a");' + + 'EMRG_Chat.addSystemMessage("msg-b", "sess-b");', + ctx + ); + 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"); + // 无 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 (切回继续看到原消息)"); + // 带 sid clear → 定向清 + ctx.EMRG_Chat.clear("sess-a"); + assert.strictEqual(va.children.length, 0, "targeted clear empties sess-a"); +}); + +test("P3 s2: 未注册 sid 的事件落激活容器,状态桶仍按 sid 隔离", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-x";' + + 'App.activateSessionView("sess-x");', + ctx + ); + 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(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"); +}); +