diff --git a/Agent.md b/Agent.md index 8297280..2c2e1d2 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` (236: 44 daemon_client + 19 conn-manager + 22 app-commands + 114 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` (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` - **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` (236: 44 daemon_client + 19 conn-manager + 22 app-commands + 114 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` (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` 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/js/app.js b/emrg/gui/renderer/js/app.js index 6e6cdcf..a467c4a 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -567,13 +567,16 @@ const App = (() => { const panel = $(`panel-${p}`); if (panel) panel.classList.remove("active"); } + // 浏览器 tab 语义:仅**新建**容器滚到底;既有容器保留滚动位置(rant 18:55:09 验收 + // "回会话视图滚动位置保留"——面板往返/会话切换不跳底) + const existed = [...$("workspace").children].some((c) => c.dataset?.sid === sid); const view = ensureSessionView(sid); for (const child of $("workspace").children) { if (child.classList) child.classList.remove("active"); } view.classList.add("active"); - Chat.scrollToBottom(sid); - setWorkspaceChrome("sessions"); // 恢复输入区 + 成果面板 + if (!existed) Chat.scrollToBottom(sid); + setWorkspaceChrome("sessions"); // 恢复输入区 + 成果面板(含 back-to-bottom 按位置恢复) state.activeView = "sessions"; return view; } @@ -694,21 +697,45 @@ const App = (() => { const empty = $("empty-state"); const panel = $("result-panel"); const resizer = $("result-resizer"); + const btb = $("back-to-bottom"); if (mode === "panel") { composer.classList.add("hidden"); empty.classList.add("hidden"); if (panel) panel.classList.add("hidden"); if (resizer) resizer.classList.add("hidden"); + if (btb) btb.classList.add("hidden"); // 回到底部按钮属会话视图,面板视图下隐藏 // HTML 预览(WebContentsView)随面板隐藏(main 侧比对路径;无预览时 no-op) try { if (window.emrg && typeof window.emrg.closePreview === "function") window.emrg.closePreview({}); } catch { /* ignore */ } } else { composer.classList.remove("hidden"); if (panel) panel.classList.remove("hidden"); if (resizer) resizer.classList.remove("hidden"); + updateBackToBottomState(); // 回会话视图:按当前滚动位置恢复按钮 updateEmptyState(); } } + /** 当前滚动容器:激活会话的 .session-view(#workspace overflow:hidden 自身不滚动) */ + function activeScrollEl() { + const ws = $("workspace"); + if (state.sessionId) { + const v = [...(ws.children || [])].find((c) => c.dataset?.sid === state.sessionId); + if (v) return v; + } + const active = [...(ws.children || [])].find((c) => c.classList && c.classList.contains("active")); + return active || ws; + } + + /** 更新"回到底部"按钮 + autoScroll(scroll 事件不冒泡 → 需 capture 捕获子 .session-view 滚动) */ + function updateBackToBottomState() { + const btn = $("back-to-bottom"); + const el = activeScrollEl(); + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40; + state.autoScroll = atBottom; + // 上滑阅读(不在底部)→ 显示"回到底部"悬浮按钮(不打扰) + if (btn) btn.classList.toggle("hidden", atBottom); + } + /** rant 14:10:14 P2:设置面板 tab 切换(模型服务/工作目录/GitHub/外观/语言/关于)。 */ const SETTINGS_TABS = ["model", "workdir", "github", "appearance", "language", "about"]; function switchSettingsTab(name) { @@ -1638,16 +1665,12 @@ const App = (() => { }); const chatView = $("workspace"); - const updateBackToBottom = () => { - const btn = $("back-to-bottom"); - const atBottom = chatView.scrollTop + chatView.clientHeight >= chatView.scrollHeight - 40; - state.autoScroll = atBottom; - // 上滑阅读(不在底部)→ 显示"回到底部"悬浮按钮(不打扰) - if (btn) btn.classList.toggle("hidden", atBottom); - }; - chatView.addEventListener("scroll", updateBackToBottom); + // scroll 事件不冒泡 → 必须用 capture 捕获子 .session-view 的滚动(#634 起滚动容器下移; + // 此前监听挂在 #workspace 上从不触发,back-to-bottom 与滚动加载历史在真实 GUI 静默失效) + chatView.addEventListener("scroll", updateBackToBottomState, true); $("back-to-bottom").addEventListener("click", () => { - chatView.scrollTop = chatView.scrollHeight; + const el = activeScrollEl(); + el.scrollTop = el.scrollHeight; state.autoScroll = true; $("back-to-bottom").classList.add("hidden"); }); @@ -1661,7 +1684,7 @@ const App = (() => { clearTimeout(historyScrollTimer); historyScrollTimer = setTimeout(() => loadOlderHistory(state.sessionId), 150); } - }, { passive: true }); + }, { capture: true, passive: true }); // 空状态示例问题卡片 → 填入输入框 $("empty-state").addEventListener("click", (e) => { diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 0e21c4c..ebe60c3 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -2679,6 +2679,54 @@ test("rant 14:15:12:切会话加载最近历史(只读气泡 + 加载条) assert.ok(noMore, "无更多历史时加载条仍在(显示没有更多)"); }); +test("rant 18:55:09 v0.2 回归:back-to-bottom 跟随会话视图滚动(scroll 不冒泡 → capture 捕获子 .session-view)", async () => { + const { ctx, els } = makeSandbox({ + init: async () => ({ + config_exists: true, + api_key_configured: true, + project_dir: "/tmp", + project_dir_valid: true, + server_id: "srv-1", + model: "m", + evolution_count: 0, + sessions: [{ session_id: "s1", title: "测试对话", updated_at: "2026-08-05T10:00:00Z" }], + open_sessions: [], + active_sid: "s1", + }), + switchSession: async () => ({}), + }); + await vm.runInContext("App.boot()", ctx); + await tick(); + const view = vm.runInContext('document.getElementById("workspace").querySelector(".session-view")', ctx); + const btn = els["back-to-bottom"]; + // 初始:在底部 → 按钮隐藏、autoScroll=true + view.scrollTop = 100; + view.scrollHeight = 1000; + view.clientHeight = 100; // 100+100 >= 960? no → 不在底部 + els["workspace"].dispatch("scroll", { target: view }); + await tick(); + assert.strictEqual(btn.classList.contains("hidden"), false, "上滑(不在底部)→ back-to-bottom 显示"); + assert.strictEqual(vm.runInContext("App.state.autoScroll", ctx), false, "上滑 → autoScroll=false"); + // 点按钮 → 回到底部:scrollTop=scrollHeight、按钮隐藏 + vm.runInContext('document.getElementById("back-to-bottom").click()', ctx); + await tick(); + assert.strictEqual(view.scrollTop, view.scrollHeight, "点击后滚动容器回到底部"); + assert.strictEqual(btn.classList.contains("hidden"), true, "回到底部后按钮隐藏"); + assert.strictEqual(vm.runInContext("App.state.autoScroll", ctx), true, "回到底部后 autoScroll=true"); + // 面板视图激活 → 按钮强制隐藏(不悬浮覆盖面板) + view.scrollTop = 0; + els["workspace"].dispatch("scroll", { target: view }); + await tick(); + assert.strictEqual(btn.classList.contains("hidden"), false, "再次上滑 → 按钮复现"); + await vm.runInContext('document.getElementById("nav-projects").click()', ctx); + await tick(); + assert.strictEqual(btn.classList.contains("hidden"), true, "面板视图下 back-to-bottom 隐藏"); + // 回会话视图 → 按钮按滚动位置恢复(仍在上滑位置 → 复现) + await vm.runInContext('document.getElementById("nav-projects").click()', ctx); + await tick(); + assert.strictEqual(btn.classList.contains("hidden"), false, "回会话视图后按位置恢复按钮"); +}); + test("rant 18:55:09 v0.2:导航点击 → 工作区视图切换(面板整块显示 + 互斥 + 点同项关闭回会话)", async () => { const { ctx } = makeSandbox({ init: async () => ({