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` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (255: 45 daemon_client + 19 conn-manager + 22 app-commands + 126 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 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/<name>.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/`)
Expand Down Expand Up @@ -119,7 +119,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (988) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 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` (255: 45 daemon_client + 19 conn-manager + 22 app-commands + 126 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 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 路径不受影响)

Expand Down
6 changes: 0 additions & 6 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@
background: var(--bg-soft);
color: var(--text-1);
}
/* 键盘导航聚焦(↑↓)——区别于当前会话高亮 */
.conv-item.kbd-focus {
outline: 2px solid var(--accent);
outline-offset: -2px;
background: var(--bg-soft);
}
.conv-item.active {
background: var(--accent-soft);
color: var(--accent);
Expand Down
1 change: 0 additions & 1 deletion emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
<!-- 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>
</div>
Expand Down
7 changes: 1 addition & 6 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ const App = (() => {
return;
}
if (init.sessions && init.sessions.length > 0) {
state.sessions = init.sessions;
Sidebar.render(init.sessions);
state.sessions = init.sessions; // rant 11:44:52:历史会话列表已移除,不再渲染 conv-list
// P4 slice 2:main 恢复的打开会话 + 激活会话(跳过 switchSession 的 IPC 往返)
state.openSessions = init.open_sessions || [];
Sidebar.renderOpenSessions(state.openSessions);
Expand Down Expand Up @@ -651,7 +650,6 @@ const App = (() => {
if (res.error === "session_not_found") {
Chat.addSystemMessage(_t("app.deletedSwitch"));
if (res.next_session) {
Sidebar.render(res.sessions || []);
await switchSession(res.next_session, { silent: true });
} else {
await newSession();
Expand Down Expand Up @@ -909,7 +907,6 @@ const App = (() => {
try {
const sessions = await window.emrg.listSessions();
state.sessions = sessions;
Sidebar.render(sessions);
Sidebar.renderOpenSessions(state.openSessions); // 打开会话区 title 同步刷新
renderSessionHeader(state.sessionId); // 激活会话标题栏同步刷新
} catch { /* 忽略 */ }
Expand Down Expand Up @@ -1491,7 +1488,6 @@ const App = (() => {
break;
case "sessions":
state.sessions = data.sessions || [];
Sidebar.render(data.sessions || []);
break;
case "open_sessions":
// P4 slice 2:main 广播打开会话列表变化 → 刷新侧边栏打开会话区
Expand Down Expand Up @@ -1539,7 +1535,6 @@ const App = (() => {
case "list_result":
if (data.type === "sessions_list") {
state.sessions = data.sessions || [];
Sidebar.render(data.sessions || []);
}
break;
case "command_result":
Expand Down
91 changes: 3 additions & 88 deletions emrg/gui/renderer/js/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*/

const Sidebar = (() => {
let sessions = [];
let openSessions = [];

/**
Expand Down Expand Up @@ -46,45 +45,9 @@ const Sidebar = (() => {
return title ? `${project}/${title}` : `${project}/${sid}`;
}

/** cwd 末段作项目名(Path(s.cwd).name 语义,兼容 \\ 与 /) */
function cwdProjectName(cwd) {
if (!cwd) return "";
const norm = String(cwd).replace(/\\/g, "/").replace(/\/+$/, "");
const seg = norm.split("/");
return seg[seg.length - 1] || "";
}

/** 渲染会话列表(rant 17:48:07:去掉今天/昨天/更早分组,按最后活跃倒序,project/name|id) */
function render(list) {
sessions = list || [];
const nav = $("conv-list");
nav.innerHTML = "";
if (!sessions.length) {
nav.appendChild(el("div", { class: "conv-item placeholder" }, EMRG_Copy.COPY.noSessions));
return;
}
const sorted = [...sessions].sort((a, b) =>
String(b.updated_at || b.created_at || "").localeCompare(String(a.updated_at || a.created_at || "")));
for (const s of sorted) {
const item = el("div", { class: "conv-item" });
item.dataset.sid = s.session_id;
const title = s.title || ""; // G27:title 优先,无 title 则空(id 已单独显示)
const project = cwdProjectName(s.cwd);
item.appendChild(el("span", { class: "conv-title" }, sessionLabel(project, title, s.session_id)));
item.addEventListener("click", () => App.switchSession(s.session_id, { scopeNav: nav }));
// 右键菜单:重命名 / 删除(友好确认)
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
App.showConvMenu(item, s.session_id, title || s.session_id);
});
nav.appendChild(item);
}
highlight(App.state.sessionId);
}

/** 高亮当前对话:指定 navEl 则仅更新该列表,否则两列表都更新(rant 2026-08-20T22:04:02 列表选中互不影响) */
/** 高亮当前会话条目(rant 11:44:52:历史会话列表已移除,仅剩打开会话区) */
function highlight(sid, navEl) {
const targets = navEl ? [navEl] : [$("conv-list"), $("open-sessions")];
const targets = navEl ? [navEl] : [$("open-sessions")];
for (const nav of targets) {
if (!nav) continue;
for (const item of nav.querySelectorAll(".conv-item")) {
Expand All @@ -93,55 +56,7 @@ const Sidebar = (() => {
}
}

// ── 键盘导航:↑↓ 切换高亮 / Enter 切换会话(与 TUI /resume 选择器一致)──
let _keyHandler = null;
let _focusIdx = -1;

function initKeyboard() {
const nav = $("conv-list");
_keyHandler = (e) => {
// 输入控件(textarea/input/select/contenteditable)内不劫持 ↑↓/Enter——
// 输入框是 <textarea id="input">,多行输入需 ↑↓ 移动光标、Shift+Enter 换行
if (e.target.closest("input, textarea, select, [contenteditable]")) return;
const items = nav.querySelectorAll(".conv-item");
if (!items.length) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (_focusIdx < 0) {
// 未聚焦:从当前会话开始
_focusIdx = items.findIndex((it) => it.classList.contains("active"));
if (_focusIdx < 0) _focusIdx = 0;
}
_focusIdx = e.key === "ArrowDown"
? (_focusIdx + 1) % items.length
: (_focusIdx - 1 + items.length) % items.length;
items.forEach((it, j) => it.classList.toggle("kbd-focus", j === _focusIdx));
items[_focusIdx].scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && _focusIdx >= 0) {
e.preventDefault();
const target = items[_focusIdx];
if (target.dataset.sid) App.switchSession(target.dataset.sid, { scopeNav: nav });
clearFocus();
} else if (e.key === "Escape") {
clearFocus();
}
};
document.addEventListener("keydown", _keyHandler);
}

function clearFocus() {
_focusIdx = -1;
for (const it of $("conv-list").querySelectorAll(".kbd-focus")) {
it.classList.remove("kbd-focus");
}
}

function init() {
if (!_keyHandler) initKeyboard();
}
init(); // 模块级绑定一次

return { render, renderOpenSessions, highlight, clearFocus };
return { renderOpenSessions, highlight };
})();

window.EMRG_Sidebar = Sidebar;
40 changes: 16 additions & 24 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function makeEl(id) {
}

const ELEMENT_IDS = [
"workspace", "input", "send-btn", "stop-btn", "conv-list", "open-sessions", "open-sessions-label", "status-dot", "settings-btn",
"workspace", "input", "send-btn", "stop-btn", "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", "open-chat-btn",
Expand Down Expand Up @@ -285,7 +285,7 @@ test("boot:config 缺失 → 首启引导(不拉起 daemon)", async () =>
assert.ok(els["welcome-dialog"].open, "welcome-dialog 应打开");
});

test("boot:config 就绪 → 加载会话列表", async () => {
test("boot:config 就绪 → 加载会话列表(rant 11:44:52:历史列表已移除,state 仍保留)", async () => {
const { ctx, els } = makeSandbox({
init: async () => ({
config_exists: true,
Expand All @@ -298,9 +298,9 @@ test("boot:config 就绪 → 加载会话列表", async () => {
switchSession: async () => ({}),
});
await tick();
// conv-list 应有会话项(rant 17:48:07:无分组标签,直接 project/name|id)
const items = vm.runInContext('document.getElementById("conv-list").children.length', ctx);
assert.ok(items >= 1, `conv-list 应有会话项,实际 ${items}`);
// rant 11:44:52:conv-list 不再渲染(历史会话列表移除);state.sessions 仍加载供切换/标题栏使用
const count = vm.runInContext('App.state.sessions.length', ctx);
assert.strictEqual(count, 1, `state.sessions 应有 1 条,实际 ${count}`);
});

test("流式 delta 追加 + 工具行 running→done 状态流转", async () => {
Expand Down Expand Up @@ -977,24 +977,18 @@ test("设置/首启对话框:Enter 提交键盘绑定(交互一致性)", a
assert.ok(bindBlock.includes("enterToSave"), "Enter 应通过 enterToSave 触发提交");
});

test("对话列表键盘导航:↑↓ 聚焦 / Enter 切换(与 TUI /resume 一致)", async () => {
const { ctx } = makeSandbox({});
await tick();
const src = fs.readFileSync(path.join(RENDERER_JS, "sidebar.js"), "utf8");
assert.ok(src.includes("ArrowDown"), "sidebar 应支持 ↑↓ 键盘导航");
assert.ok(src.includes("kbd-focus"), "应有键盘聚焦状态");
assert.ok(src.includes("App.switchSession"), "Enter 应切换会话");
const css = fs.readFileSync(path.join(__dirname, "..", "renderer", "css", "components.css"), "utf8");
assert.ok(css.includes(".conv-item.kbd-focus"), "键盘聚焦高亮样式应存在");
});
// rant 11:44:52:历史会话列表 + ↑↓/Enter 键盘导航已整体移除(sidebar.js 不再含 initKeyboard/clearFocus/kbd-focus)

test("对话列表键盘导航:输入控件内不劫持(e.target 守卫,textarea ↑↓/Enter 正常)", async () => {
test("rant 11:44:52: sidebar.js 已移除历史会话列表渲染与键盘导航", async () => {
const { ctx } = makeSandbox({});
await tick();
const src = fs.readFileSync(path.join(RENDERER_JS, "sidebar.js"), "utf8");
// 输入框是 <textarea id="input">,document 级常驻 keydown 必须跳过输入控件
assert.ok(src.includes("closest(\"input, textarea, select, [contenteditable]\")"), "输入控件内应跳过键盘导航");
assert.ok(src.includes("e.target.closest"), "应使用 e.target 守卫");
assert.ok(!src.includes("conv-list"), "sidebar 不应再引用 conv-list");
assert.ok(!src.includes("kbd-focus"), "sidebar 不应再有 kbd-focus 键盘聚焦");
assert.ok(!src.includes("initKeyboard"), "sidebar 不应再有 initKeyboard");
assert.ok(!src.includes("clearFocus"), "sidebar 不应再有 clearFocus");
const html = fs.readFileSync(path.join(__dirname, "..", "renderer", "index.html"), "utf8");
assert.ok(!html.includes('id="conv-list"'), "index.html 不应再有 conv-list nav");
});

test("P3.2:tool_finished 只登记 write/edit 成功文件(bash 不登记)", async () => {
Expand Down Expand Up @@ -2020,7 +2014,7 @@ test("P4 s2: 跨项目打开会话(entry.title 优先,state.sessions 无该
assert.ok(!(t2.textContent || "").includes("sess-local"), "有 title 时不显示 id");
});

test("rant 22:04:02: highlight(sid, navEl) 列表作用域 — 只高亮指定列表,另一列表保持原样", async () => {
test("rant 22:04:02: highlight(sid, navEl) 列表作用域 — 只高亮指定列表(rant 11:44:52:仅剩 open-sessions)", async () => {
const { ctx, els } = makeSandbox({});
await tick();
await vm.runInContext(
Expand All @@ -2035,11 +2029,9 @@ test("rant 22:04:02: highlight(sid, navEl) 列表作用域 — 只高亮指定
// 只高亮打开会话区(模拟点击打开会话列表条目)
await vm.runInContext('EMRG_Sidebar.highlight("sess-b", document.getElementById("open-sessions"));', ctx);
assert.strictEqual(els["open-sessions"].children[0].classList.contains("active"), true, "open-sessions 条目高亮");
const convB = [...els["conv-list"].children].find((c) => c.dataset.sid === "sess-b");
assert.ok(convB && !convB.classList.contains("active"), "conv-list 中同 sid 条目不被联动高亮");
// 无 navEl 参数 → 两列表都更新(兼容初始渲染/全量刷新)
// 无 navEl 参数 → 全量刷新同样只作用于 open-sessions(conv-list 已不存在)
await vm.runInContext('EMRG_Sidebar.highlight("sess-b");', ctx);
assert.strictEqual(convB.classList.contains("active"), true, "无 navEl 时两列表都高亮");
assert.strictEqual(els["open-sessions"].children[0].classList.contains("active"), true, "无 navEl 时 open-sessions 高亮");
});

test("P4 s2: closeOpenSession 关闭激活会话 → 切到剩余打开会话 + 容器释放", async () => {
Expand Down
Loading