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 @@ -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` (163: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 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` (167: 43 daemon_client + 17 conn-manager + 22 app-commands + 50 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)
Expand Down Expand Up @@ -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` (163: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 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` (167: 43 daemon_client + 17 conn-manager + 22 app-commands + 50 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 路径不受影响)

Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI
cd emrg/gui
npm ci # 安装依赖(生产模式可 --omit=dev)
npm start # 启动 GUI(自动拉起 daemon)
npm test # 运行 Node 测试(163 项:43 daemon_client + 17 conn-manager + 22 app-commands + 46 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(167 项:43 daemon_client + 17 conn-manager + 22 app-commands + 50 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`)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (163: 43 daemon_client + 17 conn-manager + 22 app-commands + 46 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 (167: 43 daemon_client + 17 conn-manager + 22 app-commands + 50 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`).
Expand Down
64 changes: 64 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ vision = false
await ensureConnected();
const pong = await waitForPong();
const sessions = await listSessions();
// P4 slice 2:启动恢复打开会话(gui_state.json → 重开连接 + resume 重订阅)
await restoreOpenSessions(sessions);
return {
config_exists: true,
api_key_configured: true,
Expand All @@ -261,6 +263,8 @@ vision = false
evolution_count: pong?.evolution_count ?? 0, // G19:init 透传演化计数(waitForPong 已消耗 pong)
version: APP_VERSION, // WorkBuddy P3:版本号随 package.json 走(此前 renderer 硬编码 v0.2.7)
sessions,
open_sessions: openSessionsList(),
active_sid: currentSessionId, // P4 slice 2:恢复后的激活会话(renderer 直接采用)
};
});

Expand Down Expand Up @@ -325,6 +329,7 @@ vision = false
connManager?.close(sessionId); // P2:删除会话 → 关闭该会话连接(若打开)
openSessions.delete(sessionId); // P4:删除(删数据)→ 一并移出打开会话簿记
schedulePersistGuiState();
broadcastOpenSessions();
return { ok: true };
});

Expand Down Expand Up @@ -358,6 +363,7 @@ vision = false
// P4:新会话在首条消息前不建连接(sendMessage 自动 open)——此处仅标记激活
// 并刷新持久化(activeSid 前进;openSessions 条目随 openSession 落簿记)
schedulePersistGuiState();
broadcastOpenSessions();
return { session_id: sid };
});

Expand Down Expand Up @@ -719,6 +725,7 @@ vision = false
projectPath,
lastActive: new Date().toISOString(),
});
broadcastOpenSessions();
}

// 激活会话变化(切换/新会话/发送)→ 更新 lastActive + activeSid → 防抖写盘
Expand All @@ -727,6 +734,7 @@ vision = false
openSessions.get(sid).lastActive = new Date().toISOString();
}
schedulePersistGuiState();
broadcastOpenSessions();
}

function schedulePersistGuiState() {
Expand Down Expand Up @@ -756,13 +764,69 @@ vision = false
}
}

// 读盘 + 清洗(启动恢复用;损坏/缺失 → 空)
function readGuiState() {
try {
const p = guiStatePath(os.homedir());
if (!fs.existsSync(p)) return { openSessions: [], activeSid: null };
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
return {
openSessions: sanitizeOpenSessions(raw.openSessions || []),
activeSid: raw.activeSid || null,
};
} catch (e) {
logger.warn(`[gui] gui_state.json read failed: ${e.message}`);
return { openSessions: [], activeSid: null };
}
}

// P4 slice 2:启动恢复——gui_state.json 中的打开会话(上限 20)逐个重开连接 +
// resume 重订阅;activeSid 失效 → 第一个有效;失效条目跳过 + 重写盘。
async function restoreOpenSessions(sessions) {
const { openSessions: saved, activeSid } = readGuiState();
const validSids = new Set((sessions || []).map((s) => s.session_id));
let restored = 0;
for (const entry of saved) {
if (restored >= 20) break; // 恢复上限(sanitize 已守,双保险)
if (!validSids.has(entry.sid)) continue; // 失效条目跳过
try {
await openSession(entry.sid, entry.projectPath); // resume 重订阅
restored += 1;
} catch (e) {
logger.warn(`[gui] restore session ${entry.sid} failed: ${e.message}`);
openSessions.delete(entry.sid); // 失效 → 移除
}
}
// activeSid:优先恢复原激活;失效 → 第一个有效
if (openSessions.has(activeSid)) {
currentSessionId = activeSid;
} else if (openSessions.size > 0) {
currentSessionId = openSessionsList()[0].sid;
}
if (currentSessionId) {
win?.setTitle(`EMRG — ${currentSessionId}`);
}
schedulePersistGuiState(); // 失效条目剔除后重写盘
broadcastOpenSessions();
return restored;
}

