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
5 changes: 5 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/specs/history-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
88 changes: 86 additions & 2 deletions packages/desktop/scripts/history-entry-renderer-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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"),
`<!doctype html><html><head><meta charset="utf-8"><title>CCbuddy history renderer fixture</title>${cssFiles.map((name) => `<link rel="stylesheet" href="${name}">`).join("")}</head><body><div id="root"></div><script src="renderer.js"></script></body></html>`,
);
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); },
});`,
);
Expand Down Expand Up @@ -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"]');
Expand All @@ -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(
Expand Down
95 changes: 95 additions & 0 deletions packages/desktop/src/renderer/src/desktopPlatform.test.ts
Original file line number Diff line number Diff line change
@@ -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<IPlatformService["historyReview"]>;
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();
});
});
20 changes: 20 additions & 0 deletions packages/desktop/src/renderer/src/desktopPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import {
import { desktopBrowserPlatformBridge } from "./desktopBrowserPlatformBridge.js";
import { createDesktopPluginSandboxPlatform } from "./plugin-sandbox/desktopPluginSandboxPlatform.js";

declare global {
interface Window {
ccbuddyHistory?: NonNullable<IPlatformService["historyReview"]>;
}
}

export function createDesktopPlatform(options: {
isLocalDevelopmentRuntime: boolean;
}): IPlatformService {
Expand Down Expand Up @@ -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 }),
Expand Down
7 changes: 4 additions & 3 deletions packages/desktop/test/history-window-entry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading
Loading