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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg

Python: `uv run pytest tests/ -v` (1213) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (95: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api + 4 boot-contract + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (480: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 17 transcript + 10 TranscriptView + 15 history + 22 composer + 34 Composer + 6 LinkDialog + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (487: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 16 transcript + 10 TranscriptView + 15 history + 22 composer + 38 Composer + 6 LinkDialog + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
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 路径不受影响)
Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径)
Expand Down
21 changes: 3 additions & 18 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ const MAX_PAYLOAD = 16 * 1024 * 1024; // G62/G105:16MB 双向一致(工具
const AUTH_TIMEOUT_MS = 10_000;
const SPAWN_WAIT_MS = 5_000;
const PENDING_TIMEOUT_MS = 5_000;
const STREAM_END_TIMEOUT_MS = 30_000; // G94:最后帧后 30s 无 done 强制结束
// Rant 2026-08-09T13:16:36 ⑤(防风暴总闸):单个"连接生命周期"内最多 spawn
// MAX_SPAWN_ATTEMPTS 次 daemon——之后不再拉起,只把真实错误(含 emrgd.log 尾部)
// 抛给上层,杜绝 GUI 每 5s 反复 spawn(每次 spawn 都是一个新的 cmd 窗口来源)。
Expand Down Expand Up @@ -678,16 +677,15 @@ class DaemonClient {

_onDelta(frame) {
this._touchGroup(frame.request_id, frame.session_id);
this._resetStreamTimer(frame.request_id);
}

_onDone(frame) {
const rid = frame.request_id;
if (rid && this._currentStream && this._currentStream.requestId === rid) {
this.clearActiveStream();
}
// G65:仅自有流的 done 释放锁(广播 done 不影响);timeout 兜底同样只清自有
if (rid === this.ownStreamRequestId || (frame.timeout && this.ownStream)) {
// G65:仅自有流的 done 释放锁(广播 done 不影响)
if (rid === this.ownStreamRequestId) {
this._releaseOwnStream();
}
// G83:done 清理分组缓存(DOM 保留)
Expand Down Expand Up @@ -739,19 +737,6 @@ class DaemonClient {
this._currentStream = { requestId };
}

_resetStreamTimer(requestId) {
if (this._currentStream && this._currentStream.requestId === requestId) {
if (this._currentStream.timer) clearTimeout(this._currentStream.timer);
this._currentStream.timer = setTimeout(() => {
// G94:最后帧后 30s 无 done → 强制结束
this.logger.warn("[gui] stream end timeout — forcing done");
this._emit("done", { request_id: requestId, content: "", done: true, delta: false, timeout: true });
this.clearActiveStream();
}, STREAM_END_TIMEOUT_MS);
this._currentStream.timer.unref?.();
}
}

clearActiveStream() {
if (this._currentStream) {
if (this._currentStream.timer) clearTimeout(this._currentStream.timer);
Expand All @@ -765,7 +750,7 @@ class DaemonClient {
this.connected = false;
this._releaseOwnStream(); // 断连即释放 G65 自有流锁(防锁泄漏)
this._rejectAllPending("connection closed");
this.clearActiveStream(); // G94 timer 清理:断连后 30s 超时 timer 不应再触发(防虚假"响应超时"提示)
this.clearActiveStream(); // 清理当前流缓存
this.clearGroups(); // G97:断连清空广播分组缓存(含 10 分钟 timer),防"幽灵"分组残留
this._emit("disconnected", {});
this.logger.info("[gui] daemon connection closed");
Expand Down
27 changes: 23 additions & 4 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const { parse: parseToml, stringify: stringifyToml } = require("smol-toml");
const { generateSessionId, SESSION_ID_RE } = require("./daemon_client");
const { ConnManager } = require("./conn-manager");
const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("./gui-state");
const { externalNavPolicy } = require("./nav-policy");
const APP_VERSION = require("./package.json").version;

// ── 单实例锁(G85/G120:第二个实例退出并 focus 已有窗口)──
Expand Down Expand Up @@ -123,6 +124,23 @@ function main() {
win.webContents.on("unresponsive", () => {
logger.warn("[gui] renderer unresponsive");
});
// Rant 2026-09-01T20:33:44:主窗口导航拦截——聊天区链接点击不得把应用导航成网页。
// file:// 放行(loadFile/reload),其余外链一律交系统浏览器(shell.openExternal)。
win.webContents.on("will-navigate", (event, url) => {
const policy = externalNavPolicy(url);
if (policy === "allow") return;
event.preventDefault();
if (policy === "open-external") {
shell.openExternal(url).catch((e) => logger.warn(`[gui] openExternal failed: ${e.message}`));
}
});
// 防 target=_blank / window.open(与 will-navigate 互补)
win.webContents.setWindowOpenHandler(({ url }) => {
if (externalNavPolicy(url) === "open-external") {
shell.openExternal(url).catch((e) => logger.warn(`[gui] openExternal failed: ${e.message}`));
}
return { action: "deny" };
});
}

function restoreWindowBounds(w) {
Expand Down Expand Up @@ -382,10 +400,11 @@ vision = false
return { ok: true };
});

// 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰
// daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的
// restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon
// 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。
// 升级横幅重启(rant 2026-08-27T21:54:51 旧决策 → 2026-09-01T20:09:41 更正):
// 旧实现仅重启 GUI 进程本身(relaunchGui),绝不触碰 daemon —— 但 daemon 内存
// _run_version 不更新 → 心跳持续报 installed≠current → 横幅死循环、版本显示
// 永远落后。更正:升级横幅按钮改调 emrg:restartDaemon(完整 stop 链 + GUI relaunch
// + 新 daemon)。relaunchGui 保留仅为其他"仅重启外壳"场景(无版本更新诉求)。
ipcMain.handle("emrg:relaunchGui", async () => {
app.relaunch();
app.exit(0);
Expand Down
20 changes: 20 additions & 0 deletions emrg/gui/nav-policy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use strict";
/**
* nav-policy.js — 主窗口外部导航策略(纯函数,便于单测)。
*
* 背景(rant 2026-09-01T20:33:44):聊天区 markdown 经 marked 渲染出的 `<a href>`
* 点击后在 Electron 主窗口内导航,整个应用变成网页、GUI 功能全部消失。修复 = 主窗口
* 全局拦截 will-navigate + setWindowOpenHandler,外部 URL 一律交给系统浏览器。
*
* 策略:
* - "allow" → file:// 内部导航放行(loadFile / reload / SPA 自身)
* - "open-external" → 拦截并交给 shell.openExternal(系统浏览器)
* - "deny" → 拦截且不打开(空/非法 URL)
*/
function externalNavPolicy(url) {
if (typeof url !== "string" || url.length === 0) return "deny";
if (url.startsWith("file:")) return "allow";
return "open-external";
}

module.exports = { externalNavPolicy };
1 change: 1 addition & 0 deletions emrg/gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"conn-manager.js",
"gui-state.js",
"daemon_client.js",
"nav-policy.js",
"package.json",
"scripts/**"
],
Expand Down
64 changes: 64 additions & 0 deletions emrg/gui/renderer/src/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -594,3 +594,67 @@ describe("Composer — 格式栏与快捷键(Stage 2, rant 14:07:29)", () =>
expect(sent[0].sandbox).toBe("danger-full-access");
});
});

describe("Composer — 草稿持久化(rant 2026-09-01T20:28:31)", () => {
it("输入 → 草稿实时写入 store(onUpdate 序列化 markdown)", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("hello draft");
});
await waitFor(() => expect(store.getComposerDraft("s1")).toContain("hello draft"));
});

it("卸载重挂载 → 草稿恢复(onCreate setContent 回填)", async () => {
const store = createTranscriptStore();
const s = setup(store);
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("**bold** draft");
});
await waitFor(() => expect(store.getComposerDraft("s1")).toContain("bold"));
s.unmount();
const s2 = setup(store);
const editor2 = await waitEditor(s2);
await waitFor(() => expect(editor2.getText()).toContain("draft"));
});

it("发送成功 → 草稿清空(clearContent → onUpdate 空内容)", async () => {
const store = createTranscriptStore();
const sendMessage = vi.fn().mockResolvedValue({ requestId: "r1" });
const s = setup(store, { sendMessage });
const editor = await waitEditor(s);
act(() => {
editor.commands.insertContent("send me");
});
await waitFor(() => expect(store.getComposerDraft("s1")).toContain("send me"));
s.press("Enter");
await waitFor(() => expect(sendMessage).toHaveBeenCalled());
await waitFor(() => expect(store.getComposerDraft("s1")).toBe(""));
});

it("会话切换(同挂载 prop 变化)→ 旧会话草稿保留 + 新会话草稿载入", async () => {
const store = createTranscriptStore();
const editorRef: MutableRefObject<Editor | null> = { current: null };
const ui = (sid: string) => (
<I18nProvider lang="zh">
<Composer store={store} sid={sid} sandbox="workspace-write" editorRef={editorRef} />
</I18nProvider>
);
const s = render(ui("s1"));
await waitFor(() => expect(editorRef.current).not.toBeNull());
act(() => {
editorRef.current!.commands.insertContent("draft for s1");
});
await waitFor(() => expect(store.getComposerDraft("s1")).toContain("draft for s1"));
// 预置 s2 草稿(模拟之前在该会话输入过)
act(() => {
store.setComposerDraft("draft for s2", "s2");
});
s.rerender(ui("s2"));
await waitFor(() => expect(editorRef.current!.getText()).toContain("draft for s2"));
// 旧会话草稿仍在 store(切回 s1 可恢复)
expect(store.getComposerDraft("s1")).toContain("draft for s1");
});
});
35 changes: 35 additions & 0 deletions emrg/gui/renderer/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ export function Composer({
menuRef.current = menu;
const tRef = useRef(t);
tRef.current = t;
// 草稿读写(rant 2026-09-01T20:28:31):useEditor 闭包只创建一次 → store/sid 走 ref 桥接
const storeRef = useRef(store);
storeRef.current = store;
const sidRef = useRef(sid);
sidRef.current = sid;
// 格式命令 ref 桥接(editor/菜单闭包只创建一次 → 变化值走 ref)
const fmtRef = useRef<Record<string, () => void>>({});
fmtRef.current = {
Expand Down Expand Up @@ -229,13 +234,43 @@ export function Composer({
// / 指令补全:以 / 开头且无空格(仍处于指令词)→ 弹出菜单
if (trimmed.startsWith("/") && !trimmed.includes(" ")) setMenu(menuForPrefix(trimmed, tRef.current));
else setMenu(CMD_MENU_CLOSED);
// 草稿持久化(rant 2026-09-01T20:28:31):实时写 store(markdown 序列化),
// 切视图(组件卸载)不丢;发送后 clearContent → onUpdate 空内容 → 草稿自动清空
const md = (editor.storage as unknown as { markdown: { getMarkdown(): string } }).markdown.getMarkdown();
storeRef.current.setComposerDraft(md, sidRef.current);
},
onCreate: ({ editor }) => {
setHasText(!editor.isEmpty);
// 草稿恢复(rant 2026-09-01T20:28:31):重挂载后经 tiptap-markdown setContent 解析回填
const d = storeRef.current.getComposerDraft(sidRef.current);
if (d) {
editor.commands.setContent(d);
setHasText(true);
}
},
immediatelyRender: false,
});

// 会话切换:保存旧会话草稿 → 载入新会话草稿(组件不卸载,editor 复用)
const prevSidRef = useRef(sid);
useEffect(() => {
const prev = prevSidRef.current;
if (prev === sid || !editor) return;
if (prev != null) {
const md = (editor.storage as unknown as { markdown: { getMarkdown(): string } }).markdown.getMarkdown();
storeRef.current.setComposerDraft(md, prev);
}
const d = sid != null ? storeRef.current.getComposerDraft(sid) : "";
if (d) {
editor.commands.setContent(d);
setHasText(true);
} else {
editor.commands.clearContent();
setHasText(false);
}
prevSidRef.current = sid;
}, [sid, editor, store]);

// 测试注入:editor 实例回填
useEffect(() => {
if (editorRef) editorRef.current = editor;
Expand Down
Loading
Loading