diff --git a/Agent.md b/Agent.md index 5e2dfbfb..0bf1fe4f 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` (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); 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` (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state) — 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 ee14d6f3..40020a5f 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 测试(169 项:43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(172 项:43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index af1c210a..52783474 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 (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; 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/main.js b/emrg/gui/main.js index 362a3884..d1b00e04 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -275,9 +275,11 @@ vision = false throw new Error("invalid request_id"); // G143:renderer 预生成 id 的格式护栏 } // P2:每会话独立连接——首条消息前自动打开(新会话不 resume,daemon 隐式订阅) + // P5 slice 2:cwd 取该会话所属项目(跨项目会话用其项目路径,非全局 projectDir) + const sessionCwd = openSessions.get(sessionId)?.projectPath || projectDir; let conn = connManager?.get(sessionId); if (!conn || !conn.connected) { - conn = await openSession(sessionId, projectDir, { resume: false }); + conn = await openSession(sessionId, sessionCwd, { resume: false }); } markSessionActive(sessionId); // P4:发送 = 会话活动 → lastActive + 持久化 let rid; @@ -285,7 +287,7 @@ vision = false // G143:renderer 预生成 requestId(send 前标记自有流,消除 IPC 往返竞态窗口) // WorkBuddy P2:mode="ask" → 纯对话(daemon 不启用工具) // G65:conn.sendTask 内部标记 ownStream(每连接独立锁) - rid = conn.sendTask({ sessionId, cwd: projectDir, prompt: text, stream: true, requestId, mode }); + rid = conn.sendTask({ sessionId, cwd: sessionCwd, prompt: text, stream: true, requestId, mode }); } catch (e) { conn._releaseOwnStream(); // sendTask 抛异常(ws.send 失败)→ 释放锁,防 G65 锁泄漏 throw e; @@ -295,15 +297,17 @@ vision = false ipcMain.handle("emrg:listSessions", async () => listSessions()); - ipcMain.handle("emrg:switchSession", async (_e, { sessionId }) => { + ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); // G65:自有流运行中禁止切会话(每连接独立锁,查当前激活连接) if (connManager?.get(currentSessionId)?.ownStream) throw new Error("stream in progress — cannot switch"); const prevSid = currentSessionId; // G110:切会话清空旧连接分组缓存(含 timer),防广播"幽灵"残留 connManager?.get(prevSid)?.clearGroups(); + // P5 slice 2:跨项目打开——用该项目路径 resume(非全局 projectDir) + const targetPath = projectPath || openSessions.get(sessionId)?.projectPath || projectDir; try { - await openSession(sessionId, projectDir); // 打开(新)会话连接 + resume_session 自动订阅 + await openSession(sessionId, targetPath); // 打开(新)会话连接 + resume_session 自动订阅 } catch (e) { // G106:被动删除恢复——resume error → 刷新列表 + 切最近 if (/not found|error/i.test(e.message)) { @@ -354,12 +358,14 @@ vision = false activeSid: currentSessionId, })); - ipcMain.handle("emrg:newSession", async () => { + ipcMain.handle("emrg:newSession", async (_e, { projectPath } = {}) => { // G14/G81:本地生成 session_id(无 new_session 消息) const sid = generateSessionId(); // 同步 main 侧会话状态:重连后 resume 正确会话(G41)+ 窗口标题(G109) currentSessionId = sid; win.setTitle(`EMRG — ${sid}`); + // P5 slice 2:新会话指定项目 → 先记簿记(发送时用其 cwd 建连接) + if (projectPath) touchOpenSession(sid, projectPath); // P4:新会话在首条消息前不建连接(sendMessage 自动 open)——此处仅标记激活 // 并刷新持久化(activeSid 前进;openSessions 条目随 openSession 落簿记) schedulePersistGuiState(); @@ -475,6 +481,34 @@ vision = false return { ok: true, path: p }; }); + // P5 slice 2:删除项目 = 只删 projects.yml 条目(保留磁盘数据)+ 关闭该项目已打开会话 + // 受保护项目不可删除(内置 project emrg / 内置 task emrg-task → 演化依赖,删了悬空); + // `.emrg` 是 _touch_project 历史记录**非内置 → 可删**(再次访问重新注册)。 + // 返回 { ok, removed, closed: [sids] };renderer 收到后负责切换激活会话。 + ipcMain.handle("emrg:removeProject", async (_e, { name, path: p } = {}) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid project name"); + // 受保护项目:内置 project `emrg` + 内置 task `emrg-task` + if (name === "emrg" || name === "emrg-task") { + return { ok: false, protected: true, error: "protected system project" }; + } + // 该项目已打开的会话 → 关闭连接 + 移除 + 写盘(激活中先切相邻由 renderer 处理) + const closed = []; + for (const [sid, v] of [...openSessions.entries()]) { + if (v.projectPath === p || v.projectName === name) { + connManager?.close(sid); + openSessions.delete(sid); + closed.push(sid); + } + } + if (currentSessionId && closed.includes(currentSessionId)) { + currentSessionId = null; // 激活会话被关 → 无激活(renderer 切相邻) + } + const frame = await requireConn().sendCommandAndWait("remove_project", { name: name.trim() }, 5000); + schedulePersistGuiState(); + broadcastOpenSessions(); + return { ok: true, removed: Boolean(frame.removed), closed, name }; + }); + ipcMain.handle("emrg:listTasks", async () => { // GUI / 指令 P4:/trigger — daemon list_tasks → tasks_list const frame = await requireConn().sendCommandAndWait("list_tasks", {}, 5000); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 636abcf8..62b34a94 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -11,7 +11,7 @@ const api = { sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload), listSessions: () => ipcRenderer.invoke("emrg:listSessions"), switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload), - newSession: () => ipcRenderer.invoke("emrg:newSession"), + newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload), deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload), closeSession: (payload) => ipcRenderer.invoke("emrg:closeSession", payload), getOpenSessions: () => ipcRenderer.invoke("emrg:getOpenSessions"), @@ -27,6 +27,7 @@ const api = { listProjects: () => ipcRenderer.invoke("emrg:listProjects"), listProjectSessions: (payload) => ipcRenderer.invoke("emrg:listProjectSessions", payload), registerProject: (payload) => ipcRenderer.invoke("emrg:registerProject", payload), + removeProject: (payload) => ipcRenderer.invoke("emrg:removeProject", payload), listTasks: () => ipcRenderer.invoke("emrg:listTasks"), triggerTask: (payload) => ipcRenderer.invoke("emrg:triggerTask", payload), sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload), diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 2f7128cd..dfb90ed4 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -275,12 +275,26 @@

打开会话

选择项目后选择要打开的会话(跨项目多开)。

+
+ + +
+

新建会话

+

选择项目新建会话(跨项目多开)。

+
+
+ + +
+
+
+
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 177fab8d..fd08faa8 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -600,7 +600,7 @@ const App = (() => { return; } try { - const res = await window.emrg.switchSession({ sessionId: sid }); + const res = await window.emrg.switchSession({ sessionId: sid, projectPath: opts.projectPath }); state.sessionId = sid; // P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId // P3 slice 2:激活该会话容器(状态保留,不 Chat.clear——切回继续看到原消息/流式现场) @@ -632,13 +632,13 @@ const App = (() => { } } - async function newSession() { + async function newSession(opts = {}) { if (state.busy) { Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); return; } try { - const res = await window.emrg.newSession(); + const res = await window.emrg.newSession({ projectPath: opts.projectPath }); state.sessionId = res.session_id; activateSessionView(state.sessionId); // P3 slice 2:新会话独立容器(空) Chat.clear(state.sessionId); // 新会话从空开始(容器可能被复用) @@ -1278,6 +1278,8 @@ const App = (() => { $("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {})); $("new-chat-btn").addEventListener("click", newSession); Dialogs.initOpenSessionDialog(); // P5:打开会话对话框绑定 + Dialogs.initNewSessionDialog(); // P5 slice 2:新建会话对话框绑定 + $("open-session-new-session")?.addEventListener("click", () => Dialogs.showNewSessionDialog()); // P5 slice 2:打开弹窗 → 新建会话 $("settings-btn").addEventListener("click", () => { loadEvolutionSummary(); // WorkBuddy P3(#502):打开设置时加载最近改进 Dialogs.showSettings(); diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 91ac4395..7685a10d 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -549,12 +549,19 @@ const Dialogs = (() => { } // 第一步:项目列表(按最近活跃倒序——daemon 已排;底部"新建项目…"按钮) projects.forEach((p) => { - const row = el("button", { class: "help-row", type: "button", style: "width:100%;text-align:left;cursor:pointer;background:none;border:none;" }); + const row = el("div", { class: "help-row", style: "display:flex;align-items:center;gap:var(--sp-2);" }); + const pick = el("button", { type: "button", style: "flex:1;text-align:left;cursor:pointer;background:none;border:none;display:flex;flex-direction:column;align-items:flex-start;padding:0;" }); const name = el("span", { class: "help-cmd" }, p.name || p.path || ""); const hint = el("span", { class: "help-hint" }, p.path || ""); - row.appendChild(name); - row.appendChild(hint); - row.addEventListener("click", () => showProjectSessions(p)); + pick.appendChild(name); + pick.appendChild(hint); + // P5 slice 2:点击打开会话(带项目路径 → resume 用该项目 cwd) + pick.addEventListener("click", () => showProjectSessions(p)); + row.appendChild(pick); + // 删除项目(受保护项目不可删 → 提示;非受保护 → 确认弹窗) + const del = el("button", { type: "button", class: "btn btn-ghost", style: "padding:2px 8px;flex-shrink:0;", title: _t("deleteProject.delete") }, _t("deleteProject.delete")); + del.addEventListener("click", () => confirmDeleteProject(p)); + row.appendChild(del); list.appendChild(row); }); } catch (e) { @@ -562,6 +569,42 @@ const Dialogs = (() => { } } + // P5 slice 2:受保护项目(内置 project emrg / 内置 task emrg-task → 演化依赖,不可删) + function isProtectedProject(p) { + return p && (p.name === "emrg" || p.name === "emrg-task"); + } + + function confirmDeleteProject(p) { + if (isProtectedProject(p)) { + showConfirm(_t("deleteProject.title"), _t("deleteProject.protectedBody", { name: p.name }), { okText: _t("dlg.gotIt"), danger: false }); + return; + } + showConfirm(_t("deleteProject.title"), _t("deleteProject.body", { name: p.name }), { + okText: _t("dlg.delete"), + danger: true, + onOk: async () => { + try { + const res = await window.emrg.removeProject({ name: p.name, path: p.path }); + if (!res || !res.ok) { + Chat.addSystemMessage(_t("deleteProject.failed", { msg: (res && res.error) || _t("app.unknownError") })); + return; + } + Chat.addSystemMessage(_t("deleteProject.removed", { name: p.name })); + // 该项目已打开的会话被关闭 → 激活会话被关则切相邻 + const closed = res.closed || []; + if (closed.length > 0 && closed.includes(App.state.sessionId)) { + const remaining = (App.state.openSessions || []).filter((s) => !closed.includes(s.sid)); + if (remaining.length > 0) await App.switchSession(remaining[0].sid, { silent: true }); + else await App.newSession(); + } + showOpenSessionDialog(); // 刷新项目列表 + } catch (e) { + Chat.addSystemMessage(_t("deleteProject.failed", { msg: e.message })); + } + }, + }); + } + // 第二步:该项目会话列表(created_at 倒序)→ 点击打开(switchSession 复用连接) async function showProjectSessions(project) { const list = $("open-session-list"); @@ -583,7 +626,8 @@ const Dialogs = (() => { row.appendChild(hint); row.addEventListener("click", async () => { $("open-session-dialog").close(); - await App.switchSession(s.session_id); + // P5 slice 2:带项目路径切换(resume 用该项目 cwd,非全局 projectDir) + await App.switchSession(s.session_id, { projectPath: project.path }); }); list.appendChild(row); }); @@ -609,6 +653,55 @@ const Dialogs = (() => { }); } + // ── P5 slice 2:新建会话对话框(选已有项目 → 新建;或新建项目 → 新建) ── + async function showNewSessionDialog() { + const list = $("new-session-list"); + const dialog = $("new-session-dialog"); + if (!list || !dialog) return; + list.innerHTML = `
${_t("dlg.loading")}
`; + dialog.showModal(); + try { + const projects = await window.emrg.listProjects(); + list.innerHTML = ""; + if (!projects || projects.length === 0) { + list.innerHTML = `
${_t("openSession.noProjects")}
`; + return; + } + // 已有项目(活跃排序)→ 点选即在该项目新建会话 + projects.forEach((p) => { + const row = el("button", { class: "help-row", type: "button", style: "width:100%;text-align:left;cursor:pointer;background:none;border:none;" }); + const name = el("span", { class: "help-cmd" }, p.name || p.path || ""); + const hint = el("span", { class: "help-hint" }, p.path || ""); + row.appendChild(name); + row.appendChild(hint); + row.addEventListener("click", async () => { + $("new-session-dialog").close(); + await App.newSession({ projectPath: p.path }); + }); + list.appendChild(row); + }); + } catch (e) { + list.innerHTML = `
${_t("newSession.loadFailed", { msg: e.message })}
`; + } + } + + function initNewSessionDialog() { + $("new-session-cancel").addEventListener("click", () => $("new-session-dialog").close()); + $("new-session-new").addEventListener("click", async () => { + // 新建项目 = 选目录 → 注册(轻量命令隐式 _touch_project,不调 init_auto_evolve)→ 新建会话 + try { + const res = await window.emrg.pickProjectDir(); + if (res && res.path) { + await window.emrg.registerProject({ path: res.path }); + $("new-session-dialog").close(); + await App.newSession({ projectPath: res.path }); + } + } catch (e) { + Chat.addSystemMessage(_t("newSession.projectFailed", { msg: e.message })); + } + }); + } + function closeConfirm() { $("confirm-dialog").close(); confirmCb = null; @@ -633,6 +726,8 @@ const Dialogs = (() => { refreshGithubStatus, initOpenSessionDialog, // P5:打开会话对话框初始化 showOpenSessionDialog, // P5:两步打开会话 + initNewSessionDialog, // P5 slice 2:新建会话对话框初始化 + showNewSessionDialog, // P5 slice 2:新建会话(选项目) showRename, submitRename, showSettings, diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 9331f589..6ccea2a0 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -144,6 +144,18 @@ const I18N = (() => { "openSession.newProject": "+ 新建项目…", "openSession.projectCreated": "项目已注册:{path}", "openSession.projectFailed": "新建项目失败:{msg}", + "newSession.openButton": "+ 新建会话…", + "newSession.title": "新建会话", + "newSession.desc": "选择项目新建会话(跨项目多开)。", + "newSession.newProject": "+ 新建项目…", + "newSession.loadFailed": "加载失败:{msg}", + "newSession.projectFailed": "新建会话失败:{msg}", + "deleteProject.delete": "删除", + "deleteProject.title": "删除项目", + "deleteProject.body": "删除项目「{name}」?只删除项目记录,磁盘数据保留(可重新注册)。", + "deleteProject.protectedBody": "「{name}」是系统项目,不可删除。", + "deleteProject.removed": "项目已删除:{name}", + "deleteProject.failed": "删除项目失败:{msg}", "rewind.title": "回退到历史消息点", "rewind.desc": "选择要保留到的消息点,之后的对话将被移除。", "rewind.cancel": "取消", @@ -452,6 +464,18 @@ const I18N = (() => { "openSession.newProject": "+ New project…", "openSession.projectCreated": "Project registered: {path}", "openSession.projectFailed": "Failed to create project: {msg}", + "newSession.openButton": "+ New session…", + "newSession.title": "New session", + "newSession.desc": "Pick a project to create a new session (multi-project tabs).", + "newSession.newProject": "+ New project…", + "newSession.loadFailed": "Failed to load: {msg}", + "newSession.projectFailed": "Failed to create session: {msg}", + "deleteProject.delete": "Delete", + "deleteProject.title": "Delete project", + "deleteProject.body": "Delete project \"{name}\"? Only the project record is removed; on-disk data is kept (re-registerable).", + "deleteProject.protectedBody": "\"{name}\" is a system project and cannot be deleted.", + "deleteProject.removed": "Project removed: {name}", + "deleteProject.failed": "Failed to delete project: {msg}", "rewind.title": "Rewind to a history point", "rewind.desc": "Choose the message point to keep — later messages will be removed.", "rewind.cancel": "Cancel", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index e6d691ae..d086b8a4 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -107,7 +107,8 @@ function makeEl(id) { const ELEMENT_IDS = [ "chat-view", "input", "send-btn", "stop-btn", "conv-list", "open-sessions", "open-sessions-label", "status-dot", "settings-btn", - "open-session-dialog", "open-session-list", "open-session-title", "open-session-desc", "open-session-new", "open-session-cancel", + "open-session-dialog", "open-session-list", "open-session-title", "open-session-desc", "open-session-new", "open-session-cancel", "open-session-new-session", + "new-session-dialog", "new-session-list", "new-session-new", "new-session-cancel", "conn-banner", "empty-state", "model-switcher", "model-switcher-label", "brand-star", "new-chat-btn", "settings-dialog", "settings-cancel", "settings-save", "set-api-key", "set-base-url", "set-project-dir", "set-model", "pick-dir-btn", "theme-options", "welcome-dialog", "welcome-api-key", "welcome-base-url", @@ -820,7 +821,7 @@ test("boot 死锁修复:boot/newSession/switchSession 成功路径启用输入 const bootBlock = src.slice(bootIdx, src.indexOf("async function sendMessage")); assert.ok(bootBlock.includes("setComposerDisabled(false)"), "boot() 成功路径应启用输入框"); // newSession()/switchSession() 成功路径也应启用(防御性,独立调用场景) - const nsIdx = src.indexOf("async function newSession()"); + const nsIdx = src.indexOf("async function newSession("); const nsBlock = src.slice(nsIdx, src.indexOf("async function deleteSession")); assert.ok(nsBlock.includes("setComposerDisabled(false)"), "newSession() 成功路径应启用输入框"); const ssIdx = src.indexOf("async function switchSession("); @@ -1357,8 +1358,8 @@ test("P5: showOpenSessionDialog 列项目(第一步)→ 点项目列会话 await tick(); assert.strictEqual(els["open-session-dialog"].open, true, "dialog opened"); assert.strictEqual(els["open-session-list"].children.length, 2, "two projects listed"); - // 点击 emrg 项目 → 第二步列出会话(按钮行子节点 [name, hint]) - els["open-session-list"].children[0].click(); + // 点击 emrg 项目 → 第二步列出会话(行 = div[pick 按钮, 删除按钮];点 pick 按钮) + els["open-session-list"].children[0].children[0].click(); await tick(); const rows = els["open-session-list"].children; assert.strictEqual(rows.length, 2, "two sessions listed for project"); @@ -1380,3 +1381,79 @@ test("P5: /open 指令 → 打开会话对话框;无项目 → 提示新建", "no-projects hint" ); }); + +// ── P5 slice 2(rant 15:07:19):新建会话对话框 + 删除项目(受保护守卫) ── + +test("P5 slice 2: showNewSessionDialog 列项目 → 点选项目 → newSession(projectPath)", async () => { + const projects = [ + { name: "emrg", path: "/p/emrg" }, + { name: "mem", path: "/p/mem" }, + ]; + const newCalls = []; + const { ctx, els } = makeSandbox({ + listProjects: async () => projects, + newSession: async (payload) => { newCalls.push(payload); return { session_id: "s-new" }; }, + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.showNewSessionDialog()", ctx); + await tick(); + assert.strictEqual(els["new-session-dialog"].open, true, "new-session dialog opened"); + assert.strictEqual(els["new-session-list"].children.length, 2, "two projects listed"); + // 点选 mem 项目 → newSession({ projectPath }) 被调用 + 弹窗关闭 + els["new-session-list"].children[1].click(); + await tick(); + assert.strictEqual(newCalls.length, 1, "newSession called once"); + assert.strictEqual(newCalls[0] && newCalls[0].projectPath, "/p/mem", "projectPath passed"); + assert.strictEqual(els["new-session-dialog"].open, false, "dialog closed after pick"); +}); + +test("P5 slice 2: 新建会话弹窗 → 新建项目 → registerProject + newSession(new path)", async () => { + const registerCalls = []; + const newCalls = []; + const { ctx, els } = makeSandbox({ + pickProjectDir: async () => ({ path: "/p/brand-new" }), + registerProject: async (p) => { registerCalls.push(p); return { ok: true }; }, + newSession: async (payload) => { newCalls.push(payload); return { session_id: "s-new2" }; }, + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.showNewSessionDialog()", ctx); + await tick(); + els["new-session-new"].click(); + await tick(); + assert.strictEqual(registerCalls.length, 1, "registerProject called once"); + assert.strictEqual(registerCalls[0] && registerCalls[0].path, "/p/brand-new", "new path registered"); + assert.strictEqual(newCalls.length, 1, "newSession called after register"); + assert.strictEqual(newCalls[0] && newCalls[0].projectPath, "/p/brand-new", "new session in new project"); +}); + +test("P5 slice 2: 删除项目 — 受保护 emrg 提示不可删;普通项目确认后 removeProject", async () => { + const projects = [ + { name: "emrg", path: "/p/emrg" }, + { name: "mem", path: "/p/mem" }, + ]; + const removeCalls = []; + const { ctx, els } = makeSandbox({ + listProjects: async () => projects, + removeProject: async (p) => { removeCalls.push(p); return { ok: true, removed: true, closed: [] }; }, + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.showOpenSessionDialog()", ctx); + await tick(); + // 受保护项目:行 = [pick, delete];点删除 → 确认框提示系统项目不可删(不调 removeProject) + els["open-session-list"].children[0].children[1].click(); + await tick(); + assert.strictEqual(removeCalls.length, 0, "protected project NOT removed"); + assert.ok( + (els["confirm-message"].textContent || "").includes("系统项目") || (els["confirm-message"].textContent || "").includes("system project"), + "protected hint shown" + ); + await vm.runInContext("EMRG_Dialogs.closeConfirm()", ctx); + // 普通项目:点删除 → 确认 → removeProject({name, path}) + els["open-session-list"].children[1].children[1].click(); + await tick(); + await vm.runInContext("EMRG_Dialogs.confirmOk()", ctx); + await tick(); + assert.strictEqual(removeCalls.length, 1, "removeProject called once"); + assert.strictEqual(removeCalls[0] && removeCalls[0].name, "mem", "project name passed"); + assert.strictEqual(removeCalls[0] && removeCalls[0].path, "/p/mem", "project path passed"); +}); diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 13495cc9..ab1169ad 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -390,6 +390,8 @@ When reading rants, follow these rules: > - GUI disconnected per-sid isolation (#636 GUI 多会话 rant 2026-08-10T15:07:19 P3 finalize:disconnected 事件按 sid 隔离——sidState 条目加 `disconnected` 标记 + 注册容器 `.disconnected` 类(`Chat.hasContainer(sid)` 守卫,防 chatContainer 回退链误标激活容器);**全局横幅/红点仅激活会话(或无 sid 单会话过渡期)断连显示**,后台会话断连不打扰全局 UI(rant 验收"断一条不影响其他会话");`status` connected 清全部断线标记 + 容器类;switchSession 切入断线会话提示 i18n `app.sessionDisconnected`(G89 输入条仍可用);CSS `.session-view.disconnected` 视觉(dim + hatch);+3 测试 renderer.smoke 43→46、GUI 153→156 三文档同步;680 pytest 全绿;P3 完成) ✅ > - GUI open-sessions state + gui_state.json (#637 GUI 多会话 rant 2026-08-10T15:07:19 P4 slice 1 写路径:新 `emrg/gui/gui-state.js`(纯 Node)——`guiStatePath`(~/.emrg/gui_state.json)+ `sanitizeOpenSessions`(跳过缺 sid/projectPath 条目、lastActive 倒序、上限 20,读写共用)+ `saveGuiState`(.tmp+rename 原子写,镜像 #569);main.js `openSessions: Map` 簿记 + touchOpenSession/markSessionActive/防抖 1s 写盘 + 退出冲刷;**switchSession 不再关闭旧会话连接**(多会话保持,浏览器 tab 语义——关闭走新 `emrg:closeSession` IPC:断开+移除+持久化**保留磁盘数据**,与 delete_session 区分);`emrg:getOpenSessions` IPC(跨项目打开列表,slice 2 侧边栏数据源);sendMessage 标记激活 + 事件桥 done/delta 刷新 lastActive(conn-manager onOpen 钩子加 projectPath 参);deleteSession/newSession 同步簿记;+7 测试 gui-state.test.js;GUI 153→163(#636 并入后 renderer.smoke 46 + 7 gui-state,计数冲突解到实测 163)三文档同步;680 pytest 全绿;P4 剩余=slice 2 sidebar + 启动恢复) ✅ > - GUI open-sessions sidebar + gui_state restore (#639 GUI 多会话 rant 2026-08-10T15:07:19 P4 slice 2 读路径/侧边栏:main.js `restoreOpenSessions`(init 读 gui_state.json → 重开有效条目 cap 20 + resume 重订阅 → 失效跳过 + 重写盘 → activeSid 恢复/回退最近有效 → 窗口标题同步)+ `broadcastOpenSessions`(打开/激活/关闭/删除/新建每次变更推 `open_sessions` 事件给 renderer)+ init 返回 `open_sessions` + `active_sid`(renderer 直接采用恢复激活会话,免 switchSession IPC 往返);sidebar.js `renderOpenSessions`(侧边栏顶部跨项目打开会话区:项目名/会话标题、lastActive 倒序、激活高亮、点击切换、右键关闭保留数据/重命名/删除)+ highlight 扩展覆盖;app.js `state.openSessions` + `open_sessions` 事件 + `closeOpenSession`(断开+释放容器+保留磁盘数据;关激活会话 → 切最近打开会话否则新建)+ showOpenSessionsMenu;index.html `#open-sessions` + i18n zh/en + CSS;+4 测试 renderer.smoke 46→50 + mock 升级(querySelectorAll DFS 类选择器 + classList.toggle 忠实 force);GUI 163→167 三文档同步;680 pytest 全绿;P4 完成,P5-P6 待续) ✅ +> - GUI open-session dialog project→session (#641 GUI 多会话 rant 2026-08-10T15:07:19 P5 slice 1:打开会话弹窗两步——`showOpenSessionDialog`(listProjects → 项目行含路径 hint + 底部"+ 新建项目…")+ `showProjectSessions`(listProjectSessions(cwd=projectPath) → created_at 倒序 → 点击 switchSession 复用连接);main.js `emrg:listProjectSessions`/`emrg:registerProject` IPC(G121 目录可写校验;list_sessions(cwd) 轻量命令 → daemon 隐式 `_touch_project` 注册,零 daemon 改动,**不调 init_auto_evolve** 防意外建演化任务);preload 暴露两 API;app.js `/open` 指令(commands 15→16)+ bindUi 初始化;i18n zh/en 10 条 + cmd.open.hint;+2 测试 renderer.smoke 50→52(项目→会话下钻 / 无项目提示);GUI 167→169 三文档同步;680 pytest 全绿;P5 剩余=slice 2 新建会话 + 删除项目) ✅ +> - GUI new-session dialog + delete-project protected guard (#642 GUI 多会话 rant 2026-08-10T15:07:19 P5 slice 2:**新建会话弹窗** `showNewSessionDialog`(listProjects 活跃序 → 点选即 `App.newSession({projectPath})`;底部"+ 新建项目…" → pickProjectDir → registerProject → 同路径新建)+ index.html `#new-session-dialog` + 打开弹窗顶部"+ 新建会话…"入口;**删除项目**:打开弹窗项目行右侧删除按钮 → 受保护守卫(内置 project `emrg` / 内置 task `emrg-task` 提示"系统项目不可删除",不调 API;`.emrg` 非内置可删)→ 确认弹窗(数据保留可恢复)→ main.js `emrg:removeProject` IPC(关闭该项目已打开会话连接 + 移出簿记 + 写盘 + 激活被关 → renderer 切相邻/新建 + 广播 open_sessions)→ daemon `remove_project`(P1 已备);**slice-1 补洞**:switchSession/sendMessage 带 per-session projectPath(跨项目 resume 用项目 cwd 非全局 projectDir);newSession 接受 projectPath(首条消息前即记簿记);i18n zh/en 11 条;+3 测试 renderer.smoke 52→55(新建会话选项目 / 新建项目→注册→新建 / 删除项目受保护+普通确认);GUI 169→172 三文档同步;680 pytest 全绿;P5 完成,P6 收尾待续) ✅ #### 2.2 Latest GitHub code changes