diff --git a/Agent.md b/Agent.md index 6a0b3b75..95d5db78 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` (244: 44 daemon_client + 19 conn-manager + 22 app-commands + 119 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (245: 44 daemon_client + 19 conn-manager + 22 app-commands + 120 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); 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` (804) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (244: 44 daemon_client + 19 conn-manager + 22 app-commands + 119 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (245: 44 daemon_client + 19 conn-manager + 22 app-commands + 120 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — 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/main.js b/emrg/gui/main.js index c73c1e1d..d64e6629 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -375,6 +375,12 @@ vision = false const clean = String(title || "").trim().slice(0, 80); // 截断超长标题 if (!clean) throw new Error("empty title"); const frame = await requireConn().sendCommandAndWait("rename_session", { session_id: sessionId, cwd: projectDir, title: clean }, 5000); + // 跨项目会话重命名成功后立即同步侧边栏标题(rant 12:01:44) + const v = openSessions.get(sessionId); + if (v) { + v.title = frame.title || clean; + broadcastOpenSessions(); + } return { ok: true, title: frame.title || clean }; }); @@ -978,6 +984,19 @@ vision = false lastActive: new Date().toISOString(), }); broadcastOpenSessions(); + // 跨项目标题(rant 12:01:44):异步拉该会话所在项目列表取 title—— + // 找到则 v.title = s.title 并广播(失败/无 title → 保持 undefined → 侧边栏 sid 兜底) + listSessions(projectPath) + .then((sessions) => { + const v = openSessions.get(sid); + if (!v) return; // 会话已关闭/移除 + const s = sessions.find((x) => x.session_id === sid); + if (s && s.title) { + v.title = s.title; + broadcastOpenSessions(); + } + }) + .catch(() => { /* 拉取失败保持 undefined → sid 兜底 */ }); } // 激活会话变化(切换/新会话/发送)→ 更新 lastActive + activeSid → 防抖写盘 @@ -1084,7 +1103,7 @@ vision = false function openSessionsList() { return [...openSessions.entries()] - .map(([sid, v]) => ({ sid, projectName: v.projectName, projectPath: v.projectPath, lastActive: v.lastActive })) + .map(([sid, v]) => ({ sid, projectName: v.projectName, projectPath: v.projectPath, lastActive: v.lastActive, title: v.title })) .sort((a, b) => String(b.lastActive || "").localeCompare(String(a.lastActive || ""))); } @@ -1163,9 +1182,9 @@ vision = false }); } - async function listSessions() { + async function listSessions(cwd = projectDir) { try { - const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd: projectDir }, 5000); + const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd }, 5000); return frame.sessions || []; } catch (e) { logger.warn(`[gui] list_sessions failed: ${e.message}`); diff --git a/emrg/gui/renderer/js/sidebar.js b/emrg/gui/renderer/js/sidebar.js index 595416c4..9c6b4525 100644 --- a/emrg/gui/renderer/js/sidebar.js +++ b/emrg/gui/renderer/js/sidebar.js @@ -27,7 +27,7 @@ const Sidebar = (() => { const known = (App.state && App.state.sessions) || []; for (const entry of openSessions) { const cur = known.find((s) => s.session_id === entry.sid) || {}; - const title = cur.title || entry.sid; // G27:title 优先 + const title = entry.title || cur.title || entry.sid; // entry.title 优先(跨项目),再 cur.title(当前项目),最后 sid const item = el("div", { class: "conv-item open-session-item" }); item.dataset.sid = entry.sid; item.appendChild(el("span", { class: "conv-title" }, _t("sidebar.openSessionOf", { project: entry.projectName || "", title }))); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index ab1b9d2b..e65c0c1e 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1913,6 +1913,30 @@ test("P4 s2: open_sessions 事件 → 渲染打开会话区(项目名/标题 + assert.strictEqual(els["open-sessions-label"].hidden, true, "label hidden when no open sessions"); }); +test("P4 s2: 跨项目打开会话(entry.title 优先,state.sessions 无该 sid)→ 显示 entry.title 而非 sid", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-x";' + + 'App.state.sessions = [{ session_id: "sess-local", title: "Local" }];' + // 当前项目会话;无 sess-x / sess-other + 'App.handleEvent({ type: "open_sessions", data: { openSessions: [' + // main 已按 lastActive 倒序 + ' { sid: "sess-x", projectName: "evolution", projectPath: "/p/evolution", lastActive: "t3", title: "Evolution Task" },' + // 跨项目 + title + ' { sid: "sess-other", projectName: "mem", projectPath: "/p/mem", lastActive: "t2" },' + // 跨项目无 title → sid 兜底 + ' { sid: "sess-local", projectName: "emrg", projectPath: "/p/emrg", lastActive: "t1" }' + // 当前项目 → state.sessions title + '] } });', + ctx + ); + const nav = els["open-sessions"]; + assert.strictEqual(nav.children.length, 3, "three open-session items rendered"); + const t0 = nav.children[0].children[0] || nav.children[0]; + assert.ok((t0.textContent || "").includes("Evolution Task"), "cross-project entry shows entry.title"); + assert.ok(!(t0.textContent || "").includes("sess-x"), "does NOT fall back to sid when entry.title present"); + const t1 = nav.children[1].children[0] || nav.children[1]; + assert.ok((t1.textContent || "").includes("sess-other"), "cross-project no title + not in state.sessions → sid fallback"); + const t2 = nav.children[2].children[0] || nav.children[2]; + assert.ok((t2.textContent || "").includes("Local"), "current-project entry still resolves via state.sessions title"); +}); + test("P4 s2: closeOpenSession 关闭激活会话 → 切到剩余打开会话 + 容器释放", async () => { let closed = null; const { ctx, els } = makeSandbox({