diff --git a/Agent.md b/Agent.md index aae95c78..6220b427 100644 --- a/Agent.md +++ b/Agent.md @@ -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+ 周期实证的恢复路径) diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index f06aa4b6..af9ff292 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -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 窗口来源)。 @@ -678,7 +677,6 @@ class DaemonClient { _onDelta(frame) { this._touchGroup(frame.request_id, frame.session_id); - this._resetStreamTimer(frame.request_id); } _onDone(frame) { @@ -686,8 +684,8 @@ class DaemonClient { 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 保留) @@ -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); @@ -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"); diff --git a/emrg/gui/main.js b/emrg/gui/main.js index a8584676..bacef821 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -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 已有窗口)── @@ -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) { @@ -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); diff --git a/emrg/gui/nav-policy.js b/emrg/gui/nav-policy.js new file mode 100644 index 00000000..36aa9a09 --- /dev/null +++ b/emrg/gui/nav-policy.js @@ -0,0 +1,20 @@ +"use strict"; +/** + * nav-policy.js — 主窗口外部导航策略(纯函数,便于单测)。 + * + * 背景(rant 2026-09-01T20:33:44):聊天区 markdown 经 marked 渲染出的 `` + * 点击后在 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 }; diff --git a/emrg/gui/package.json b/emrg/gui/package.json index fcac4905..b8d1b9b1 100644 --- a/emrg/gui/package.json +++ b/emrg/gui/package.json @@ -50,6 +50,7 @@ "conn-manager.js", "gui-state.js", "daemon_client.js", + "nav-policy.js", "package.json", "scripts/**" ], diff --git a/emrg/gui/renderer/src/components/Composer.test.tsx b/emrg/gui/renderer/src/components/Composer.test.tsx index 6b23d5a1..7f58e20f 100644 --- a/emrg/gui/renderer/src/components/Composer.test.tsx +++ b/emrg/gui/renderer/src/components/Composer.test.tsx @@ -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 = { current: null }; + const ui = (sid: string) => ( + + + + ); + 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"); + }); +}); diff --git a/emrg/gui/renderer/src/components/Composer.tsx b/emrg/gui/renderer/src/components/Composer.tsx index ffb8c955..a39d37cc 100644 --- a/emrg/gui/renderer/src/components/Composer.tsx +++ b/emrg/gui/renderer/src/components/Composer.tsx @@ -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 void>>({}); fmtRef.current = { @@ -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; diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 88971d41..44f5988b 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -37,7 +37,9 @@ function mockEmrg() { const taskDelete = vi.fn().mockResolvedValue({ ok: true }); const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]); const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 }); - const relaunchGui = vi.fn().mockResolvedValue({ ok: true }); + const restartDaemon = vi.fn().mockResolvedValue({ ok: true }); + const setModel = vi.fn().mockResolvedValue({ ok: true }); + const triggerTask = vi.fn().mockResolvedValue({ ok: true }); const switchSession = vi.fn().mockResolvedValue({ ok: true }); (window as unknown as { emrg?: unknown }).emrg = { onEvent, @@ -55,7 +57,9 @@ function mockEmrg() { taskDelete, taskTemplateList, sendRant, - relaunchGui, + restartDaemon, + setModel, + triggerTask, switchSession, }; return { @@ -71,7 +75,9 @@ function mockEmrg() { taskDelete, taskTemplateList, sendRant, - relaunchGui, + restartDaemon, + setModel, + triggerTask, switchSession, emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)), }; @@ -453,7 +459,7 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument(); }); - it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => { + it("upgrade 事件 → 渲染升级横幅;重启按钮调 restartDaemon(完整重启链,修版本号死循环);dismiss 后同版本不重现", async () => { const m = mockEmrg(); render(wrapper()); await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); @@ -467,9 +473,10 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect(banner).toBeInTheDocument(); // from→to 文案(en 词典) expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument(); - // 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链) + // 重启按钮 → restartDaemon(rant 2026-09-01T20:09:41:完整 stop 链 + GUI relaunch, + // 否则 daemon 内存版本不更新 → 心跳持续报版本差 → 横幅死循环) fireEvent.click(screen.getByTestId("upgrade-banner-restart")); - await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(m.restartDaemon).toHaveBeenCalledTimes(1)); // dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义) fireEvent.click(screen.getByTestId("upgrade-banner-dismiss")); expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument(); @@ -479,3 +486,68 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument(); }); }); + +describe("Shell — 会话信息行 + /指令接线(rant 2026-09-01T20:16:55 / 20:22:00)", () => { + afterEach(() => { + delete (window as unknown as { emrg?: unknown }).emrg; + }); + + it("header 显示会话信息:title (sid) · project · N msgs(对齐 TUI 状态栏)", async () => { + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit(openSessionsFrame([{ sid: "s1", title: "Alpha" }, { sid: "s2", title: "Beta" }])); + m.emit({ + type: "sessions", + data: { + sessions: [ + { session_id: "s1", title: "Alpha", message_count: 5 }, + { session_id: "s2", title: "Beta", message_count: 9 }, + ], + }, + }); + await waitFor(() => expect(screen.getByTestId("session-info")).toBeInTheDocument()); + const info = screen.getByTestId("session-info"); + expect(info.textContent).toContain("Alpha"); + expect(info.textContent).toContain("(s1)"); + expect(info.textContent).toContain("5 msgs"); + }); + + it("/model → setModel 直切(对齐 TUI);无参 → 设置面板", async () => { + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit(openSessionsFrame([{ sid: "s1", title: "Alpha" }])); + await waitFor(() => expect(screen.getByTestId("composer-input")).toBeInTheDocument()); + await typeIntoComposer("/model deepseek-chat"); + const input = await screen.findByTestId("composer-input"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + await waitFor(() => expect(m.setModel).toHaveBeenCalledWith({ model: "deepseek-chat" })); + }); + + it("/trigger → triggerTask 直发(对齐 TUI)", async () => { + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit(openSessionsFrame([{ sid: "s1", title: "Alpha" }])); + await waitFor(() => expect(screen.getByTestId("composer-input")).toBeInTheDocument()); + await typeIntoComposer("/trigger evo"); + const input = await screen.findByTestId("composer-input"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + await waitFor(() => expect(m.listTasks).toHaveBeenCalled()); + }); + + it("/rant @emrg hello → sendRant 直发(对齐 TUI @project 前缀)", async () => { + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit(openSessionsFrame([{ sid: "s1", title: "Alpha" }])); + await waitFor(() => expect(screen.getByTestId("composer-input")).toBeInTheDocument()); + await typeIntoComposer("/rant @emrg hello world"); + const input = await screen.findByTestId("composer-input"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + await waitFor(() => + expect(m.sendRant).toHaveBeenCalledWith({ message: "hello world", project: "emrg" }), + ); + }); +}); diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 91e9589a..469d9799 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -15,6 +15,12 @@ import { WorkspaceView, type WorkspaceViewId } from "./WorkspaceView"; import { TranscriptView } from "./TranscriptView"; import { Composer, type CommandRouting } from "./Composer"; import { DialogHost, type DialogHostHandle } from "./DialogHost"; +import { + HISTORY_PAGE, + applyHistoryPage, + createHistoryPages, + historyPageState, +} from "../lib/history"; /** * Shell — React 布局(Batch 0 骨架 + Batch 3 Sidebar/ResultPanel/WorkspaceView @@ -61,8 +67,20 @@ interface WorkspaceBridge { taskDelete(p: { name: string }): Promise; taskTemplateList(): Promise<{ name: string }[]>; sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>; - /** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */ - relaunchGui?(): Promise; + /** + * 升级横幅重启(rant 2026-09-01T20:09:41):调 emrg:restartDaemon —— 完整 stop + * 链(TUI→daemon,--skip-gui 跳过 GUI 防自杀)→ GUI 自己 relaunch → 新 GUI + * ensureDaemon 用新安装代码 spawn 新 daemon。此前仅重启 GUI 外壳导致版本号死循环 + * (daemon 内存 _run_version 不更新 → 心跳持续报版本差 → 横幅永不消失)。 + */ + restartDaemon?(): Promise; + /** /model 直切模型(rant 2026-09-01T20:22:00:对齐 TUI,preload.js 已暴露 emrg:setModel) */ + setModel?(p: { model: string }): Promise; + /** 历史分页加载(rant 2026-09-01T20:19:40:切会话/滚动到顶;daemon list_history) */ + listHistory?(p: { sessionId: string; limit?: number; offset?: number }): Promise<{ + messages: Array<{ record_index?: number; content?: string; preview?: string; timestamp?: string }>; + hasMore?: boolean; + }>; } function wsBridge(): WorkspaceBridge | undefined { @@ -92,12 +110,38 @@ export function Shell() { // 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。 const [dismissedUpgrade, setDismissedUpgrade] = useState(null); const [restarting, setRestarting] = useState(false); + // 会话信息行(rant 2026-09-01T20:16:55:对齐 TUI 状态栏——id/name/project/消息数/busy 计时) + const [elapsed, setElapsed] = useState(0); + const busyStartRef = useRef(null); + const activeBusy = activeSid ? (appState.busyBySid[activeSid] ?? false) : false; + useEffect(() => { + if (activeBusy) { + busyStartRef.current = Date.now(); + setElapsed(0); + const iv = setInterval(() => { + const base = busyStartRef.current ?? Date.now(); + setElapsed(Math.floor((Date.now() - base) / 1000)); + }, 500); + return () => clearInterval(iv); + } + busyStartRef.current = null; + setElapsed(0); + }, [activeBusy]); + const activeSessionEntry = appState.openSessions.find((o) => o.sid === activeSid); + const activeKnown = appState.sessions.find((s) => s.session_id === activeSid); + const activeTitle = activeSessionEntry?.title || activeKnown?.title || t("app.unnamed"); + const activeProject = activeSessionEntry?.projectName || ""; + const activeMsgCount = (activeKnown as { message_count?: number } | undefined)?.message_count ?? 0; + const mmss = `${String(Math.floor(elapsed / 60)).padStart(2, "0")}:${String(elapsed % 60).padStart(2, "0")}`; // open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义) useEffect(() => { if (activeSid === null && appState.openSessions.length > 0) { - setActiveSid(appState.openSessions[0].sid); + const sid = appState.openSessions[0].sid; + setActiveSid(sid); + void loadHistory(sid); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [appState.openSessions, activeSid]); // ── workspace 面板数据加载(vanilla loadTaskMeta/renderRantList 语义) ── @@ -205,6 +249,7 @@ export function Shell() { if (b?.switchSession) await b.switchSession({ sessionId: sid }); setActiveSid(sid); setActiveView("sessions"); + void loadHistory(sid); } /** 任务面板「打开会话」:switchSession 到任务所属会话(vanilla #924 行为迁移) */ @@ -216,6 +261,7 @@ export function Shell() { await b.switchSession({ sessionId: task.session_id, projectPath: task.project_path }); setActiveSid(task.session_id); setActiveView("sessions"); + void loadHistory(task.session_id); } catch (e) { transcript.addSystemMessage( t("openSession.loadFailed", { msg: (e as Error)?.message ?? String(e) }), @@ -332,6 +378,83 @@ export function Shell() { function selectSession(sid: string | null): void { setActiveSid(sid); setActiveView("sessions"); + if (sid) void loadHistory(sid); + } + + // ── 历史按需加载(rant 2026-09-01T20:19:40:GUI 打开会话不显示历史——链路从未接线)── + // vanilla historyPages 移植:切会话加载最近一页(limit 50,offset 从最新往回数), + // 滚动到顶加载更早一页。TranscriptView 滚动监听已在(capture),这里补加载函数 + + // canLoadOlder/onLoadOlder 接线。historyLoaded 集合防重复加载(切换走/回来不重复 append)。 + const historyPagesRef = useRef>(createHistoryPages()); + const historyLoadedRef = useRef>(new Set()); + const historyTimerRef = useRef | null>(null); + + /** 切会话:加载最近一页历史(用户消息气泡,只读;vanilla loadHistory)。 */ + async function loadHistory(sid: string) { + if (historyLoadedRef.current.has(sid)) return; + const b = wsBridge(); + if (!b?.listHistory) return; + const st = historyPageState(historyPagesRef.current, sid); + if (st.loading) return; + st.loading = true; + try { + const res = await b.listHistory({ sessionId: sid, limit: HISTORY_PAGE, offset: st.offset }); + const msgs = res.messages || []; + for (const m of msgs) { + transcript.addHistoryMessage((m as { preview?: string; content?: string }).preview + || (m as { preview?: string; content?: string }).content + || "", sid); + } + applyHistoryPage(st, msgs.length, !!res.hasMore); + transcript.setLoadBar(st.hasMore ? t("app.historyLoadMore") : null, sid); + historyLoadedRef.current.add(sid); + } catch (e) { + transcript.addSystemMessage( + t("app.historyFailed", { msg: (e as Error)?.message ?? String(e) }), + sid, + ); + } finally { + st.loading = false; + } + } + + /** 滚动到顶:加载更早一页(prepend + 滚差补偿由渲染层处理;vanilla loadOlderHistory)。 */ + async function loadOlderHistory(sid: string | null) { + if (!sid) return; + const b = wsBridge(); + if (!b?.listHistory) return; + const st = historyPageState(historyPagesRef.current, sid); + if (!st.hasMore || st.loading) return; + st.loading = true; + try { + const res = await b.listHistory({ sessionId: sid, limit: HISTORY_PAGE, offset: st.offset }); + const msgs = res.messages || []; + // prepend:倒序 unshift 保持时间序(vanilla insertAfter(bar) 同序插入会页内倒序—— + // React 版修正为逐条 unshift,倒序迭代使页底 = 上一页顶部,无缝衔接) + for (const m of [...msgs].reverse()) { + transcript.prependHistoryMessage( + (m as { preview?: string; content?: string }).preview + || (m as { preview?: string; content?: string }).content + || "", + sid, + ); + } + applyHistoryPage(st, msgs.length, !!res.hasMore); + transcript.setLoadBar(st.hasMore ? t("app.historyLoadMore") : t("app.historyNoMore"), sid); + } catch (e) { + transcript.addSystemMessage( + t("app.historyFailed", { msg: (e as Error)?.message ?? String(e) }), + sid, + ); + } finally { + st.loading = false; + } + } + + /** 滚动到顶触发(TranscriptView onScroll → 这里防抖 150ms,vanilla 同)。 */ + function onScrollTop() { + if (historyTimerRef.current) clearTimeout(historyTimerRef.current); + historyTimerRef.current = setTimeout(() => void loadOlderHistory(activeSid), 150); } const isPanelView = activeView !== "sessions"; @@ -376,20 +499,87 @@ export function Shell() { case "/image": void h.runDirect(routing.cmd, routing.args ?? []); break; + // rant 2026-09-01T20:22:00:/model /rant /trigger 接线(此前落 cmdUnknown) + case "/model": + void handleModelCommand(routing.args ?? []); + break; + case "/rant": + void handleRantCommand(routing.args ?? []); + break; + case "/trigger": + void handleTriggerCommand(routing.args ?? []); + break; default: - // /model /rant /trigger — 待 workspace/settings slice 接线 transcript.addSystemMessage(t("app.cmdUnknown", { cmd: routing.cmd }), activeSid); } } - /** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon —— - * 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */ + /** /model:有参 → 直切模型(对齐 TUI /model );无参 → 设置面板。 */ + async function handleModelCommand(args: string[]) { + const name = args[0]?.trim(); + if (name) { + const b = wsBridge(); + if (!b?.setModel) return; + try { + await b.setModel({ model: name }); + transcript.addSystemMessage(t("app.modelSwitched", { model: name }), activeSid); + } catch (e) { + transcript.addSystemMessage( + t("app.modelSwitchFailed", { msg: (e as Error)?.message ?? String(e) }), + activeSid, + ); + } + } else { + switchView("settings"); + } + } + + /** /rant:有参 → 直发(/@project 前缀指定项目,对齐 TUI);无参/空内容 → 对话框。 */ + async function handleRantCommand(args: string[]) { + const first = args[0] ?? ""; + let project = ""; + let message = args.join(" "); + if (first.startsWith("@")) { + project = first.slice(1); + message = args.slice(1).join(" "); + } + if (!message.trim()) { + await newRant(); + return; + } + await submitRant({ message, project }); + } + + /** /trigger:有参 → 直发任务;无参 → 任务面板。 */ + async function handleTriggerCommand(args: string[]) { + const name = args[0]?.trim(); + if (name) { + const b = wsBridge(); + if (!b?.triggerTask) return; + try { + await b.triggerTask({ name }); + transcript.addSystemMessage(t("app.triggered", { n: name }), activeSid); + await loadTasks(); + } catch (e) { + transcript.addSystemMessage( + t("app.triggerFailed", { msg: (e as Error)?.message ?? String(e) }), + activeSid, + ); + } + } else { + switchView("tasks"); + } + } + + /** 升级横幅重启(rant 2026-09-01T20:09:41):调 emrg:restartDaemon 完整重启链 + * (stop_all --skip-gui → GUI relaunch → 新 daemon)。TUI 会被 stop 链杀掉, + * 用户需手动重开(设计如此)。失败 → 系统消息。 */ async function handleUpgradeRestart() { const b = wsBridge(); - if (!b?.relaunchGui) return; + if (!b?.restartDaemon) return; setRestarting(true); try { - await b.relaunchGui(); + await b.restartDaemon(); // 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡) } catch (e) { setRestarting(false); @@ -437,6 +627,15 @@ export function Shell() { ) : null}
✦ EMRG + {activeSid ? ( + + {activeTitle} ({activeSid}) + {activeProject ? ` · ${activeProject}` : ""} + {" · "} + {t("app.msgCount", { count: activeMsgCount })} + {activeBusy ? ` [${mmss}]` : ""} + + ) : null} {appState.connected ? appState.model : t("copy.disconnected")} @@ -485,7 +684,20 @@ export function Shell() { {t("app.sessionDisconnected")} ) : null} - + { + const hs = historyPageState(historyPagesRef.current, activeSid); + return hs.hasMore && !hs.loading; + })() + : false + } + onLoadOlder={onScrollTop} + /> )} diff --git a/emrg/gui/renderer/src/components/TranscriptView.tsx b/emrg/gui/renderer/src/components/TranscriptView.tsx index dbbae290..0187bc7b 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.tsx @@ -33,9 +33,13 @@ export interface TranscriptViewProps { sid?: string | null; /** 注入 markdown 渲染器(测试/浏览器接线用;缺省按 t 构造降级渲染器) */ renderer?: MarkdownRenderer; + /** 滚动到顶加载更早历史(rant 2026-09-01T20:19:40):hasMore && !loading 时允许触发 */ + canLoadOlder?: boolean; + /** 滚动到顶回调(触发方防抖;vanilla loadOlderHistory 语义) */ + onLoadOlder?: () => void; } -export function TranscriptView({ store, sid = null, renderer }: TranscriptViewProps) { +export function TranscriptView({ store, sid = null, renderer, canLoadOlder = false, onLoadOlder }: TranscriptViewProps) { // 版本号快照:每次 store 变更 +1(getSnapshot 稳定引用,满足 useSyncExternalStore 要求) const version = useSyncExternalStore(store.subscribe, store.getVersion); const { t } = useI18n(); @@ -64,10 +68,17 @@ export function TranscriptView({ store, sid = null, renderer }: TranscriptViewPr useEffect(() => { const el = viewportRef.current; if (!el) return; - const onScroll = () => updateAutoScroll(el); + const onScroll = () => { + updateAutoScroll(el); + // 滚动到顶 → 加载更早一页(rant 2026-09-01T20:19:40:scrollTop<=2 && canLoadOlder; + // 防抖由 onLoadOlder 触发方负责) + if (onLoadOlder && canLoadOlder && el.scrollTop <= 2) { + onLoadOlder(); + } + }; el.addEventListener("scroll", onScroll, { capture: true }); return () => el.removeEventListener("scroll", onScroll, { capture: true }); - }, [updateAutoScroll]); + }, [updateAutoScroll, canLoadOlder, onLoadOlder]); // 新消息 append/流入 → 若 autoScroll 为 true 则滚到底。 // 依赖 version(每次 store 变更 +1)而非 entries.length:流式文本是原地 append diff --git a/emrg/gui/renderer/src/lib/i18n-dicts.ts b/emrg/gui/renderer/src/lib/i18n-dicts.ts index 8b1cbb64..e9802681 100644 --- a/emrg/gui/renderer/src/lib/i18n-dicts.ts +++ b/emrg/gui/renderer/src/lib/i18n-dicts.ts @@ -286,7 +286,6 @@ export const ZH_DICT: Record = { "chat.copyCode": "复制", "chat.copied": "已复制 ✓", "chat.copyFailed": "复制失败", - "chat.timeoutWarn": "⚠️ 响应超时了,我还没说完——可以再试一次。", "chat.maxRoundsHint": "⚠️ 这次任务因为工具调用次数上限中断了,结果可能不完整——输入“继续”我可以接着做。", "chat.elapsed": "耗时 {s}", "chat.expand": "展开全文", @@ -328,6 +327,7 @@ export const ZH_DICT: Record = { "app.imagePaste": "请直接粘贴图片到输入框(Ctrl+V / ⌘V)。", "app.cmdFailed": "指令 {cmd} 执行失败:{msg}", "app.unnamed": "(未命名)", + "app.msgCount": "{count} 条消息", "app.current": "当前", "app.noHistory": "没有可回退的历史消息。", "app.historyFailed": "加载历史失败:{msg}", @@ -381,6 +381,7 @@ export const ZH_DICT: Record = { "app.noModels": "还没有配置模型", "app.goSettings": "去设置添加", "app.modelSwitchFailed": "切换模型失败了:{msg}", + "app.modelSwitched": "已切换到模型 {model}。", "app.modelListFailed": "读取模型列表失败了:{msg}", "app.upgradeBannerMsg": "EMRG 已升级到 {version},重启后生效", "app.upgradeBannerMsgFromTo": "EMRG 已从 {from} 升级到 {to},重启后生效", @@ -682,7 +683,6 @@ export const EN_DICT: Record = { "chat.copyCode": "Copy", "chat.copied": "Copied ✓", "chat.copyFailed": "Copy failed", - "chat.timeoutWarn": "⚠️ Response timed out — I wasn't done. Try again?", "chat.maxRoundsHint": "⚠️ This task hit the tool-call limit and may be incomplete — type \"continue\" and I'll keep going.", "chat.elapsed": "took {s}", "chat.expand": "Expand full text", @@ -724,6 +724,7 @@ export const EN_DICT: Record = { "app.imagePaste": "Paste the image into the input (Ctrl+V / ⌘V).", "app.cmdFailed": "Command {cmd} failed: {msg}", "app.unnamed": "(untitled)", + "app.msgCount": "{count} msgs", "app.current": "current", "app.noHistory": "No history points to rewind to.", "app.historyFailed": "Failed to load history: {msg}", @@ -777,6 +778,7 @@ export const EN_DICT: Record = { "app.noModels": "No models configured", "app.goSettings": "Add in Settings", "app.modelSwitchFailed": "Failed to switch model: {msg}", + "app.modelSwitched": "Switched to model {model}.", "app.modelListFailed": "Failed to load models: {msg}", "app.upgradeBannerMsg": "EMRG upgraded to {version} — restart to apply", "app.upgradeBannerMsgFromTo": "EMRG upgraded from {from} to {to} — restart to apply", diff --git a/emrg/gui/renderer/src/lib/i18n.test.ts b/emrg/gui/renderer/src/lib/i18n.test.ts index c5547966..bc26572f 100644 --- a/emrg/gui/renderer/src/lib/i18n.test.ts +++ b/emrg/gui/renderer/src/lib/i18n.test.ts @@ -73,11 +73,11 @@ describe("getLocale / setLocale(localStorage 覆盖)", () => { }); describe("词典完整性守卫(防漂移)", () => { - it("zh/en 各 392 个 key 且完全对齐", () => { + it("zh/en 各 393 个 key 且完全对齐", () => { const zhKeys = Object.keys(ZH_DICT); const enKeys = Object.keys(EN_DICT); - expect(zhKeys.length).toBe(392); - expect(enKeys.length).toBe(392); + expect(zhKeys.length).toBe(393); + expect(enKeys.length).toBe(393); expect(zhKeys.sort()).toEqual(enKeys.sort()); // DICTS 聚合结构 expect(Object.keys(DICTS)).toEqual(["zh", "en"]); diff --git a/emrg/gui/renderer/src/lib/transcript.test.ts b/emrg/gui/renderer/src/lib/transcript.test.ts index 9d986fa5..de705823 100644 --- a/emrg/gui/renderer/src/lib/transcript.test.ts +++ b/emrg/gui/renderer/src/lib/transcript.test.ts @@ -189,13 +189,6 @@ describe("done 收尾", () => { expect(s.getEntries("s1").filter((x) => x.kind === "assistant")).toHaveLength(1); }); - it("timeout → 系统消息(经注入 t 解析)", () => { - const s = store(); - s.handleDone({ request_id: "r1", timeout: true }, "s1"); - const entries = s.getEntries("s1"); - expect(entries.some((e) => e.kind === "system" && e.text === "chat.timeoutWarn")).toBe(true); - }); - it("maxRounds 截断 → 系统提示", () => { const s = store(); s.handleDone({ request_id: "r1", content: "exceeded max tool rounds" }, "s1"); diff --git a/emrg/gui/renderer/src/lib/transcript.ts b/emrg/gui/renderer/src/lib/transcript.ts index baa82380..9b8937fe 100644 --- a/emrg/gui/renderer/src/lib/transcript.ts +++ b/emrg/gui/renderer/src/lib/transcript.ts @@ -99,6 +99,8 @@ export interface SessionTranscript { entries: TranscriptEntry[]; /** 顶部历史加载条(rant 14:15:12;text=null 移除) */ loadBar: string | null; + /** 输入框草稿(rant 2026-09-01T20:28:31:按 sid 隔离,切视图/切会话不丢) */ + draft: string; /** rid → entries 下标(助手条目映射;done 后删除,迟到 delta 由 doneRids 拦截) */ groupIndex: Map; /** callId → { entry: 条目下标, row: 组内行号(独立行 = null) } */ @@ -154,6 +156,9 @@ export interface TranscriptStore { /** 更早一页历史 prepend 到顶部(vanilla addHistoryMessage prepend 语义;loadBar 独立字段渲染在上方) */ prependHistoryMessage(text: string, sid?: string | null): void; setLoadBar(text: string | null, sid?: string | null): void; + /** 输入框草稿读写(rant 2026-09-01T20:28:31:按 sid 隔离,切视图/切会话不丢) */ + getComposerDraft(sid?: string | null): string; + setComposerDraft(text: string, sid?: string | null): void; toggleRowOutput(sid: string | null, callId: string): void; expandRowContent(sid: string | null, callId: string): void; toggleGroup(sid: string | null, entryIndex: number): void; @@ -205,6 +210,7 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip sid: sid || null, entries: [], loadBar: null, + draft: "", groupIndex: new Map(), toolRowIndex: new Map(), doneRids: new Set(), @@ -292,9 +298,6 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip s.groupIndex.delete(rid); // 渲染完成 → 移除映射(chat.js groupNodes.delete) } } - if (data.timeout) { - s.entries.push({ kind: "system", text: t("chat.timeoutWarn") }); - } // 工具调用次数上限中断(跨项目教训:截断的工作不提示 = 用户拿半成品) if (data.content && /exceeded/i.test(data.content) && /max|limit|round/i.test(data.content)) { s.entries.push({ kind: "system", text: t("chat.maxRoundsHint") }); @@ -421,6 +424,7 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip st, getEntries: (sid) => st(sid).entries, getLoadBar: (sid) => st(sid).loadBar, + getComposerDraft: (sid) => st(sid).draft, setOwnStream: (rid) => { mutate(() => { ownStreamRequestId = rid; @@ -457,6 +461,11 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip st(sid).loadBar = text; }); }, + setComposerDraft: (text, sid) => { + mutate(() => { + st(sid).draft = text; + }); + }, toggleRowOutput: (sid, callId) => { mutate(() => { const found = findRow(st(sid), callId); diff --git a/emrg/gui/renderer/src/shell.css b/emrg/gui/renderer/src/shell.css index 5c44c843..dd2a4c2d 100644 --- a/emrg/gui/renderer/src/shell.css +++ b/emrg/gui/renderer/src/shell.css @@ -231,6 +231,17 @@ opacity: 0.75; } +.react-shell-session { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + .conn-dot { width: 8px; height: 8px; diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index ba7db382..a44e36f8 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -577,22 +577,18 @@ test("ws close → disconnected 事件 + pending reject(G89/G90)", async () assert.strictEqual(client.connected, false); }); -test("断连清 _currentStream + G94 timer(#338 回归:不弹虚假超时)", async () => { +test("断连清 _currentStream(#338 回归:断连后无虚假 done)", async () => { const client = new DaemonClient(); await connectClient(client); // 发起任务 → _currentStream 建立 const rid = client.sendTask({ sessionId: "s_260803_1730_abcd1234", cwd: tmpHome, prompt: "hi" }); assert.ok(client._currentStream, "_currentStream 已建立"); assert.ok(client._currentStream.requestId === rid); - // 收到 delta → _resetStreamTimer 挂起 G94 30s timer - currentMockWs.emit("message", Buffer.from(JSON.stringify({ request_id: rid, session_id: "s_260803_1730_abcd1234", content: "hi", delta: true }))); - assert.ok(client._currentStream.timer, "G94 timer 已挂起(delta 后)"); const doneEvents = []; client.onEvent((t, d) => { if (t === "done") doneEvents.push(d); }); - // 断连 → clearActiveStream 清 timer + _currentStream(#338) + // 断连 → clearActiveStream 清 _currentStream(#338) currentMockWs.emit("close"); assert.strictEqual(client._currentStream, null, "断连后 _currentStream 被清(#338)"); - // 等 40ms(远小于 30s timer)——若 timer 未被清,这里不可能触发;验证无虚假 done await new Promise((r) => setTimeout(r, 40)); assert.deepStrictEqual(doneEvents, [], "断连后无虚假 done 事件"); }); @@ -993,15 +989,6 @@ test("P2 ownStream: 自有 done(request 匹配)→ 释放锁;广播 done assert.strictEqual(client.ownStreamRequestId, null); }); -test("P2 ownStream: timeout 兜底 done(无匹配 request)→ 释放锁", async () => { - const client = new DaemonClient(); - await connectClient(client); - client.sendTask({ sessionId: "s_260803_1730_abcd1234", cwd: "/proj", prompt: "hi", requestId: "req-own-3" }); - const send = (obj) => currentMockWs.emit("message", Buffer.from(JSON.stringify(obj))); - send({ request_id: "req-stale", done: true, delta: false, timeout: true }); - assert.strictEqual(client.ownStream, false, "timeout done must release own lock"); -}); - test("P2 ownStream: session busy 即发 error → 释放锁(防 G65 锁泄漏)", async () => { const client = new DaemonClient(); await connectClient(client); diff --git a/emrg/gui/test/nav-policy.test.js b/emrg/gui/test/nav-policy.test.js new file mode 100644 index 00000000..b9e7eea2 --- /dev/null +++ b/emrg/gui/test/nav-policy.test.js @@ -0,0 +1,29 @@ +"use strict"; +/** + * nav-policy.test.js — 主窗口外部导航策略单测(rant 2026-09-01T20:33:44)。 + */ +const { test } = require("node:test"); +const assert = require("node:assert"); +const { externalNavPolicy } = require("../nav-policy"); + +test("file:// URLs are allowed (loadFile / reload / SPA self-navigation)", () => { + assert.strictEqual(externalNavPolicy("file:///Users/x/emrg/renderer/dist/index.html"), "allow"); + assert.strictEqual(externalNavPolicy("file:///"), "allow"); +}); + +test("http(s) external URLs are opened in the system browser", () => { + assert.strictEqual(externalNavPolicy("https://github.com/argszero/emrg"), "open-external"); + assert.strictEqual(externalNavPolicy("http://example.com/a?b=c#d"), "open-external"); +}); + +test("non-file schemes (mailto, javascript, data) are treated as external / not internal", () => { + assert.strictEqual(externalNavPolicy("mailto:x@y.com"), "open-external"); + assert.strictEqual(externalNavPolicy("javascript:alert(1)"), "open-external"); + assert.strictEqual(externalNavPolicy("data:text/html,hi"), "open-external"); +}); + +test("empty / non-string URLs are denied", () => { + assert.strictEqual(externalNavPolicy(""), "deny"); + assert.strictEqual(externalNavPolicy(undefined), "deny"); + assert.strictEqual(externalNavPolicy(null), "deny"); +});