// 通知 renderer 打开会话列表变化(侧边栏数据源,slice 2)
function broadcastOpenSessions() {
sendToRenderer("open_sessions", {
openSessions: openSessionsList(),
activeSid: currentSessionId,
});
}

// 关闭会话(保留磁盘数据):断连 + 移除 + 持久化。P4 slice 2 侧边栏"关闭"入口用。
async function closeSession(sid) {
const entry = openSessions.get(sid);
connManager?.close(sid); // 主动关闭(_intentionalClose 抑制断线横幅)
openSessions.delete(sid);
schedulePersistGuiState();
if (currentSessionId === sid) currentSessionId = null; // 关闭激活会话 → 无激活
broadcastOpenSessions();
return { ok: true, closed: !!entry };
}

Expand Down
12 changes: 12 additions & 0 deletions emrg/gui/renderer/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@
letter-spacing: 0.02em;
}

/* P4 slice 2:打开会话区(跨项目 tab)——紧贴新对话按钮,视觉轻量 */
#open-sessions {
padding-bottom: var(--sp-1);
border-bottom: 1px solid var(--border);
margin-bottom: var(--sp-1);
max-height: 40%;
overflow-y: auto;
}
.open-sessions-label {
padding-top: var(--sp-2);
}

.sidebar-footer {
display: flex;
align-items: center;
Expand Down
3 changes: 3 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
<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>
<!-- 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>
<nav id="conv-list"></nav>
<div class="sidebar-footer">
<span id="status-dot" class="status-dot gray" title="连接状态" data-i18n-title="sidebar.statusTitle"></span>
Expand Down
105 changes: 101 additions & 4 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const App = (() => {
// (defineProperty getter/setter → sidState(sessionId)),既有调用点零改动;
// 事件按 sid 路由时操作对应条目(后台会话的 done 不误清激活会话的 busy)。
sessionsBySid: new Map(), // sid → { busy, ownStreamRequestId, mode, autoScroll }
// P4 slice 2(rant 15:07:19):跨项目打开的会话(侧边栏数据源,main 广播)
openSessions: [], // [{ sid, projectName, projectPath, lastActive }],lastActive 倒序
apiKeyConfigured: false,
configExists: false,
projectDir: "",
Expand Down Expand Up @@ -91,11 +93,22 @@ const App = (() => {
if (init.sessions && init.sessions.length > 0) {
state.sessions = init.sessions;
Sidebar.render(init.sessions);
const current = init.sessions.find((s) => s.session_id === state.sessionId);
if (current) {
Sidebar.highlight(state.sessionId);
// P4 slice 2:main 恢复的打开会话 + 激活会话(跳过 switchSession 的 IPC 往返)
state.openSessions = init.open_sessions || [];
Sidebar.renderOpenSessions(state.openSessions);
const restoredSid = init.active_sid;
if (restoredSid && init.sessions.some((s) => s.session_id === restoredSid)) {
state.sessionId = restoredSid;
activateSessionView(restoredSid);
updateEmptyState();
Sidebar.highlight(restoredSid);
} else {
await switchSession(init.sessions[0].session_id, { silent: true });
const current = init.sessions.find((s) => s.session_id === state.sessionId);
if (current) {
Sidebar.highlight(state.sessionId);
} else {
await switchSession(init.sessions[0].session_id, { silent: true });
}
}
} else {
await newSession();
Expand Down Expand Up @@ -667,6 +680,83 @@ const App = (() => {
} catch { /* 忽略 */ }
}

// ── P4 slice 2:打开会话(跨项目侧边栏) ───────────
// 关闭 = 断开连接 + 移出列表,**保留磁盘数据**(与删除区分:关闭留数据/删除删数据)
async function closeOpenSession(sid) {
if (state.busy && state.sessionId === sid) {
Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy);
return;
}
try {
await window.emrg.closeSession({ sessionId: sid });
Chat.unregisterContainer(sid); // 释放容器(若已打开)
const view = [...$("chat-view").children].find((c) => c.dataset?.sid === sid);
if (view) view.remove();
if (state.sessionId === sid) {
// 关闭激活会话 → 切到剩余打开会话中最近激活的,否则新建
const remaining = state.openSessions.filter((s) => s.sid !== sid);
if (remaining.length > 0) {
await switchSession(remaining[0].sid, { silent: true });
} else {
await newSession();
}
}
} catch (e) {
Chat.addSystemMessage(_t("app.closeFailed", { msg: e.message }));
}
}

// 打开会话右键菜单:关闭(留数据)/ 重命名 / 删除(删数据,确认)
function showOpenSessionsMenu(item, entry) {
const menu = $("ctx-menu");
menu.innerHTML = "";
const mk = (label, danger, action) => {
const b = el("button", { class: "ctx-item" + (danger ? " danger" : "") }, label);
b.addEventListener("click", () => {
hideCtxMenu();
action();
});
menu.appendChild(b);
};
const cur = state.sessions.find((s) => s.session_id === entry.sid);
const title = (cur && cur.title) || entry.sid;
mk(_t("app.closeSession"), false, () => closeOpenSession(entry.sid));
mk(_t("app.rename"), false, () => Dialogs.showRename(entry.sid, title));
mk(_t("app.deleteConv"), true, () => Dialogs.showConfirm(EMRG_Copy.COPY.deleteConfirmTitle, EMRG_Copy.COPY.deleteConfirmBody, {
okText: _t("dlg.delete"),
danger: true,
onOk: () => deleteSession(entry.sid),
}));
menu.hidden = false;
const rect = item.getBoundingClientRect();
menu.style.left = Math.min(rect.right, window.innerWidth - 160) + "px";
menu.style.top = Math.min(rect.bottom, window.innerHeight - 80) + "px";
const btns = menu.querySelectorAll(".ctx-item");
let idx = btns.length ? 0 : -1;
const setActive = (i) => {
if (i < 0 || i >= btns.length) return;
idx = i;
btns.forEach((b, j) => b.classList.toggle("active", j === idx));
};
setActive(0);
_ctxMenuKeyHandler = (ev) => {
if (ev.key === "ArrowDown") {
ev.preventDefault();
setActive((idx + 1) % btns.length);
} else if (ev.key === "ArrowUp") {
ev.preventDefault();
setActive((idx - 1 + btns.length) % btns.length);
} else if (ev.key === "Enter") {
ev.preventDefault();
if (idx >= 0) btns[idx].click();
} else if (ev.key === "Escape") {
ev.preventDefault();
hideCtxMenu();
}
};
document.addEventListener("keydown", _ctxMenuKeyHandler);
}

// ── 右键菜单(重命名 / 删除) ───────────
function showConvMenu(item, sid, title) {
// 设计 §3.2:右键菜单 = 重命名 / 删除(删除有友好确认)
Expand Down Expand Up @@ -1077,6 +1167,11 @@ const App = (() => {
state.sessions = data.sessions || [];
Sidebar.render(data.sessions || []);
break;
case "open_sessions":
// P4 slice 2:main 广播打开会话列表变化 → 刷新侧边栏打开会话区
state.openSessions = data.openSessions || [];
Sidebar.renderOpenSessions(state.openSessions);
break;
case "disconnected":
// P3 finalize:断连按 sid 隔离——后台会话断连不影响全局 UI(无横幅/红点),
// 仅激活会话(或无 sid 的单会话过渡期)断连显示全局横幅 + 红点。
Expand Down Expand Up @@ -1347,6 +1442,8 @@ const App = (() => {
switchSession,
newSession,
deleteSession,
closeOpenSession, // P4 slice 2:关闭打开会话(保留数据)
showOpenSessionsMenu, // P4 slice 2:打开会话右键菜单
activateSessionView, // P3 slice 2:激活会话容器(display 切换;导出供测试)
refreshSessions,
showConvMenu,
Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ const I18N = (() => {
"app.deleteFailed": "删除失败了:{msg}",
"app.rename": "✏️ 重命名",
"app.deleteConv": "🗑 删除对话",
"app.closeSession": "❌ 关闭会话(保留数据)",
"app.closeFailed": "关闭会话失败了:{msg}",
"sidebar.openSessions": "打开的会话",
"sidebar.noOpenSessions": "暂无打开的会话",
"sidebar.openSessionOf": "{project} / {title}",
"app.noModels": "还没有配置模型",
"app.goSettings": "去设置添加",
"app.modelSwitchFailed": "切换模型失败了:{msg}",
Expand Down Expand Up @@ -587,6 +592,11 @@ const I18N = (() => {
"app.deleteFailed": "Delete failed: {msg}",
"app.rename": "✏️ Rename",
"app.deleteConv": "🗑 Delete conversation",
"app.closeSession": "❌ Close session (keep data)",
"app.closeFailed": "Failed to close session: {msg}",
"sidebar.openSessions": "Open sessions",
"sidebar.noOpenSessions": "No open sessions",
"sidebar.openSessionOf": "{project} / {title}",
"app.noModels": "No models configured",
"app.goSettings": "Add in Settings",
"app.modelSwitchFailed": "Failed to switch model: {msg}",
Expand Down
Loading
Loading