From c896c0c0a8f5b7b92b6b5d96370b6512a235b379 Mon Sep 17 00:00:00 2001 From: LoadChange Date: Wed, 14 Oct 2026 14:27:43 +0800 Subject: [PATCH] fix: restore calendar and session history navigation --- .github/workflows/pr-build.yml | 5 + docs/specs/history-review.md | 29 ++++++ .../scripts/history-entry-renderer-e2e.mjs | 88 ++++++++++++++++- .../src/renderer/src/desktopPlatform.test.ts | 95 +++++++++++++++++++ .../src/renderer/src/desktopPlatform.ts | 20 ++++ .../test/history-window-entry.test.mjs | 7 +- packages/ui/test/history-entry.fixture.tsx | 66 +++---------- 7 files changed, 252 insertions(+), 58 deletions(-) create mode 100644 packages/desktop/src/renderer/src/desktopPlatform.test.ts 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 (