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` (237: 44 daemon_client + 19 conn-manager + 22 app-commands + 115 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (238: 44 daemon_client + 19 conn-manager + 22 app-commands + 116 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); 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.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (801) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (237: 44 daemon_client + 19 conn-manager + 22 app-commands + 115 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
GUI: `cd emrg/gui && npm test` (238: 44 daemon_client + 19 conn-manager + 22 app-commands + 116 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — 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
17 changes: 15 additions & 2 deletions emrg/gui/renderer/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,21 @@
.session-view.active {
display: flex;
}
/* 会话标题栏:项目/名称(id) 或 项目/id,滚动置顶、全宽贴边(负 margin 抵消 .session-view padding) */
.session-header {
position: sticky;
top: 0;
z-index: 5;
flex-shrink: 0;
margin: calc(-1 * var(--sp-5)) calc(-1 * var(--sp-5)) var(--sp-3) !important;
padding: var(--sp-3) var(--sp-5);
width: auto !important;
max-width: none !important;
font-size: var(--fs-secondary);
color: var(--text-2);
background: var(--bg);
border-bottom: 1px solid var(--border);
}
/* P3 finalize:会话连接断开标记(P4 每会话容器可见;激活会话另走全局横幅) */
.session-view.disconnected {
opacity: 0.55;
Expand Down Expand Up @@ -756,8 +771,6 @@ body.sidebar-collapsed #app {
flex: 1;
overflow-y: auto;
padding: var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.result-empty {
Expand Down
32 changes: 32 additions & 0 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -553,12 +553,41 @@ const App = (() => {
let view = [...$("workspace").children].find((c) => c.dataset?.sid === sid);
if (!view) {
view = el("div", { class: "session-view", id: "session-view-" + sid, dataset: { sid } });
// 顶部标题栏(项目/名称(id) 或 项目/id);Chat.clear 保留它,消息节点在其后追加
view.appendChild(el("div", { class: "session-header" }));
$("workspace").appendChild(view);
}
Chat.registerContainer(sid, view); // 幂等:重复注册覆盖同一元素
return view;
}

/** 会话标题栏显示的项目名:openSessions 优先,回退全局 projectDir 的 basename */
function sessionProjectName(sid) {
const os = state.openSessions.find((s) => s.sid === sid);
if (os && os.projectName) return os.projectName;
const dir = state.projectDir || "";
const parts = dir.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || dir;
}

// 会话视图顶部标题栏:项目/名称(id) 或 项目/id(有 title 时带 (id) 后缀)
function renderSessionHeader(sid) {
if (!sid) return;
const view = [...$("workspace").children].find((c) => c.dataset?.sid === sid);
if (!view) return;
let header = view.querySelector(".session-header");
if (!header) {
header = el("div", { class: "session-header" });
view.insertBefore(header, view.firstChild);
}
const cur = state.sessions.find((s) => s.session_id === sid) || {};
const project = sessionProjectName(sid);
const title = cur.title && cur.title !== sid ? cur.title : "";
const text = title ? `${project}/${title}(${sid})` : `${project}/${sid}`;
header.textContent = text;
header.title = text; // 悬停完整信息
}

// rant 18:55:09 v0.2:工作区视图互斥(激活会话视图 → 同步隐藏所有面板视图 + 清导航高亮)
function activateSessionView(sid) {
for (const p of VIEWS) {
Expand All @@ -575,6 +604,7 @@ const App = (() => {
if (child.classList) child.classList.remove("active");
}
view.classList.add("active");
renderSessionHeader(sid); // 标题栏随激活/重命名刷新
if (!existed) Chat.scrollToBottom(sid);
setWorkspaceChrome("sessions"); // 恢复输入区 + 成果面板(含 back-to-bottom 按位置恢复)
state.activeView = "sessions";
Expand Down Expand Up @@ -875,6 +905,8 @@ const App = (() => {
const sessions = await window.emrg.listSessions();
state.sessions = sessions;
Sidebar.render(sessions);
Sidebar.renderOpenSessions(state.openSessions); // 打开会话区 title 同步刷新
renderSessionHeader(state.sessionId); // 激活会话标题栏同步刷新
} catch { /* 忽略 */ }
}

Expand Down
7 changes: 6 additions & 1 deletion emrg/gui/renderer/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ const Chat = (() => {

function clear(sid) {
const key = sid || null;
chatContainer(key).innerHTML = "";
const cv = chatContainer(key);
// 清空消息节点但保留 .session-header 标题栏(app.js 每个 .session-view 顶部固定)
for (const node of [...cv.children]) {
if (node.classList && node.classList.contains("session-header")) continue;
node.remove();
}
st(key).groupNodes.clear();
st(key).toolRows.clear();
st(key).doneRids.clear();
Expand Down
32 changes: 24 additions & 8 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ function makeSandbox(overrides = {}) {
/** 等 microtask 完成 */
const tick = () => new Promise((r) => setTimeout(r, 20));

/** 消息节点数:排除 .session-header 标题栏(app.js 每会话视图顶部固定) */
const msgCount = (el) => (el.children || []).filter((c) => !(c.className || "").split(/\s+/).includes("session-header")).length;

test("8 模块按序加载且全局符号解析", () => {
const { ctx } = makeSandbox();
const out = vm.runInContext(
Expand Down Expand Up @@ -987,6 +990,19 @@ test("P2 框架:右栏 Tab 栏渲染 + 切换 pane 显隐", async () => {
assert.ok(els["result-list"].classList.contains("active"), "切回产物 pane");
});

test("rant 20:49:45:产物 pane 显示由 .result-pane 控制——.result-list 不得重声明 display", () => {
// CSS 源级断言(沙箱无 CSS 引擎)。根因:.result-list 曾声明 display:flex(同特异性 0,1,0
// 后定义胜出)覆盖 .result-pane{display:none},导致文件 tab 下产物 pane 恒显示。
const css = fs.readFileSync(path.join(RENDERER_CSS, "layout.css"), "utf8");
// 定位独立 .result-list 规则(行首选择器,排除 #result-panel.collapsed .result-list 前缀形态)
const start = css.indexOf("\n.result-list {") + 1;
const end = css.indexOf("\n.result-empty {", start); // 下一个独立规则
const block = css.slice(start, end > start ? end : start + 400);
assert.ok(start > 0, ".result-list 独立规则应存在");
assert.ok(!/display\s*:/.test(block), ".result-list 不得重声明 display(显隐由 .result-pane/.result-pane.active 控制)");
assert.ok(!/flex-direction\s*:/.test(block), ".result-list 不得重声明 flex-direction(.result-pane.active 已提供)");
});

test("P2 框架:resizer 拖拽改宽度 + .dragging 抑制 transition", async () => {
const { ctx, win, els } = makeSandbox();
await tick();
Expand Down Expand Up @@ -1710,8 +1726,8 @@ test("P3 s2: activateSessionView 建独立容器并切换 display(仅激活可
assert.strictEqual(els["workspace"].children.length, 2, "both views live under the #workspace wrapper");
// 无 sid 渲染 → 落激活会话容器(slice 2 回退链第二跳)
ctx.EMRG_Chat.addSystemMessage("to-active");
assert.strictEqual(vb.children.length, 1, "unsid'd node goes to active session view");
assert.strictEqual(va.children.length, 0, "inactive view untouched (state preserved)");
assert.strictEqual(msgCount(vb), 1, "unsid'd node goes to active session view");
assert.strictEqual(msgCount(va), 0, "inactive view untouched (state preserved)");
});

test("P3 s2: Chat.clear 只清目标容器——无 sid 清激活,带 sid 定向清", async () => {
Expand All @@ -1728,15 +1744,15 @@ test("P3 s2: Chat.clear 只清目标容器——无 sid 清激活,带 sid 定
);
const va = ctx.EMRG_Chat.chatContainer("sess-a");
const vb = ctx.EMRG_Chat.chatContainer("sess-b");
assert.strictEqual(va.children.length, 1, "sess-a has its node");
assert.strictEqual(vb.children.length, 1, "sess-b has its node");
assert.strictEqual(msgCount(va), 1, "sess-a has its node");
assert.strictEqual(msgCount(vb), 1, "sess-b has its node");
// 无 sid clear(/clear 的既有调用形态)→ 只清激活容器
ctx.EMRG_Chat.clear();
assert.strictEqual(vb.children.length, 0, "active view cleared");
assert.strictEqual(va.children.length, 1, "inactive view retained (切回继续看到原消息)");
assert.strictEqual(msgCount(vb), 0, "active view cleared");
assert.strictEqual(msgCount(va), 1, "inactive view retained (切回继续看到原消息)");
// 带 sid clear → 定向清
ctx.EMRG_Chat.clear("sess-a");
assert.strictEqual(va.children.length, 0, "targeted clear empties sess-a");
assert.strictEqual(msgCount(va), 0, "targeted clear empties sess-a");
});

test("P3 s2: 未注册 sid 的事件落激活容器,状态桶仍按 sid 隔离", async () => {
Expand All @@ -1750,7 +1766,7 @@ test("P3 s2: 未注册 sid 的事件落激活容器,状态桶仍按 sid 隔离
const vx = ctx.EMRG_Chat.chatContainer("sess-x");
// 广播流(sid=sess-other 无独立容器,P4 前过渡)→ 节点落激活容器、状态入自己桶
ctx.EMRG_Chat.handleDelta([{ request_id: "r1", content: "fallback", done: false, delta: true }], "sess-other");
assert.strictEqual(vx.children.length, 1, "unregistered sid renders into active view");
assert.strictEqual(msgCount(vx), 1, "unregistered sid renders into active view");
assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-other").size, 1, "state bucket keyed by own sid");
assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-x").size, 0, "active bucket untouched by other sid");
});
Expand Down
Loading