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 numberDiff line numberDiff 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` (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); 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 DownExpand 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` (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
GUI: `cd emrg/gui && npm test` (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — 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 numberDiff line numberDiff 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 测试(150 项:43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(153 项:43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 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 numberDiff line numberDiff 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 (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration)
npm test # run Node tests (153: 43 daemon_client + 17 conn-manager + 22 app-commands + 43 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; 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
23 changes: 19 additions & 4 deletions emrg/gui/renderer/css/layout.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,17 +112,31 @@
background: var(--bg);
}

/* 聊天滚动区:居中单栏最大 760px,左右留白 ≥24px */
/* 聊天区包装(P3 slice 2:多会话容器——每会话一个 .session-view,display 切换) */
#chat-view {
flex: 1;
overflow-y: auto;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}

/* 每会话滚动容器:只有激活会话可见,切换 display 保留状态(浏览器 tab 效果) */
.session-view {
position: absolute;
inset: 0;
overflow-y: auto;
display: none;
flex-direction: column;
gap: var(--sp-5);
padding: var(--sp-5) var(--sp-5) var(--sp-4);
scroll-behavior: smooth;
}
#chat-view > * {
.session-view.active {
display: flex;
}
#chat-view > *:not(.session-view),
.session-view > * {
width: 100%;
max-width: 760px;
margin-left: auto;
Expand DownExpand Up@@ -231,7 +245,8 @@ body.sidebar-collapsed #app {
#sidebar {
display: none;
}
#chat-view {
/* #chat-view 现为绝对定位包装(padding 移入 .session-view,避免滚动条内缩) */
.session-view {
padding: var(--sp-4) var(--sp-4) var(--sp-3);
}
#composer-wrap {
Expand Down
47 changes: 39 additions & 8 deletions emrg/gui/renderer/js/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ const App = (() => {
return;
}
await window.emrg.clearSession({ sessionId: state.sessionId });
Chat.clear();
Chat.clear(state.sessionId);
Chat.addSystemMessage(_t("app.cleared"));
break;
case "/compact":
Expand DownExpand Up@@ -323,7 +323,7 @@ const App = (() => {
async function doRewind(recordIndex) {
try {
const res = await window.emrg.rewindSession({ sessionId: state.sessionId, recordIndex });
Chat.clear();
Chat.clear(state.sessionId);
Chat.addSystemMessage(_t("app.rewound", { index: recordIndex, n: res.removedCount ?? 0 }));
} catch (e) {
Chat.addSystemMessage(_t("app.rewindFailed", { msg: e.message }));
Expand DownExpand Up@@ -553,6 +553,30 @@ const App = (() => {
}

// ── 会话 ─────────────────────────────────
// P3 slice 2:每会话一个 .session-view 容器(浏览器 tab 效果——切换 display 保留状态)
function ensureSessionView(sid) {
if (!sid) return $("chat-view");
// 在 wrapper 内按 dataset.sid 查找既有容器(不依赖 getElementById——测试沙箱对
// 未知 id 返回新 mock,且 mock 的 .id 不入 attributes;dataset 双端一致)
let view = [...$("chat-view").children].find((c) => c.dataset?.sid === sid);
if (!view) {
view = el("div", { class: "session-view", id: "chat-view-" + sid, dataset: { sid } });
$("chat-view").appendChild(view);
}
Chat.registerContainer(sid, view); // 幂等:重复注册覆盖同一元素
return view;
}

function activateSessionView(sid) {
const view = ensureSessionView(sid);
for (const child of $("chat-view").children) {
if (child.classList) child.classList.remove("active");
}
view.classList.add("active");
Chat.scrollToBottom(sid);
return view;
}

async function switchSession(sid, opts = {}) { // G65:busy 即自有流进行中/发送中(IPC 往返窗口内 ownStreamRequestId 尚未赋值)
if (state.busy) {
Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy);
Expand All@@ -562,8 +586,8 @@ const App = (() => {
const res = await window.emrg.switchSession({ sessionId: sid });
state.sessionId = sid;
// P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId
// (G110 旧语义=清全局自有流标记;per-sid 后由各会话条目自己管理,P4 切回继续生成
Chat.clear();
// P3 slice 2:激活该会话容器(状态保留,不 Chat.clear——切回继续看到原消息/流式现场
activateSessionView(sid);
updateEmptyState();
if (res.error === "session_not_found") {
Chat.addSystemMessage(_t("app.deletedSwitch"));
Expand All@@ -577,7 +601,7 @@ const App = (() => {
}
// G13:v1 不加载历史(G12)
if (!opts.silent) {
Chat.addSystemMessage(_t("app.switched"));
Chat.addSystemMessage(_t("app.switched"), sid);
}
updateEmptyState();
Sidebar.highlight(sid);
Expand All@@ -595,7 +619,8 @@ const App = (() => {
try {
const res = await window.emrg.newSession();
state.sessionId = res.session_id;
Chat.clear();
activateSessionView(state.sessionId); // P3 slice 2:新会话独立容器(空)
Chat.clear(state.sessionId); // 新会话从空开始(容器可能被复用)
updateEmptyState(); // 欢迎屏即反馈
await refreshSessions();
Sidebar.highlight(state.sessionId);
Expand All@@ -612,6 +637,10 @@ const App = (() => {
}
try {
await window.emrg.deleteSession({ sessionId: sid });
// P3 slice 2:删除会话 → 释放其容器(若已打开)
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.sessions.filter((s) => s.session_id !== sid);
if (remaining.length > 0) {
Expand DownExpand Up@@ -959,8 +988,9 @@ const App = (() => {
// ── 空状态欢迎屏 ───────────────────────
function updateEmptyState() {
const empty = $("empty-state");
const chatView = $("chat-view");
empty.classList.toggle("hidden", chatView.children.length > 0);
// P3 slice 2:检查**激活会话**容器(wrapper 恒含 session-view 子节点,不能数 wrapper)
const activeView = Chat.chatContainer(state.sessionId);
empty.classList.toggle("hidden", activeView.children.length > 0);
}

// ── 输入条状态 ─────────────────────────
Expand DownExpand Up@@ -1297,6 +1327,7 @@ const App = (() => {
switchSession,
newSession,
deleteSession,
activateSessionView, // P3 slice 2:激活会话容器(display 切换;导出供测试)
refreshSessions,
showConvMenu,
handleEvent,
Expand Down
7 changes: 5 additions & 2 deletions emrg/gui/renderer/js/chat.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,12 @@ const Chat = (() => {
return sessionState.get(key);
}

/** 该会话的渲染容器:已注册的独立容器优先,否则默认聊天区(P3 过渡期行为) */
/** 该会话的渲染容器:已注册的独立容器优先,其次当前激活会话的容器(无 sid 事件落激活会话,P3 slice 2),最后回退默认聊天区(单会话过渡期行为)。 */
function chatContainer(sid) {
return containers.get(sid) || $("chat-view");
if (containers.has(sid)) return containers.get(sid);
const active = App.state?.sessionId;
if (active && containers.has(active)) return containers.get(active);
return $("chat-view");
}

/** P4 起:为新打开的会话注册独立容器;关闭时 unregister 清引用 */
Expand Down
75 changes: 74 additions & 1 deletion emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,13 @@ function makeEl(id) {
_update() { node.className = [...node._cls].join(" "); },
className: "",
textContent: "",
innerHTML: "",
_html: "",
get innerHTML() { return this._html; },
set innerHTML(v) {
this._html = v;
// 忠实 DOM:innerHTML = "" 清空子节点(Chat.clear 依赖;P3 s2 容器隔离判别)
if (v === "") { while (this.children.length) this.children[0].remove(); }
},
value: "",
disabled: false,
title: "",
Expand DownExpand Up@@ -1060,3 +1066,70 @@ test("P3 s1: cancelled 带 sid → 只清该会话条目;无 sid → 清激活
await vm.runInContext('App.handleEvent({ type: "cancelled", data: {} });', ctx);
assert.strictEqual(ctx.App.state.busy, false, "no-sid cancelled clears active");
});

// ── P3 slice 2(rant 15:07:19):每会话 .session-view 容器 + display 切换 ──

test("P3 s2: activateSessionView 建独立容器并切换 display(仅激活可见)", async () => {
const { ctx, els } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-a";' +
'App.activateSessionView("sess-a");' +
'App.state.sessionId = "sess-b";' +
'App.activateSessionView("sess-b");',
ctx
);
// 沙箱 getElementById 对未知 id 返回新 mock —— 视图经 Chat.chatContainer(sid) 取实际对象
const va = ctx.EMRG_Chat.chatContainer("sess-a");
const vb = ctx.EMRG_Chat.chatContainer("sess-b");
assert.notStrictEqual(va, vb, "two distinct per-session views");
assert.strictEqual(va.classList.contains("active"), false, "sess-a deactivated after switch");
assert.strictEqual(vb.classList.contains("active"), true, "sess-b is the active view");
assert.strictEqual(els["chat-view"].children.length, 2, "both views live under the #chat-view 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)");
});

test("P3 s2: Chat.clear 只清目标容器——无 sid 清激活,带 sid 定向清", async () => {
const { ctx } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-a";' +
'App.activateSessionView("sess-a");' +
'App.state.sessionId = "sess-b";' +
'App.activateSessionView("sess-b");' +
'EMRG_Chat.addSystemMessage("msg-a", "sess-a");' +
'EMRG_Chat.addSystemMessage("msg-b", "sess-b");',
ctx
);
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");
// 无 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 (切回继续看到原消息)");
// 带 sid clear → 定向清
ctx.EMRG_Chat.clear("sess-a");
assert.strictEqual(va.children.length, 0, "targeted clear empties sess-a");
});

test("P3 s2: 未注册 sid 的事件落激活容器,状态桶仍按 sid 隔离", async () => {
const { ctx } = makeSandbox({});
await tick();
await vm.runInContext(
'App.state.sessionId = "sess-x";' +
'App.activateSessionView("sess-x");',
ctx
);
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(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");
});

Loading