diff --git a/Agent.md b/Agent.md index b9d35970..06f2f11d 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` (1107) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (354: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 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 + 12 daemonBridge) + `npm run build` → `renderer/dist/` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (360: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 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 + 12 daemonBridge + 6 DaemonBridgeProvider) + `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/renderer/src/App.tsx b/emrg/gui/renderer/src/App.tsx index 8fe163d8..599b1670 100644 --- a/emrg/gui/renderer/src/App.tsx +++ b/emrg/gui/renderer/src/App.tsx @@ -1,15 +1,17 @@ import { ErrorBoundary } from "./components/ErrorBoundary"; import { Shell } from "./components/Shell"; +import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider"; import { I18nProvider } from "./lib/i18n"; /** - * App — React 组件树根(Batch 0 骨架)。 + * App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。 * * 组件树与设计文档 §4.1 对齐: * * └── # 全局错误边界(React 版,替代 error-boundary.js) - * └── # i18n / theme / daemon-event context(本期:i18n) - * └── # 本期为 占位 + * └── # i18n / theme / daemon-event context + * └── # Batch 5 slice 2:daemon 事件桥 + 聊天状态机 + * └── # 本期为 占位 * * 后续批次将 Shell 逐步替换为 Sidebar / TranscriptView / ResultPanel / Dialogs; * 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。 @@ -18,7 +20,9 @@ export function App() { return ( - + + + ); diff --git a/emrg/gui/renderer/src/components/DaemonBridgeProvider.test.tsx b/emrg/gui/renderer/src/components/DaemonBridgeProvider.test.tsx new file mode 100644 index 00000000..83de93a0 --- /dev/null +++ b/emrg/gui/renderer/src/components/DaemonBridgeProvider.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { useSyncExternalStore } from "react"; +import type { ReactNode } from "react"; +import { DaemonBridgeProvider, useDaemonBridge } from "./DaemonBridgeProvider"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { I18nProvider } from "../lib/i18n"; +import { useSnapshotStore } from "../hooks/useSnapshotStore"; +import type { DaemonEventFrame } from "../lib/daemonBridge"; + +/** + * DaemonBridgeProvider.test.tsx — Batch 5 slice 2:AppProviders daemon-event + * context 层测试。模拟 preload 的 window.emrg(ipcRenderer.on 多订阅者语义): + * 验证订阅创建、事件端到端路由进共享 TranscriptStore、卸载取消订阅、 + * window.emrg 缺失降级、Provider 外 useDaemonBridge 抛错。 + */ + +/** 模拟 preload 暴露的 window.emrg(多订阅者通道,与 ipcRenderer.on 语义一致) */ +function mockEmrg() { + const listeners = new Set<(evt: DaemonEventFrame) => void>(); + const onEvent = vi.fn((cb: (evt: DaemonEventFrame) => void) => { + listeners.add(cb); + return () => listeners.delete(cb); + }); + const sendMessage = vi.fn().mockResolvedValue({ requestId: "req-9" }); + (window as unknown as { emrg?: unknown }).emrg = { onEvent, sendMessage }; + return { + listeners, + onEvent, + sendMessage, + emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)), + }; +} + +function wrapper(children: ReactNode) { + return ( + + {children} + + ); +} + +describe("DaemonBridgeProvider (Batch 5 slice 2)", () => { + afterEach(() => { + delete (window as unknown as { emrg?: unknown }).emrg; + }); + + it("在 effect 中创建桥并订阅 window.emrg.onEvent", async () => { + const m = mockEmrg(); + render(wrapper(
)); + await waitFor(() => expect(screen.getByTestId("child")).toBeInTheDocument()); + expect(m.onEvent).toHaveBeenCalledTimes(1); + }); + + it("把 daemon 事件端到端路由进共享 TranscriptStore(message_delta 双帧拼接)", async () => { + const m = mockEmrg(); + let text = ""; + function Probe() { + const { transcript } = useDaemonBridge(); + // 与 TranscriptView 相同的订阅方式:store 版本号变更触发重渲染 + useSyncExternalStore(transcript.subscribe, transcript.getVersion); + text = transcript + .getEntries("s1") + .map((e) => (e.kind === "assistant" ? e.segments.map((s) => s.text).join("") : e.kind)) + .join(","); + return
; + } + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "Hel" }] }, sid: "s1" }); + m.emit({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "lo" }] }, sid: "s1" }); + await waitFor(() => expect(text).toContain("Hello")); + }); + + it("sessions 事件更新桥 store(Shell 侧边栏数据源)", async () => { + const m = mockEmrg(); + let openSessions = 0; + function Probe() { + const { bridge } = useDaemonBridge(); + // 与 Shell 相同的订阅方式:bridge.store 快照订阅 + const appState = useSnapshotStore(bridge.store); + openSessions = appState.openSessions.length; + return
; + } + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit({ type: "open_sessions", data: { openSessions: [{ sid: "s1", projectName: "p" }] }, sid: null }); + await waitFor(() => expect(openSessions).toBe(1)); + }); + + it("window.emrg 缺失时优雅降级(不抛错,子组件可挂载)", async () => { + render( + +
+ , + ); + await waitFor(() => expect(screen.getByTestId("child")).toBeInTheDocument()); + }); + + it("Provider 之外 useDaemonBridge 抛错(被 ErrorBoundary 捕获)", () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + function Bad() { + useDaemonBridge(); + return null; + } + const { container } = render( + + + , + ); + expect(container.querySelector(".error-boundary-overlay")).toBeInTheDocument(); + errSpy.mockRestore(); + }); + + it("卸载时 dispose(取消订阅,listener 清空)", async () => { + const m = mockEmrg(); + const { unmount } = render(wrapper(
)); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + expect(m.listeners.size).toBe(1); + unmount(); + expect(m.listeners.size).toBe(0); + }); +}); diff --git a/emrg/gui/renderer/src/components/DaemonBridgeProvider.tsx b/emrg/gui/renderer/src/components/DaemonBridgeProvider.tsx new file mode 100644 index 00000000..a112b03a --- /dev/null +++ b/emrg/gui/renderer/src/components/DaemonBridgeProvider.tsx @@ -0,0 +1,81 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { + createDaemonBridge, + type DaemonBridge, + type DaemonEventFrame, + type SendMessagePayload, +} from "../lib/daemonBridge"; +import { createTranscriptStore, type TranscriptStore } from "../lib/transcript"; +import { useI18n, type TranslateFn } from "../lib/i18n"; + +/** + * DaemonBridgeProvider — Batch 5 slice 2:daemon-event context 层(设计文档 §4.1 + * AppProviders)。PR #1016 的 lib/daemonBridge.ts 是纯逻辑地基;本组件把它接进 + * React 树: + * + * - 创建 TranscriptStore(聊天区状态机,与 TranscriptView 共享同一实例)和 + * DaemonBridge(订阅 window.emrg.onEvent,发送走 window.emrg.sendMessage)。 + * - context 暴露 { bridge, transcript };`useDaemonBridge()` 消费(Shell 及后续 + * slice 的组件用它取会话列表 / 连接状态 / 聊天状态机)。 + * - 桥在 effect 中创建:StrictMode dev 双挂载时 dispose 清理第一次订阅(无泄漏), + * 生产构建 StrictMode 为 no-op,单次挂载;value 就绪前一帧渲染 null。 + * - 非 Electron 环境(window.emrg 缺失)优雅降级:onEvent 返回 no-op 取消函数、 + * sendMessage 以传入 requestId resolve —— 浏览器预览 / 单测不崩溃,store 可用。 + */ + +export interface DaemonBridgeContextValue { + bridge: DaemonBridge; + transcript: TranscriptStore; +} + +const DaemonBridgeContext = createContext(null); + +/** preload.js 暴露的 window.emrg 最小形状(仅本组件用到的两个通道) */ +interface EmrgWindow { + emrg?: { + onEvent?: (cb: (evt: DaemonEventFrame) => void) => () => void; + sendMessage?: (p: SendMessagePayload) => Promise<{ requestId?: string }>; + }; +} + +export function DaemonBridgeProvider({ children }: { children: ReactNode }) { + const { t } = useI18n(); + // t 每次渲染可能是新函数对象(无 I18nProvider 时 useI18n 回退路径每帧新建), + // 若把 t 放进 effect deps 会无限重建桥(事件处理时翻译需取最新 t)→ 用 ref 桥接: + // 桥只在挂载时创建一次(deps []),翻译始终委托给 tRef.current(最新词典)。 + const tRef = useRef(t); + tRef.current = t; + const [value, setValue] = useState(null); + + useEffect(() => { + const tt: TranslateFn = (key, params) => tRef.current(key, params); + const transcript = createTranscriptStore({ t: tt }); + const bridge = createDaemonBridge({ + onEvent: (cb) => { + const emrg = (window as unknown as EmrgWindow).emrg; + return emrg?.onEvent ? emrg.onEvent(cb) : () => {}; + }, + emrg: { + sendMessage: async (p) => { + const emrg = (window as unknown as EmrgWindow).emrg; + if (!emrg?.sendMessage) return { requestId: p.requestId }; + return emrg.sendMessage(p); + }, + }, + transcript, + t: tt, + }); + setValue({ bridge, transcript }); + return () => bridge.dispose(); + }, []); + + if (!value) return null; + return {children}; +} + +/** 消费 hook:返回 { bridge, transcript };Provider 之外调用抛错(接线错误快速暴露) */ +export function useDaemonBridge(): DaemonBridgeContextValue { + const ctx = useContext(DaemonBridgeContext); + if (!ctx) throw new Error("useDaemonBridge must be used within DaemonBridgeProvider"); + return ctx; +} diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 6c2270e3..7bb9e784 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -1,4 +1,6 @@ import { useI18n } from "../lib/i18n"; +import { useSnapshotStore } from "../hooks/useSnapshotStore"; +import { useDaemonBridge } from "./DaemonBridgeProvider"; import { Sidebar } from "./Sidebar"; import { ResultPanel } from "./ResultPanel"; import { WorkspaceView } from "./WorkspaceView"; @@ -6,11 +8,15 @@ import { WorkspaceView } from "./WorkspaceView"; /** * Shell — React 骨架占位布局(Batch 0 + Batch 3 Sidebar/ResultPanel 挂载)。 * 设计文档 §5 Batch 0 项 4:错误边界/横幅/菜单等零风险组件先就位。 - * Batch 3:左侧挂载 + 右侧挂载 (当前空数据,Batch 5 接线 daemon 事件)。 + * Batch 3:左侧挂载 + 右侧挂载 (当前空数据)。 + * Batch 5 slice 2:接入 DaemonBridgeProvider —— 侧边栏实时消费 daemon 广播的 + * 会话列表(open_sessions / sessions 事件驱动,bridge.store 快照订阅)。 * 后续批次逐个替换为真实组件:TranscriptView / Dialogs。 */ export function Shell() { const { t } = useI18n(); + const { bridge } = useDaemonBridge(); + const appState = useSnapshotStore(bridge.store); return (
@@ -21,7 +27,7 @@ export function Shell() {