Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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/`)
Expand Down Expand Up @@ -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 路径不受影响)

Expand Down
47 changes: 35 additions & 12 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
});
Expand All @@ -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) => {
Expand Down
48 changes: 48 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down
Loading