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` (231: 44 daemon_client + 19 conn-manager + 22 app-commands + 109 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` (232: 44 daemon_client + 19 conn-manager + 22 app-commands + 110 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` (773) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (231: 44 daemon_client + 19 conn-manager + 22 app-commands + 109 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` (232: 44 daemon_client + 19 conn-manager + 22 app-commands + 110 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
11 changes: 7 additions & 4 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,11 +424,14 @@ vision = false
return { ok: true };
});

ipcMain.handle("emrg:listHistory", async (_e, { sessionId }) => {
// GUI / 指令 P2:/rewind — 获取会话历史消息点(daemon 协议 list_history 已存在
ipcMain.handle("emrg:listHistory", async (_e, { sessionId, limit, offset } = {}) => {
// GUI / 指令 P2:/rewind + rant 14:15:12 历史按需加载(limit/offset 可选
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
const frame = await requireConn().sendCommandAndWait("list_history", { session_id: sessionId, cwd: projectDir }, 5000);
return { messages: frame.messages || [] };
const payload = { session_id: sessionId, cwd: projectDir };
if (limit != null) payload.limit = limit;
if (offset != null) payload.offset = offset;
const frame = await requireConn().sendCommandAndWait("list_history", payload, 5000);
return { messages: frame.messages || [], hasMore: !!frame.has_more };
});

ipcMain.handle("emrg:rewindSession", async (_e, { sessionId, recordIndex }) => {
Expand Down
13 changes: 13 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@
font-size: var(--fs-aux);
color: var(--text-3);
}
/* 历史消息(rant 14:15:12:只读展示,视觉弱化区分于实时消息) */
.msg.user.history {
opacity: 0.72;
animation: none;
}
.history-load-bar {
text-align: center;
font-size: var(--fs-secondary);
color: var(--accent);
padding: var(--sp-2) 0;
cursor: pointer;
user-select: none;
}

/* Markdown 内容排版 */
.msg-body p {
Expand Down
86 changes: 85 additions & 1 deletion emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -661,9 +661,10 @@ const App = (() => {
}
return;
}
// G13:v1 不加载历史(G12)
// G13:v1 不加载历史(G12)→ rant 14:15:12:非 silent 切换加载最近 50 条 + 滚动分页
if (!opts.silent) {
Chat.addSystemMessage(_t("app.switched"), sid);
await loadHistory(sid);
}
// P3 finalize:切入断线会话 → 提示自动重连中(状态保留,不打断输入——G89)
if (sidState(sid).disconnected) {
Expand All @@ -678,6 +679,78 @@ const App = (() => {
}
}

// ── 历史按需加载(rant 14:15:12:切会话恢复最近 N 条 + 滚动到顶加载更早)──
const historyPages = new Map(); // sid → { offset, hasMore, loading }
const HISTORY_PAGE = 50;

function historyPageState(sid) {
if (!historyPages.has(sid)) {
historyPages.set(sid, { offset: 0, hasMore: false, loading: false });
}
return historyPages.get(sid);
}

/** 渲染历史消息到该会话容器(只读 user 气泡,不触发工具/交互)。 */
function renderHistoryMessages(sid, messages) {
for (const m of messages || []) {
Chat.addHistoryMessage(m.preview || m.content || "", sid);
}
}

/** 加载最近一页历史(offset 从最新往回数)——切会话时调用。 */
async function loadHistory(sid) {
const st2 = historyPageState(sid);
st2.loading = true;
try {
const res = await window.emrg.listHistory({ sessionId: sid, limit: HISTORY_PAGE, offset: st2.offset });
renderHistoryMessages(sid, res.messages || []);
st2.offset += (res.messages || []).length;
st2.hasMore = !!res.hasMore;
if (st2.hasMore) {
Chat.setLoadBar(sid, _t("app.historyLoadMore"));
}
} catch (e) {
Chat.addSystemMessage(_t("app.historyFailed", { msg: e.message }), sid);
} finally {
st2.loading = false;
}
}

/** 滚动到顶 → 加载更早一页(prepend,保持滚动位置)。 */
async function loadOlderHistory(sid) {
const st2 = historyPageState(sid);
if (!st2.hasMore || st2.loading) return;
st2.loading = true;
const view = Chat.chatContainer(sid);
const prevScrollTop = view.scrollTop;
const prevHeight = view.scrollHeight;
try {
const res = await window.emrg.listHistory({ sessionId: sid, limit: HISTORY_PAGE, offset: st2.offset });
const msgs = res.messages || [];
// prepend:先清加载条,再逐条插到顶部(addHistoryMessage prepend 会插在加载条之后)
for (const m of msgs) {
Chat.addHistoryMessage(m.preview || m.content || "", sid, { prepend: true });
}
st2.offset += msgs.length;
st2.hasMore = !!res.hasMore;
if (msgs.length === 0) st2.hasMore = false;
if (st2.hasMore) {
Chat.setLoadBar(sid, _t("app.historyLoadMore"));
} else {
Chat.setLoadBar(sid, _t("app.historyNoMore"));
}
// 保持视觉位置:新内容插到顶部后滚差补偿
view.scrollTop = prevScrollTop + (view.scrollHeight - prevHeight);
} catch (e) {
Chat.addSystemMessage(_t("app.historyFailed", { msg: e.message }), sid);
} finally {
st2.loading = false;
}
}

/** 会话视图滚动:到顶且有更早 → 加载(防抖 150ms,绑定在 bindUi 内)。 */
let historyScrollTimer = null;

async function newSession(opts = {}) {
if (state.busy) {
Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy);
Expand Down Expand Up @@ -1510,6 +1583,17 @@ const App = (() => {
state.autoScroll = true;
$("back-to-bottom").classList.add("hidden");
});
// rant 14:15:12:会话视图滚动到顶 → 加载更早历史(防抖 150ms)
chatView.addEventListener("scroll", (e) => {
const t = e.target;
const isView = t && t.classList && t.classList.contains("session-view");
if (!isView || !state.sessionId) return;
const st2 = historyPageState(state.sessionId);
if (t.scrollTop <= 2 && st2.hasMore && !st2.loading) {
clearTimeout(historyScrollTimer);
historyScrollTimer = setTimeout(() => loadOlderHistory(state.sessionId), 150);
}
}, { passive: true });

// 空状态示例问题卡片 → 填入输入框
$("empty-state").addEventListener("click", (e) => {
Expand Down
34 changes: 34 additions & 0 deletions emrg/gui/renderer/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,38 @@ const Chat = (() => {
append(el("div", { class: "msg system" }, text), sid);
}

/** Read-only history user message (rant 14:15:12: restore recent history on
* session switch; not interactive). Reuses the user-bubble style plus a
* history class; prepend inserts after the load bar keeping scroll pos. */
function addHistoryMessage(text, sid, { prepend = false } = {}) {
const node = el("div", { class: "msg user history" }, text);
const cv = chatContainer(sid);
if (prepend) {
const bar = cv.querySelector(".history-load-bar");
cv.insertBefore(node, bar ? bar.nextSibling : cv.firstChild);
} else {
cv.appendChild(node);
}
App.updateEmptyState?.();
return node;
}

/** Top history load bar (rant 14:15:12): text=null removes it; otherwise
* it becomes the first child of the session container. */
function setLoadBar(sid, text) {
const cv = chatContainer(sid);
let bar = cv.querySelector(".history-load-bar");
if (text == null) {
if (bar) bar.remove();
return;
}
if (!bar) {
bar = el("div", { class: "history-load-bar" });
cv.insertBefore(bar, cv.firstChild);
}
bar.textContent = text;
}

/** 流式 delta(G122 main 已按 chunks 批量)——按会话隔离分组/已 done 集合 */
function handleDelta(chunks, sid) {
const { groupNodes, doneRids } = st(sid);
Expand Down Expand Up @@ -313,6 +345,8 @@ const Chat = (() => {
addUserMessage,
addSystemMessage,
createAssistantNode,
addHistoryMessage,
setLoadBar,
clear,
scrollToBottom,
handleDelta,
Expand Down
6 changes: 6 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@ const I18N = (() => {
"app.current": "当前",
"app.noHistory": "没有可回退的历史消息。",
"app.historyFailed": "加载历史失败:{msg}",
"app.historyLoadMore": "↑ 加载更早消息",
"app.historyLoading": "加载中…",
"app.historyNoMore": "没有更多历史",
"app.rewound": "已回退到消息点 #{index},移除了 {n} 条记录。",
"app.rewindFailed": "回退失败:{msg}",
"app.noMemories": "还没有{scope}记忆。",
Expand Down Expand Up @@ -739,6 +742,9 @@ const I18N = (() => {
"app.current": "current",
"app.noHistory": "No history points to rewind to.",
"app.historyFailed": "Failed to load history: {msg}",
"app.historyLoadMore": "↑ Load earlier messages",
"app.historyLoading": "Loading…",
"app.historyNoMore": "No more history",
"app.rewound": "Rewound to message point #{index}, removed {n} records.",
"app.rewindFailed": "Rewind failed: {msg}",
"app.noMemories": "No {scope} memories yet.",
Expand Down
37 changes: 37 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ function makeSandbox(overrides = {}) {
pickProjectDir: async () => null,
listSessions: async () => [],
switchSession: async () => ({}),
listHistory: async () => ({ messages: [], hasMore: false }),
newSession: async () => ({ session_id: "s2" }),
deleteSession: async () => ({}),
setModel: async () => ({}),
Expand Down Expand Up @@ -2628,3 +2629,39 @@ test("P3:自定义类型管理 —— 内置只读;自定义增删改走模
assert.strictEqual(createdTpl.name, "nightly");
assert.ok(createdTpl.prompt.includes("nightly"), "prompt 原样提交");
});

test("rant 14:15:12:切会话加载最近历史(只读气泡 + 加载条),滚动到顶加载更早", async () => {
const calls = [];
const { ctx, els } = makeSandbox({
switchSession: async () => ({}),
listHistory: async ({ limit, offset } = {}) => {
calls.push({ limit, offset });
if (offset === 0) return { messages: [{ content: "msg-49", preview: "msg-49" }, { content: "msg-50", preview: "msg-50" }], hasMore: true };
return { messages: [{ content: "msg-00", preview: "msg-00" }], hasMore: false };
},
});
await vm.runInContext('App.boot()', ctx);
await tick();
// 切到 s1 → 应调 listHistory(limit=50, offset=0) 并渲染 2 条历史气泡 + 加载条
await vm.runInContext('App.switchSession("s1")', ctx);
await tick();
assert.strictEqual(calls.length, 1, "切会话应加载最近一页历史");
assert.strictEqual(calls[0].limit, 50);
assert.strictEqual(calls[0].offset, 0);
const historyNodes = vm.runInContext('document.getElementById("chat-view").querySelectorAll(".history").length', ctx);
assert.strictEqual(historyNodes, 2, "应渲染 2 条只读历史气泡");
const loadBar = vm.runInContext('document.getElementById("chat-view").querySelector(".history-load-bar")', ctx);
assert.ok(loadBar, "hasMore 时应显示加载条");
// 模拟滚动到顶 → 触发加载更早(防抖 150ms;mock 的 scroll 事件需带 .session-view target)
const view = vm.runInContext('document.getElementById("chat-view").querySelector(".session-view")', ctx);
view.scrollTop = 0;
els["chat-view"].dispatch("scroll", { target: view });
await new Promise((r) => setTimeout(r, 200));
await tick();
assert.strictEqual(calls.length, 2, "滚动到顶应加载更早一页");
assert.strictEqual(calls[1].offset, 2, "第二次加载 offset=已加载数");
const historyNodes2 = vm.runInContext('document.getElementById("chat-view").querySelectorAll(".history").length', ctx);
assert.strictEqual(historyNodes2, 3, "prepend 后共 3 条历史气泡");
const noMore = vm.runInContext('document.getElementById("chat-view").querySelector(".history-load-bar")', ctx);
assert.ok(noMore, "无更多历史时加载条仍在(显示没有更多)");
});
Loading