diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml
index 8f7850c6..55d838a3 100644
--- a/.github/workflows/pr-build.yml
+++ b/.github/workflows/pr-build.yml
@@ -43,6 +43,11 @@ jobs:
packages/desktop/test/finalize-macos-release.test.mjs
packages/desktop/test/legacy-native-update.test.mjs
packages/desktop/test/prepare-main-release.test.mjs
+ - name: Test desktop history platform integration
+ run: >-
+ pnpm exec vitest run
+ packages/desktop/src/renderer/src/desktopPlatform.test.ts
+ --config packages/ui/vitest.config.ts
macos-package:
name: Build unsigned macOS arm64 package
diff --git a/docs/specs/history-review.md b/docs/specs/history-review.md
index ab55b11c..2abbda43 100644
--- a/docs/specs/history-review.md
+++ b/docs/specs/history-review.md
@@ -146,6 +146,29 @@ covers the workspace returns immediately to the workspace before showing history
requesting the already selected history view does not trigger the Settings exit
fallback to chat.
+Desktop's `createDesktopPlatform` must forward the preload's `ccbuddyHistory`
+bridge as `IPlatformService.historyReview`, and forward
+`onOpenHistoryViewRequested` to the existing main-view navigation hook. The
+sidebar's capability check uses that injected service; exposing a preload global
+alone must not be treated as completed integration. A missing history bridge
+keeps the history commands unavailable without preventing the workbench from
+starting. The adapter does not own history data or a second navigation state.
+
+```mermaid
+sequenceDiagram
+ participant Preload as Desktop preload
+ participant Platform as IPlatformService adapter
+ participant App as App main-view owner
+ participant History as History controller
+ participant Main as Main history IPC
+ Preload->>Platform: ccbuddyHistory read bridge + menu subscription
+ Platform->>App: history capability + requested list/timeline
+ App->>History: render selected history route
+ History->>Platform: list / refresh / load
+ Platform->>Main: existing validated read-only IPC
+ Main-->>History: snapshot / progress / detail
+```
+
## Borrowed design rules
- Codex's protocol-first approach: the read API has an explicit, versioned,
@@ -237,3 +260,9 @@ producer records or config files.
loading either task cannot redirect the other task's model changes.
Repeating an import with the same trimmed identity reuses its task; local
imports without an identity retain their previous IDs.
+13. Regression coverage creates the real Desktop platform adapter from separate
+ preload history and application bridges. Both sidebar commands remain
+ available, list and calendar navigation read the supplied catalog, and native
+ menu requests still select history from Settings. Bridge methods, refresh
+ events, and subscription disposal are forwarded. A preload without history
+ does not invent the capability or crash on an unavailable menu subscription.
diff --git a/packages/desktop/scripts/history-entry-renderer-e2e.mjs b/packages/desktop/scripts/history-entry-renderer-e2e.mjs
index 9bde2765..df0847c7 100644
--- a/packages/desktop/scripts/history-entry-renderer-e2e.mjs
+++ b/packages/desktop/scripts/history-entry-renderer-e2e.mjs
@@ -8,7 +8,7 @@ import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { build } from "esbuild";
-// 隔离 renderer 交互回归:真实侧栏、导航 hooks 与历史 controller;不启动完整 Desktop Host。
+// 隔离 renderer 交互回归:真实 Desktop 平台装配、侧栏、导航 hooks 与历史 controller;不启动完整 Host。
const require = createRequire(import.meta.url);
const root = resolve(import.meta.dirname, "../../..");
const desktop = join(root, "packages/desktop");
@@ -33,17 +33,83 @@ await build({
"process.env.NODE_ENV": '"production"',
"import.meta.env": '{"DEV":false,"PROD":true,"BASE_URL":"./"}',
},
+ plugins: [
+ {
+ name: "history-fixture-telemetry",
+ setup(builder) {
+ // 只隔离无关埋点,历史能力和菜单订阅仍经过生产 createDesktopPlatform。
+ builder.onResolve({ filter: /^@ccbuddy\/ui$/ }, () => ({
+ path: "history-fixture-telemetry",
+ namespace: "history-fixture",
+ }));
+ builder.onLoad({ filter: /.*/, namespace: "history-fixture" }, () => ({
+ contents: "export function recordArmsCustomEventForE2E() {}",
+ loader: "js",
+ }));
+ },
+ },
+ ],
logLevel: "warning",
});
await writeFile(
join(output, "index.html"),
`
CCbuddy history renderer fixture ${cssFiles.map((name) => ` `).join("")}
`,
);
+const createdAt = new Date(Date.now() - 15 * 60_000).toISOString();
+const summary = {
+ id: "fixture-session",
+ source: "claude",
+ sessionId: "fixture-session",
+ title: "History entry fixture",
+ project: "Fixture workspace",
+ cwd: "/fixture/workspace",
+ createdAt,
+ lastActivity: new Date().toISOString(),
+ messageCount: 1,
+ model: null,
+ parentSessionId: null,
+ isSubagent: false,
+ fingerprint: "fixture",
+};
+const snapshot = {
+ protocolVersion: 1,
+ version: 1,
+ sessions: [summary],
+ diagnostics: [],
+ roots: [],
+ complete: true,
+};
+const detail = {
+ summary,
+ diagnostics: [],
+ messages: [
+ {
+ id: "fixture-message",
+ sequence: 0,
+ role: "user",
+ timestamp: createdAt,
+ model: null,
+ usage: null,
+ blocks: [{ type: "text", text: "Read-only history content from the renderer fixture." }],
+ },
+ ],
+};
await writeFile(
join(output, "preload.cjs"),
`const {contextBridge, ipcRenderer} = require("electron");
+const snapshot = ${JSON.stringify(snapshot)};
+const detail = ${JSON.stringify(detail)};
+contextBridge.exposeInMainWorld("ccbuddyHistory", {
+ protocolVersion: 1,
+ list: async () => snapshot,
+ load: async (id) => { if (id !== detail.summary.id) throw new Error("Unknown fixture session"); return detail; },
+ refresh: async () => ({type:"terminal", generation:1, status:"success", snapshot}),
+ onRefreshEvent: () => () => {},
+});
+contextBridge.exposeInMainWorld("ccbuddy", {
+ onOpenHistoryViewRequested(listener) { const handler = (_, view) => listener(view); ipcRenderer.on("fixture-history-open", handler); return () => ipcRenderer.removeListener("fixture-history-open", handler); },
+});
contextBridge.exposeInMainWorld("historyEntryHarness", {
- onHistoryRequested(listener) { const handler = (_, view) => listener(view); ipcRenderer.on("fixture-history-open", handler); return () => ipcRenderer.removeListener("fixture-history-open", handler); },
requestHistory(view) { return ipcRenderer.invoke("fixture-history-request", view); },
});`,
);
@@ -104,7 +170,13 @@ try {
check();
});
await browser("connect", String(port));
+ await browser("wait", "#workspace");
+ await browser(
+ "eval",
+ `if(document.querySelector('#workspace').dataset.historyCapable !== 'true') throw new Error('Production Desktop platform lost its preload history capability')`,
+ );
await browser("wait", '[data-testid="history-list-sidebar-open"]');
+ await browser("wait", '[data-testid="history-timeline-sidebar-open"]');
await writeFile(join(output, "initial-snapshot.txt"), (await browser("snapshot", "-i")).stdout);
await browser("click", '[data-testid="history-list-sidebar-open"]');
await browser("wait", '[aria-label="Filter by title, project, or path"]');
@@ -116,6 +188,18 @@ try {
await browser("click", '[role="option"]');
await browser("wait", "--text", "Read-only history content from the renderer fixture.");
await browser("screenshot", join(output, "history-list.png"));
+ await browser("click", '#workspace header [aria-label="Session review"] button:nth-child(2)');
+ await browser("wait", '[data-testid="history-timeline-scroll"]');
+ await browser(
+ "eval",
+ `if(document.querySelector('#workspace').dataset.view !== 'history-timeline' || document.querySelector('[data-testid="history-timeline-sidebar-open"]').getAttribute('aria-pressed') !== 'true' || document.querySelector('[data-testid="history-list-sidebar-open"]').getAttribute('aria-pressed') !== 'false' || !document.querySelector('#workspace').textContent.includes('Read-only history content from the renderer fixture.')) throw new Error('History header lost route, sidebar selection, or selected session')`,
+ );
+ await browser("click", '#workspace header [aria-label="Session review"] button:nth-child(1)');
+ await browser("wait", '[aria-label="Filter by title, project, or path"]');
+ await browser(
+ "eval",
+ `if(document.querySelector('[role="option"]').getAttribute('aria-selected') !== 'true' || document.querySelector('[data-testid="history-list-sidebar-open"]').getAttribute('aria-pressed') !== 'true') throw new Error('Header list navigation lost session or sidebar selection')`,
+ );
await browser("click", '[data-testid="history-timeline-sidebar-open"]');
await browser("wait", '[data-testid="history-timeline-scroll"]');
await browser(
diff --git a/packages/desktop/src/renderer/src/desktopPlatform.test.ts b/packages/desktop/src/renderer/src/desktopPlatform.test.ts
new file mode 100644
index 00000000..5f41cb5e
--- /dev/null
+++ b/packages/desktop/src/renderer/src/desktopPlatform.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { IPlatformService } from "@ccbuddy/shared";
+import { createDesktopPlatform } from "./desktopPlatform.js";
+
+vi.mock("@ccbuddy/ui", () => ({ recordArmsCustomEventForE2E: vi.fn() }));
+vi.mock("./desktopBrowserPlatformBridge.js", () => ({ desktopBrowserPlatformBridge: {} }));
+vi.mock("./plugin-sandbox/desktopPluginSandboxPlatform.js", () => ({
+ createDesktopPluginSandboxPlatform: () => undefined,
+}));
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("Desktop history platform integration", () => {
+ it("exposes the separate preload bridge that enables calendar and session navigation", async () => {
+ const snapshot = { protocolVersion: 1, sessions: [] };
+ const detail = { id: "fixture-session", messages: [] };
+ const terminal = { type: "terminal", status: "success", snapshot };
+ const bridge = {
+ protocolVersion: 1,
+ list: vi.fn(async () => snapshot),
+ load: vi.fn(async (_id: string) => detail),
+ refresh: vi.fn(async () => terminal),
+ onRefreshEvent: vi.fn(() => () => {}),
+ } satisfies NonNullable;
+ vi.stubGlobal("window", { ccbuddy: {}, ccbuddyHistory: bridge });
+
+ const platform = createDesktopPlatform({ isLocalDevelopmentRuntime: false });
+ expect(platform.historyReview?.protocolVersion).toBe(1);
+ expect(await platform.historyReview!.list()).toBe(snapshot);
+ expect(await platform.historyReview!.load("fixture-session")).toBe(detail);
+ expect(bridge.load).toHaveBeenCalledWith("fixture-session");
+ expect(await platform.historyReview!.refresh()).toBe(terminal);
+ });
+
+ it("forwards history progress and preserves subscription cleanup", () => {
+ let publish: ((event: unknown) => void) | undefined;
+ const unsubscribe = vi.fn();
+ vi.stubGlobal("window", {
+ ccbuddy: {},
+ ccbuddyHistory: {
+ protocolVersion: 1,
+ onRefreshEvent: (listener: (event: unknown) => void) => {
+ publish = listener;
+ return unsubscribe;
+ },
+ },
+ });
+ const platform = createDesktopPlatform({ isLocalDevelopmentRuntime: true });
+ const listener = vi.fn();
+ const dispose = platform.historyReview!.onRefreshEvent(listener);
+ const event = { type: "progress", generation: 1 };
+ publish!(event);
+ expect(listener).toHaveBeenCalledWith(event);
+ dispose();
+ expect(unsubscribe).toHaveBeenCalledOnce();
+ });
+
+ it("forwards native menu requests for both views and subscription cleanup", () => {
+ let publish: ((view: "list" | "timeline") => void) | undefined;
+ const unsubscribe = vi.fn();
+ vi.stubGlobal("window", {
+ ccbuddy: {
+ onOpenHistoryViewRequested: (listener: (view: "list" | "timeline") => void) => {
+ publish = listener;
+ return unsubscribe;
+ },
+ },
+ });
+ const platform = createDesktopPlatform({ isLocalDevelopmentRuntime: false });
+ const listener = vi.fn();
+ const dispose = platform.onOpenHistoryViewRequested!(listener);
+ publish!("timeline");
+ publish!("list");
+ expect(listener.mock.calls).toEqual([["timeline"], ["list"]]);
+ dispose();
+ expect(unsubscribe).toHaveBeenCalledOnce();
+ });
+
+ it("keeps read failures visible to the history controller", async () => {
+ const failure = new Error("Fixture history read failed");
+ vi.stubGlobal("window", {
+ ccbuddy: {},
+ ccbuddyHistory: { protocolVersion: 1, list: () => Promise.reject(failure) },
+ });
+ const platform = createDesktopPlatform({ isLocalDevelopmentRuntime: false });
+ await expect(platform.historyReview!.list()).rejects.toBe(failure);
+ });
+
+ it("does not invent history capability when the preload bridge is unavailable", () => {
+ vi.stubGlobal("window", { ccbuddy: {} });
+ const platform = createDesktopPlatform({ isLocalDevelopmentRuntime: false });
+ expect(platform.historyReview).toBeUndefined();
+ expect(() => platform.onOpenHistoryViewRequested!(vi.fn())()).not.toThrow();
+ });
+});
diff --git a/packages/desktop/src/renderer/src/desktopPlatform.ts b/packages/desktop/src/renderer/src/desktopPlatform.ts
index b791704b..84f8f1f1 100644
--- a/packages/desktop/src/renderer/src/desktopPlatform.ts
+++ b/packages/desktop/src/renderer/src/desktopPlatform.ts
@@ -8,6 +8,12 @@ import {
import { desktopBrowserPlatformBridge } from "./desktopBrowserPlatformBridge.js";
import { createDesktopPluginSandboxPlatform } from "./plugin-sandbox/desktopPluginSandboxPlatform.js";
+declare global {
+ interface Window {
+ ccbuddyHistory?: NonNullable;
+ }
+}
+
export function createDesktopPlatform(options: {
isLocalDevelopmentRuntime: boolean;
}): IPlatformService {
@@ -157,6 +163,20 @@ export function createDesktopPlatform(options: {
downloadUpdate: () => window.ccbuddy.downloadUpdate?.() ?? Promise.resolve(),
cancelUpdateDownload: () => window.ccbuddy.cancelUpdateDownload?.() ?? Promise.resolve(),
openUpdateStatusWindow: () => window.ccbuddy.openUpdateStatusWindow?.() ?? Promise.resolve(),
+ // 合并时遗漏历史平台转接,侧栏因能力缺失同时隐藏日历和会话列表,原生菜单也无法导航。
+ // 恢复既有 preload 只读桥与菜单订阅,让 App 继续作为主视图状态的唯一所有者。
+ onOpenHistoryViewRequested: (listener) =>
+ window.ccbuddy.onOpenHistoryViewRequested?.(listener) ?? (() => {}),
+ historyReview: window.ccbuddyHistory
+ ? {
+ protocolVersion: 1,
+ list: () => window.ccbuddyHistory!.list(),
+ load: (id) => window.ccbuddyHistory!.load(id),
+ refresh: () => window.ccbuddyHistory!.refresh(),
+ onRefreshEvent: (listener) =>
+ window.ccbuddyHistory!.onRefreshEvent((event) => listener(event)),
+ }
+ : undefined,
getAutoUpdatePreferences: () =>
window.ccbuddy.getAutoUpdatePreferences?.() ??
Promise.resolve({ autoDownloadAndInstallUpdates: false }),
diff --git a/packages/desktop/test/history-window-entry.test.mjs b/packages/desktop/test/history-window-entry.test.mjs
index 05cba054..f1f84a8a 100644
--- a/packages/desktop/test/history-window-entry.test.mjs
+++ b/packages/desktop/test/history-window-entry.test.mjs
@@ -14,9 +14,10 @@ test("session history uses the main renderer entry in dev and packaged modes", a
assert.equal(rendererHtmlFileName("index"), "index.html");
const html = await readFile(join(desktopRoot, "src", "renderer", "index.html"), "utf8");
- const entry = await readFile(join(desktopRoot, "src", "renderer", "src", "mainEntry.ts"), "utf8");
+ const entry = await readFile(join(desktopRoot, "src", "renderer", "src", "main.tsx"), "utf8");
const viteConfig = await readFile(join(desktopRoot, "vite.config.ts"), "utf8");
- assert.match(html, /src="\.\/src\/mainEntry\.ts"/u);
- assert.match(entry, /import\("\.\/main\.js"\)/u);
+ // 启动页已改为静态加载 main.tsx,历史应继续复用该入口,不能再断言旧的延迟导入路径。
+ assert.match(html, /src="\.\/src\/main\.tsx"/u);
+ assert.match(entry, /platform=\{desktopPlatform\}/u);
assert.doesNotMatch(viteConfig, /history\.html/u);
});
diff --git a/packages/ui/test/history-entry.fixture.tsx b/packages/ui/test/history-entry.fixture.tsx
index e5ed438a..838c29b6 100644
--- a/packages/ui/test/history-entry.fixture.tsx
+++ b/packages/ui/test/history-entry.fixture.tsx
@@ -9,64 +9,19 @@ import {
HistoryReviewController,
type HistoryReadBridge,
} from "../src/history/HistoryReviewController.js";
-import type { HistorySessionDetail, HistorySnapshot } from "../src/history/contract.js";
+import { createDesktopPlatform } from "../../desktop/src/renderer/src/desktopPlatform.js";
declare global {
interface Window {
historyEntryHarness: {
- onHistoryRequested(listener: (view: "list" | "timeline") => void): () => void;
requestHistory(view: "list" | "timeline"): Promise;
};
}
}
-const createdAt = new Date(Date.now() - 15 * 60_000).toISOString();
-const summary = {
- id: "fixture-session",
- source: "claude" as const,
- sessionId: "fixture-session",
- title: "History entry fixture",
- project: "Fixture workspace",
- cwd: "/fixture/workspace",
- createdAt,
- lastActivity: new Date().toISOString(),
- messageCount: 1,
- model: null,
- parentSessionId: null,
- isSubagent: false,
- fingerprint: "fixture",
-};
-const snapshot: HistorySnapshot = {
- protocolVersion: 1,
- version: 1,
- sessions: [summary],
- diagnostics: [],
- roots: [],
- complete: true,
-};
-const detail: HistorySessionDetail = {
- summary,
- diagnostics: [],
- messages: [
- {
- id: "fixture-message",
- sequence: 0,
- role: "user",
- timestamp: createdAt,
- model: null,
- usage: null,
- blocks: [{ type: "text", text: "Read-only history content from the renderer fixture." }],
- },
- ],
-};
-const history: HistoryReadBridge = {
- protocolVersion: 1,
- list: async () => snapshot,
- load: async () => detail,
- refresh: async () => ({ type: "terminal", generation: 1, status: "success", snapshot }),
- onRefreshEvent: () => () => {},
-};
-const platform = { onOpenHistoryViewRequested: window.historyEntryHarness.onHistoryRequested };
+// 回归依据:必须经过生产平台装配;手造 history capability 会漏掉适配器丢失导致的入口消失。
+const platform = createDesktopPlatform({ isLocalDevelopmentRuntime: true });
+const history = platform.historyReview;
function HistoryEntryFixture() {
const [view, setView] = useState("chat");
@@ -95,8 +50,12 @@ function HistoryEntryFixture() {
return (
-
-
+ {history ? (
+ <>
+
+
+ >
+ ) : null}
setWorkspaceVisible(false)}>
Settings
@@ -106,12 +65,13 @@ function HistoryEntryFixture() {
className="min-h-0 min-w-0 flex-1"
data-view={view}
data-visible={workspaceVisible}
+ data-history-capable={Boolean(history)}
>
{!workspaceVisible ? (
Settings fixture
- ) : historyView ? (
+ ) : historyView && history ? (