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 numberDiff line numberDiff 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` (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+ 周期实证的恢复路径)
Expand Down
12 changes: 8 additions & 4 deletions emrg/gui/renderer/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -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 对齐:
* <App>
* └── <ErrorBoundary> # 全局错误边界(React 版,替代 error-boundary.js)
* └── <AppProviders> # i18n / theme / daemon-event context(本期:i18n)
* └── <Layout> # 本期为 <Shell> 占位
* └── <AppProviders> # i18n / theme / daemon-event context
* └── <DaemonBridgeProvider> # Batch 5 slice 2:daemon 事件桥 + 聊天状态机
* └── <Layout> # 本期为 <Shell> 占位
*
* 后续批次将 Shell 逐步替换为 Sidebar / TranscriptView / ResultPanel / Dialogs;
* 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。
Expand All@@ -18,7 +20,9 @@ export function App() {
return (
<ErrorBoundary>
<I18nProvider>
<Shell />
<DaemonBridgeProvider>
<Shell />
</DaemonBridgeProvider>
</I18nProvider>
</ErrorBoundary>
);
Expand Down
123 changes: 123 additions & 0 deletions emrg/gui/renderer/src/components/DaemonBridgeProvider.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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 (
<I18nProvider lang="en">
<DaemonBridgeProvider>{children}</DaemonBridgeProvider>
</I18nProvider>
);
}

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(<div data-testid="child" />));
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 <div />;
}
render(wrapper(<Probe />));
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 <div />;
}
render(wrapper(<Probe />));
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(
<DaemonBridgeProvider>
<div data-testid="child" />
</DaemonBridgeProvider>,
);
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(
<ErrorBoundary>
<Bad />
</ErrorBoundary>,
);
expect(container.querySelector(".error-boundary-overlay")).toBeInTheDocument();
errSpy.mockRestore();
});

it("卸载时 dispose(取消订阅,listener 清空)", async () => {
const m = mockEmrg();
const { unmount } = render(wrapper(<div />));
await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1));
expect(m.listeners.size).toBe(1);
unmount();
expect(m.listeners.size).toBe(0);
});
});
81 changes: 81 additions & 0 deletions emrg/gui/renderer/src/components/DaemonBridgeProvider.tsx
Original file line numberDiff line numberDiff line change
@@ -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<DaemonBridgeContextValue | null>(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<DaemonBridgeContextValue | null>(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 <DaemonBridgeContext.Provider value={value}>{children}</DaemonBridgeContext.Provider>;
}

/** 消费 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;
}
10 changes: 8 additions & 2 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
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";

/**
* Shell — React 骨架占位布局(Batch 0 + Batch 3 Sidebar/ResultPanel 挂载)。
* 设计文档 §5 Batch 0 项 4:错误边界/横幅/菜单等零风险组件先就位。
* Batch 3:左侧挂载 <Sidebar> + 右侧挂载 <ResultPanel>(当前空数据,Batch 5 接线 daemon 事件)。
* Batch 3:左侧挂载 <Sidebar> + 右侧挂载 <ResultPanel>(当前空数据)。
* 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 (
<div className="react-shell" data-testid="react-shell">
<header className="react-shell-header">
Expand All@@ -21,7 +27,7 @@ export function Shell() {
</header>
<div className="react-shell-body">
<aside className="react-shell-sidebar" data-testid="react-shell-sidebar">
<Sidebar openSessions={[]} />
<Sidebar openSessions={appState.openSessions} knownSessions={appState.sessions} />
</aside>
<main className="react-shell-main">
<WorkspaceView activeView="projects" />
Expand Down
Loading