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` (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)
Expand Down Expand Up @@ -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 路径不受影响)

Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
23 changes: 23 additions & 0 deletions emrg/gui/renderer/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
<div class="growth-note" id="growth-note" data-i18n="copy.growthNote">边工作边学习,越用越懂你</div>
</div>
<button id="new-chat-btn" class="new-chat-btn" title="新对话 (⌘N)" data-i18n="sidebar.newChat" data-i18n-title="sidebar.newChatTitle">+ 新对话</button>
<!-- B2(rant 21:59:11):侧边栏"打开会话"显式入口(两步弹窗:选项目 → 选会话,跨项目) -->
<button id="open-chat-btn" class="open-chat-btn" title="打开会话" data-i18n="sidebar.openChat" data-i18n-title="sidebar.openChatTitle">打开会话</button>
<!-- P4 slice 2:跨项目打开的会话(浏览器 tab 效果,关闭保留数据) -->
<div class="conv-group-label open-sessions-label" id="open-sessions-label" data-i18n="sidebar.openSessions" hidden>打开的会话</div>
<nav id="open-sessions" class="open-sessions"></nav>
Expand Down
33 changes: 31 additions & 2 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/未激活 → 归入当前激活会话。
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 }));
}
Expand Down Expand Up @@ -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:打开弹窗 → 新建会话
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const I18N = (() => {
// 侧边栏
"sidebar.newChat": "+ 新对话",
"sidebar.newChatTitle": "新对话 (⌘N)",
"sidebar.openChat": "打开会话",
"sidebar.openChatTitle": "打开会话(跨项目)",
"sidebar.settings": "设置",
"sidebar.statusTitle": "连接状态",
"sidebar.backToBottom": "回到底部",
Expand Down Expand Up @@ -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",
Expand Down
62 changes: 61 additions & 1 deletion emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
});
1 change: 1 addition & 0 deletions emrg/server/evolution_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading