diff --git a/Agent.md b/Agent.md
index 7a67b86..037bbdf 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` (179: 43 daemon_client + 19 conn-manager + 22 app-commands + 59 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
+ - Unit tests `npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 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)
@@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```
Python: `uv run pytest tests/ -v` (687) — import check: `uv run python -c "from emrg.client.app import run_client"`
-GUI: `cd emrg/gui && npm test` (179: 43 daemon_client + 19 conn-manager + 22 app-commands + 59 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
+GUI: `cd emrg/gui && npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 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 3fe09ff..4ac1845 100644
--- a/README.cn.md
+++ b/README.cn.md
@@ -144,7 +144,7 @@ EMRG 不只是追赶——它自己追上来。
贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。
-快速检查:`uv run pytest tests/ -v`(当前 687 项)· `cd emrg/gui && npm test`(179 项:43 daemon_client + 19 conn-manager + 22 app-commands + 59 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
+快速检查:`uv run pytest tests/ -v`(当前 687 项)· `cd emrg/gui && npm test`(183 项:43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
---
diff --git a/README.md b/README.md
index 886fab4..6f21ba0 100644
--- a/README.md
+++ b/README.md
@@ -144,7 +144,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve
Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md).
-Quick checks: `uv run pytest tests/ -v` (currently 687 items) · `cd emrg/gui && npm test` (179: 43 daemon_client + 19 conn-manager + 22 app-commands + 59 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
+Quick checks: `uv run pytest tests/ -v` (currently 687 items) · `cd emrg/gui && npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
---
diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css
index b9d5a0c..e130991 100644
--- a/emrg/gui/renderer/css/layout.css
+++ b/emrg/gui/renderer/css/layout.css
@@ -80,6 +80,29 @@
box-shadow: var(--shadow-md);
}
+/* B2(rant 21:59:11):"打开会话"入口——新对话按钮下的轻量 ghost 按钮 */
+.open-chat-btn {
+ margin: calc(-1 * var(--sp-1)) var(--sp-3) var(--sp-3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--sp-2);
+ padding: 8px var(--sp-3);
+ background: transparent;
+ color: var(--text-2);
+ font-size: var(--fs-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-btn);
+ cursor: pointer;
+ transition:
+ background-color var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
+}
+.open-chat-btn:hover {
+ background: var(--bg-soft);
+ color: var(--text-1);
+}
+
#conv-list {
flex: 1;
overflow-y: auto;
diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html
index dfb90ed..12aa42f 100644
--- a/emrg/gui/renderer/index.html
+++ b/emrg/gui/renderer/index.html
@@ -25,6 +25,8 @@
边工作边学习,越用越懂你
+
+
打开的会话
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js
index 91aacad..acfc2ec 100644
--- a/emrg/gui/renderer/js/app.js
+++ b/emrg/gui/renderer/js/app.js
@@ -35,6 +35,9 @@ const App = (() => {
cmdMenu: { items: [], index: -1 },
// WorkBuddy P2(rant 21:35):工作模式 ask(纯对话)/ auto(默认,自动执行工具)
mode: "auto",
+ // B3(rant 21:59:11):每会话输入框草稿(浏览器 tab 式状态保留)——切换会话保存旧
+ // sid 草稿、恢复新 sid 草稿;发送成功即清除该 sid 草稿
+ drafts: new Map(), // sid → 草稿文本
};
// P3 slice 1:会话条目访问器(get-or-create)。无 sid/未激活 → 归入当前激活会话。
@@ -144,6 +147,8 @@ const App = (() => {
Chat.addUserMessage(text);
input.value = "";
input.style.height = "auto";
+ // B3:消息已发送 → 清除该会话草稿
+ state.drafts.delete(state.sessionId);
// G143:send 前预生成 requestId 并标记自有流——消除 IPC 往返竞态窗口
const requestId = genRequestId();
state.ownStreamRequestId = requestId;
@@ -594,14 +599,30 @@ const App = (() => {
return view;
}
+ // B3(rant 21:59:11):会话切换时保留/恢复输入框草稿(浏览器 tab 式状态)
+ function saveDraft(sid) {
+ if (!sid) return;
+ state.drafts.set(sid, $("input").value);
+ }
+ function restoreDraft(sid) {
+ const input = $("input");
+ input.value = sid ? (state.drafts.get(sid) || "") : "";
+ input.style.height = "auto";
+ input.style.height = Math.min(input.scrollHeight, 150) + "px";
+ }
+
async function switchSession(sid, opts = {}) { // G65:busy 即自有流进行中/发送中(IPC 往返窗口内 ownStreamRequestId 尚未赋值)
if (state.busy) {
Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy);
return;
}
try {
+ // B3:离开前保存当前会话草稿
+ saveDraft(state.sessionId);
const res = await window.emrg.switchSession({ sessionId: sid, projectPath: opts.projectPath });
state.sessionId = sid;
+ // B3:恢复目标会话草稿
+ restoreDraft(sid);
// P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId
// P3 slice 2:激活该会话容器(状态保留,不 Chat.clear——切回继续看到原消息/流式现场)
activateSessionView(sid);
@@ -639,14 +660,18 @@ const App = (() => {
return;
}
try {
+ // B3:离开前保存当前会话草稿;新会话从空草稿开始
+ saveDraft(state.sessionId);
const res = await window.emrg.newSession({ projectPath: opts.projectPath });
state.sessionId = res.session_id;
+ state.drafts.set(res.session_id, ""); // 新会话无草稿
activateSessionView(state.sessionId); // P3 slice 2:新会话独立容器(空)
Chat.clear(state.sessionId); // 新会话从空开始(容器可能被复用)
updateEmptyState(); // 欢迎屏即反馈
await refreshSessions();
Sidebar.highlight(state.sessionId);
setComposerDisabled(false); // 防御性:独立调用 newSession 也确保输入框可用
+ restoreDraft(state.sessionId); // 新会话草稿为空 → 清空输入框
} catch (e) {
Chat.addSystemMessage(_t("app.newFailed", { msg: e.message }));
}
@@ -1277,7 +1302,10 @@ const App = (() => {
function bindUi() {
$("send-btn").addEventListener("click", sendMessage);
$("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {}));
- $("new-chat-btn").addEventListener("click", newSession);
+ // B1(rant 21:59:11):侧边栏"+ 新对话"→ 项目选择弹窗(选项目新建 / 新建项目),不再直接新建
+ $("new-chat-btn").addEventListener("click", () => Dialogs.showNewSessionDialog());
+ // B2(rant 21:59:11):侧边栏"打开会话"入口 → 两步弹窗(选项目 → 选会话)
+ $("open-chat-btn")?.addEventListener("click", () => Dialogs.showOpenSessionDialog());
Dialogs.initOpenSessionDialog(); // P5:打开会话对话框绑定
Dialogs.initNewSessionDialog(); // P5 slice 2:新建会话对话框绑定
$("open-session-new-session")?.addEventListener("click", () => Dialogs.showNewSessionDialog()); // P5 slice 2:打开弹窗 → 新建会话
@@ -1408,7 +1436,8 @@ const App = (() => {
if (e.metaKey || e.ctrlKey) {
if (e.key === "n") {
e.preventDefault();
- newSession();
+ // B1(rant 21:59:11):⌘N 与侧边栏按钮一致 → 项目选择弹窗
+ Dialogs.showNewSessionDialog();
} else if (e.key === "b") {
e.preventDefault();
document.body.classList.toggle("sidebar-collapsed");
diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js
index ce4b84f..43c57a7 100644
--- a/emrg/gui/renderer/js/i18n.js
+++ b/emrg/gui/renderer/js/i18n.js
@@ -19,6 +19,8 @@ const I18N = (() => {
// 侧边栏
"sidebar.newChat": "+ 新对话",
"sidebar.newChatTitle": "新对话 (⌘N)",
+ "sidebar.openChat": "打开会话",
+ "sidebar.openChatTitle": "打开会话(跨项目)",
"sidebar.settings": "设置",
"sidebar.statusTitle": "连接状态",
"sidebar.backToBottom": "回到底部",
@@ -344,6 +346,8 @@ const I18N = (() => {
// Sidebar
"sidebar.newChat": "+ New chat",
"sidebar.newChatTitle": "New chat (⌘N)",
+ "sidebar.openChat": "Open session",
+ "sidebar.openChatTitle": "Open session (cross-project)",
"sidebar.settings": "Settings",
"sidebar.statusTitle": "Connection status",
"sidebar.backToBottom": "Back to bottom",
diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js
index 8f18862..b089a6a 100644
--- a/emrg/gui/test/renderer.smoke.test.js
+++ b/emrg/gui/test/renderer.smoke.test.js
@@ -109,7 +109,7 @@ 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-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",
+ "conn-banner", "empty-state", "model-switcher", "model-switcher-label", "brand-star", "new-chat-btn", "open-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",
"welcome-model", "welcome-project-dir", "welcome-pick-btn", "welcome-save", "confirm-dialog",
@@ -1542,3 +1542,63 @@ test("P6: 打开会话弹窗项目行显示最近活跃提示(latest_session_a
const texts1 = [...pick1.children].map((c) => c.textContent || "");
assert.ok(!texts1.some((t) => t.includes("前") || t.includes("ago")), "no activity hint when absent");
});
+
+// ── rant 21:59:11:GUI 多会话实现偏差修正(B1-B3) ──
+
+test("B1: 侧边栏'+ 新对话'按钮 → 项目选择弹窗(不再直接新建)", async () => {
+ const newCalls = [];
+ const { els } = makeSandbox({
+ listProjects: async () => [],
+ newSession: async (payload) => { newCalls.push(payload); return { session_id: "s2" }; },
+ });
+ await tick();
+ els["new-chat-btn"].click();
+ await tick();
+ assert.strictEqual(els["new-session-dialog"].open, true, "new-session dialog opened (not direct newSession)");
+ assert.strictEqual(newCalls.length, 0, "newSession NOT called directly by the button");
+});
+
+test("B2: 侧边栏'打开会话'按钮 → 两步弹窗(项目→会话)", async () => {
+ const { els } = makeSandbox({ listProjects: async () => [] });
+ await tick();
+ els["open-chat-btn"].click();
+ await tick();
+ assert.strictEqual(els["open-session-dialog"].open, true, "open-session dialog opened");
+});
+
+test("B3: 切换会话保存/恢复输入框草稿(每会话 draft,浏览器 tab 式)", async () => {
+ const { ctx, els } = makeSandbox({ switchSession: async () => ({}) });
+ await tick();
+ // 在 s1 输入草稿 → 切到 s2(s2 无草稿 → 清空)→ 切回 s1(草稿恢复)
+ await vm.runInContext("App.state.sessionId = 's1'", ctx);
+ els["input"].value = "s1 draft";
+ await vm.runInContext("App.switchSession('s2')", ctx);
+ await tick();
+ assert.strictEqual(els["input"].value, "", "s2 has no draft → input cleared");
+ await vm.runInContext("App.switchSession('s1')", ctx);
+ await tick();
+ assert.strictEqual(els["input"].value, "s1 draft", "s1 draft restored after switching back");
+});
+
+test("B3: 发送消息清除该会话草稿;新会话从空草稿开始", async () => {
+ const { ctx, els } = makeSandbox({
+ sendMessage: async () => ({}),
+ newSession: async () => ({ session_id: "s-new" }),
+ });
+ await tick();
+ // 发送后草稿删除
+ await vm.runInContext("App.state.sessionId = 's1'; App.state.drafts.set('s1', 'draft')", ctx);
+ els["input"].value = "hello";
+ els["send-btn"].click();
+ await tick();
+ const hasDraft = await vm.runInContext("App.state.drafts.has('s1')", ctx);
+ assert.strictEqual(hasDraft, false, "draft cleared after send");
+ // 新建会话 → 新会话草稿为空 → 输入框清空
+ await vm.runInContext("App.state.sessionId = 's-old'; App.state.drafts.set('s-old', 'old draft')", ctx);
+ els["input"].value = "old draft";
+ await vm.runInContext("App.newSession()", ctx);
+ await tick();
+ assert.strictEqual(els["input"].value, "", "new session input cleared");
+ const newDraft = await vm.runInContext("App.state.drafts.get('s-new')", ctx);
+ assert.strictEqual(newDraft, "", "new session starts with empty draft");
+});
diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md
index 9695068..99cc78f 100644
--- a/emrg/server/evolution_prompt.md
+++ b/emrg/server/evolution_prompt.md
@@ -402,6 +402,7 @@ When reading rants, follow these rules:
> - GUI project-row recent-activity hints (#646 GUI 多会话 rant 2026-08-10T15:07:19 验收体验补完:打开/新建会话弹窗项目行显示"最近活跃"相对时间——utils.js 新增 `relTime(iso)`(ISO → "刚刚/N 分钟前/N 小时前/N 天前",走 i18n zh/en,缺失/非法输入返回空串);dialogs.js showOpenSessionDialog + showNewSessionDialog 项目行消费 daemon `latest_session_at`(#644 新增字段)追加 hint span(无字段不显示);i18n zh/en 4 键(relTime.*);+2 测试 renderer.smoke 57→59(relTime 六态:刚刚/分钟/小时/天/空/非法 + 弹窗行活动提示/无字段不显示);GUI 176→178 三文档同步;681 pytest 全绿;"按最近活跃排序"从隐式排序变为可见提示,宿主可感知排序依据) ✅
> - Windows release zip asset (#649 宿主 rant 2026-08-10T20:10:41:Windows 发布资产除 exe 安装包外再提供 zip 压缩版——`build-release.yml` Windows job 在 Make installer 后新增 "Zip installer (Windows only)" 步骤(find `EMRG-*-windows-x64.exe` → PowerShell `Compress-Archive -Force` → bash `test -s` 校验非空)+ upload-artifact glob 加 `dist/artifacts/*.zip`(release job `files: artifacts/*` 自动带上);actionlint 1.7.12 全工作流验证通过;**tag-push 构建教训**:tag 触发的工作流读 tag ref 上的 workflow 文件——v0.2.21 tag 早于 #649 合并 → 该版本 release 无 zip(6 资产符合原始 release rant 验收),zip 自 v0.2.22 起随 CI 产出;验证=CI Windows job 全绿 + actionlint clean;workflow-only 改动,测试计数不变) ✅
> - GUI packaged local-module whitelist guard (#651 宿主 rant 2026-08-10T20:37:08:v0.2.21 Windows 打包版启动崩溃 `Cannot find module './conn-manager'`——main.js:15/16 require 的 **conn-manager.js 和 gui-state.js 都不在 electron-builder `files` 白名单**(多会话 P2/P4 新增模块忘更新,与 #612 vendor 同根因类);修复=①package.json build.files 加 `conn-manager.js` + `gui-state.js`;②test/build-config.test.js 新增**通用守卫**:扫描 main.js/preload.js 每个本地 `require("./x")` 断言被 files 白名单覆盖(支持 `x.js` 与 `dir/**` 形态;正反两态验证——删白名单项即红并命名缺失模块)——**#612/#651 整个"新增模块忘加白名单"类闭合**;GUI 178→179(build-config 3→4)三文档同步;验证=GUI 179/179 ✓ pytest 681 ✓ doc guard 3/3 ✓;打包版自 v0.2.22 起修复) ✅
+> - Mid-turn queue injection P1 (#655 宿主 rant 2026-08-10T21:55:37:tool loop 进行中发新消息 → **daemon 排队注入**(对齐 codex steer_input)——busy 时新消息入 per-session FIFO 队列(`_session_pending`),当前 round(LLM 请求+全部工具执行)结束后、下一轮 LLM 请求前注入;**不打断工具执行、不丢消息**;busy 分支不再回 "session busy" error → `task_queued`(含 position);注入走 `_inject_pending_messages`(原子 pop 防并发 append 丢失 + `append_message` 持久化保 auto-compact + `steer_committed` 广播);round 预算=注入轮不消耗(stop/Case3/循环用尽均先重查 pending 再 return);wrapper finally:正常结束 → `queued_requeue`(request_ids,客户端自动重发)、cancel/异常/clear/delete → `queued_cancelled`(cancel_event 判定防误重发);Ask mode 注入轮空工具集;clear/delete_session 清队列;协议帧 4 个 daemon→client 广播(P2 GUI / P3 TUI 客户端侧后续 rant);pytest 681→687(+7 e2e)、GUI 179 不变;合并 535efd2) ✅
#### 2.2 Latest GitHub code changes