diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index e58f108e46..0d0c72eb9f 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -25,7 +25,7 @@ "re-export rather than a direct import, which type checking cannot catch,", "so the check names them explicitly." ], - "maxBootBytes": 1707047, + "maxBootBytes": 1711143, "maxBootBrotliBytes": 448512, "forbiddenBootPackages": [ "@pierre/diffs", diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index d8f0f3af45..56f135d519 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -14,6 +14,7 @@ import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; import { useDesktopThemeSync } from "./hooks/useDesktopThemeSync"; import { usePluginFrontendBoot } from "./hooks/usePluginFrontendBoot"; +import { useRememberPluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome"; import { useWebSocket } from "./hooks/useWebSocket"; import { AUTH_CALLBACK_ROUTE_PATH, @@ -342,6 +343,7 @@ export function App() { useFaviconColorSync(); // Load plugin frontend bundles once system config resolves. usePluginFrontendBoot(); + useRememberPluginNavPanelChrome(); return ( diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx index 4e58f3fe1c..74d1119743 100644 --- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx +++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx @@ -50,8 +50,8 @@ vi.mock("@/lib/plugin-slots", () => ({ })); vi.mock("@/components/plugin/PluginPanelHeader", () => ({ - PluginPanelHeaderCenter: ({ panel }: { panel: { title: string } }) => ( - {panel.title} + PluginPanelHeaderCenter: ({ chrome }: { chrome: { title: string } }) => ( + {chrome.title} ), PluginPanelHeaderActions: () => null, })); diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index 3e90688d10..03e92af74c 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -47,7 +47,11 @@ import { PluginPanelHeaderCenter, } from "@/components/plugin/PluginPanelHeader"; import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; -import { usePluginSlots, type PluginNavPanelSlot } from "@/lib/plugin-slots"; +import { + usePluginNavPanelChrome, + type PluginNavPanelChrome, +} from "@/lib/plugin-nav-panel-chrome"; +import type { PluginNavPanelSlot } from "@/lib/plugin-slots"; import { createLocalStorageSyncStorage } from "@/lib/browser-storage"; import { BROWSER_SIDEBAR_TRIGGER_INSET_CLASS, @@ -305,9 +309,11 @@ interface AppHeaderProps { projectId?: string; project?: ProjectResponse; /** Registered navPanel when this is a plugin panel route (design §5.2): - * the shared header shows plugin icon + title, plus the registration's - * `headerContent` as the actions. */ + * the registration's `headerContent` becomes the actions. */ pluginPanel?: PluginNavPanelSlot; + /** The panel's icon + title for the header center — from the live + * registration, or remembered chrome until plugin frontends have booted. */ + pluginPanelChrome?: PluginNavPanelChrome; /** The panel route's splat remainder ("" at the panel root). */ pluginPanelSubPath?: string; meta: { @@ -324,6 +330,7 @@ function AppHeader({ projectId, project, pluginPanel, + pluginPanelChrome, pluginPanelSubPath, meta, }: AppHeaderProps) { @@ -343,8 +350,8 @@ function AppHeader({ usesDesktopChrome={usesDesktopChrome} /> - ) : pluginPanel ? ( - + ) : pluginPanelChrome ? ( + ) : hasCenterContent ? (
{headerTitle ? ( @@ -508,7 +515,7 @@ export function AppLayout({ children }: AppLayoutProps) { : null; // Plugin panel routes ride the shared header (design §5.2): icon + panel // title in the center, the registration's headerContent as the actions. - const { navPanels } = usePluginSlots(); + const navPanelChrome = usePluginNavPanelChrome(); // Global settings routes swap the app sidebar for the settings sidebar. const isGlobalSettingsView = matchPath(`${SETTINGS_ROUTE_PATH}/*`, location.pathname) !== null; @@ -517,13 +524,15 @@ export function AppLayout({ children }: AppLayoutProps) { PLUGIN_PANEL_ROUTE_PATH, location.pathname, ); - const pluginPanel = pluginPanelMatch - ? navPanels.find( + const pluginPanelEntry = pluginPanelMatch + ? navPanelChrome.find( (candidate) => - candidate.pluginId === pluginPanelMatch.params.pluginId && - candidate.path === pluginPanelMatch.params.panelPath, + candidate.chrome.pluginId === pluginPanelMatch.params.pluginId && + candidate.chrome.path === pluginPanelMatch.params.panelPath, ) : undefined; + const pluginPanel = pluginPanelEntry?.panel ?? undefined; + const pluginPanelChrome = pluginPanelEntry?.chrome; const sidebarNavigationQuery = useSidebarNavigation(); const projects = useMemo( () => sidebarNavigationQuery.data?.projects.map(stripProjectThreads), @@ -827,59 +836,60 @@ export function AppLayout({ children }: AppLayoutProps) { }, [documentTitle]); return ( - - - - - - - -
- {showHeader ? ( - - ) : null} -
- {children} -
-
-
- -
+ + + + + + + +
+ {showHeader ? ( + + ) : null} +
+ {children} +
+
+
+ +
(() => { - const pluginRows = navPanels.map((panel) => ({ + const pluginRows = navPanels.map(({ chrome, panel }) => ({ kind: "plugin", - pluginId: panel.pluginId, - id: panel.id, - title: panel.title, + pluginId: chrome.pluginId, + id: chrome.id, + title: chrome.title, + chrome, panel, })); if (toolsRoutePath === undefined) return pluginRows; @@ -452,28 +461,28 @@ function PluginNavSidebarItem({ }: Omit & { row: Extract; }) { - const { panel } = row; + const { chrome, panel } = row; const navigate = useNavigate(); const isCompactViewport = useIsCompactViewport(); const path = getPluginPanelRoutePath({ - pluginId: panel.pluginId, - path: panel.path, + pluginId: chrome.pluginId, + path: chrome.path, }); const content = { kind: "plugin-panel", - pluginId: panel.pluginId, - panelPath: panel.path, + pluginId: chrome.pluginId, + panelPath: chrome.path, subPath: "", } as const; const { onPointerDown, openInSplit } = usePaneContentSplitDrag({ content, enabled: splitEnabled, - label: panel.title, + label: chrome.title, }); const splitIndicator = usePaneContentSplitIndicator(content, splitEnabled); - const SidebarAccessory = panel.experimental_sidebarAccessory; + const SidebarAccessory = panel?.experimental_sidebarAccessory; const sidebarAccessory = - !isCompactViewport && SidebarAccessory !== undefined ? ( + panel !== null && !isCompactViewport && SidebarAccessory !== undefined ? ( } + title={chrome.title} + icon={} isActive={pathname === path || pathname.startsWith(`${path}/`)} splitMiniMap={splitIndicator.miniMap} accessory={sidebarAccessory} diff --git a/apps/app/src/components/plugin/PluginPanelHeader.tsx b/apps/app/src/components/plugin/PluginPanelHeader.tsx index 5209347dd4..00460082d8 100644 --- a/apps/app/src/components/plugin/PluginPanelHeader.tsx +++ b/apps/app/src/components/plugin/PluginPanelHeader.tsx @@ -1,4 +1,5 @@ import { Component, type ReactNode } from "react"; +import type { PluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome"; import type { PluginNavPanelSlot } from "@/lib/plugin-slots"; import { PluginIcon } from "./PluginIcon"; import { PluginContext } from "./plugin-context"; @@ -37,20 +38,24 @@ class HeaderContentBoundary extends Component< } } -/** Header center for a plugin panel route: compact plugin icon + panel title. */ +/** + * Header center for a plugin panel route: compact plugin icon + panel title. + * Takes only the panel's chrome so it can paint from a live registration or + * from the chrome remembered before plugin frontends have booted. + */ export function PluginPanelHeaderCenter({ - panel, + chrome, }: { - panel: PluginNavPanelSlot; + chrome: Pick; }) { return (
-

{panel.title}

+

{chrome.title}

); } diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index 8b15594b60..dfc0d577f9 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -29,6 +29,11 @@ import { PLUGIN_PANEL_ROUTE_PATH, AUTOMATIONS_PLUGIN_PANEL_PATH, } from "@/lib/route-paths"; +import { + markPluginFrontendsSettled, + resetPluginFrontendBootStateForTest, +} from "@/lib/plugin-frontend-boot-state"; +import { writeLastKnownPluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome"; import { PluginPanelView } from "@/views/PluginPanelView"; import { PluginPanelHeaderActions, @@ -86,6 +91,8 @@ function registrationSet( afterEach(() => { cleanup(); resetPluginSlotStoreForTest(); + resetPluginFrontendBootStateForTest(); + window.localStorage.clear(); resetAllCrashedPluginSlotsForTest(); vi.restoreAllMocks(); }); @@ -1395,7 +1402,72 @@ describe("PluginNavSidebarItems + PluginPanelView", () => { ).toBe("page"); }); - it("shows a placeholder for an unknown plugin panel route", () => { + it("draws a remembered plugin row before boot and keeps the same node when the plugin registers", () => { + resetPluginFrontendBootStateForTest(); + writeLastKnownPluginNavPanelChrome([ + { + pluginId: "demo", + id: "board", + path: "board", + title: "Demo board", + icon: "columns", + }, + ]); + render( + + + , + ); + const rememberedRow = screen.getByRole("button", { name: "Demo board" }); + + // The live registration lands under the same key: no remount, no flash. + act(() => { + setPluginSlotRegistrations( + "demo", + registrationSet({ + navPanels: [ + { + id: "board", + title: "Demo board", + icon: "columns", + path: "board", + component: Board, + }, + ], + }), + ); + markPluginFrontendsSettled(); + }); + expect(screen.getByRole("button", { name: "Demo board" })).toBe( + rememberedRow, + ); + }); + + it("drops a remembered plugin row that never registers once frontends have settled", () => { + resetPluginFrontendBootStateForTest(); + writeLastKnownPluginNavPanelChrome([ + { + pluginId: "ghost", + id: "board", + path: "board", + title: "Ghost board", + icon: "columns", + }, + ]); + render( + + + , + ); + expect(screen.getByRole("button", { name: "Ghost board" })).toBeDefined(); + act(() => markPluginFrontendsSettled()); + expect(screen.queryByRole("button", { name: "Ghost board" })).toBeNull(); + }); + + it("stays quiet for an unknown panel until plugin frontends have booted", () => { + resetPluginFrontendBootStateForTest(); + // A reload or deep link renders the route before registrations arrive; + // that moment must not read as an error. render( @@ -1403,6 +1475,9 @@ describe("PluginNavSidebarItems + PluginPanelView", () => { , ); + expect(screen.queryByText(/This plugin panel is not available/)).toBeNull(); + + act(() => markPluginFrontendsSettled()); expect( screen.getByText(/This plugin panel is not available/), ).toBeDefined(); @@ -1448,7 +1523,7 @@ describe("plugin panel shared title bar and full-bleed body", () => { const panel = panelSlot({ headerContent: ExplodingAccessory }); render( <> - + , ); @@ -1464,7 +1539,7 @@ describe("plugin panel shared title bar and full-bleed body", () => { const panel = panelSlot({ headerContent: Accessory }); render( <> - + , ); diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index e1b991ad22..3f82eb934a 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -553,8 +553,11 @@ function EmbeddedThreadChatWithComposer({ }, [stopThread, threadId]); const isProvisioning = displayStatus === "provisioning" || displayStatus === "starting"; + // A replayed (placeholder) resolution seeds the pickers but does not open + // submission; wait for the live query like an empty cache would. const isDefaultExecutionOptionsLoading = - defaultExecutionOptions === undefined && executionOptionsQuery.isLoading; + executionOptionsQuery.isPlaceholderData || + (defaultExecutionOptions === undefined && executionOptionsQuery.isLoading); const { processingQueuedMessage, diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx new file mode 100644 index 0000000000..6b1efff563 --- /dev/null +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom + +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { + sidebarBootstrapResponseSchema, + type SidebarBootstrapResponse, +} from "@bb/server-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { request } from "@/lib/api"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { useSidebarNavigation } from "./sidebar-navigation-query"; + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, request: vi.fn() }; +}); + +vi.mock("@/lib/api-server", () => ({ + apiClient: { "sidebar-bootstrap": { $get: vi.fn(() => ({})) } }, +})); + +vi.mock("@/hooks/useRealtimeSubscription", () => ({ + useEnvironmentListRealtimeSubscription: vi.fn(), + useHostListRealtimeSubscription: vi.fn(), + useProjectListRealtimeSubscription: vi.fn(), + useThreadListRealtimeSubscription: vi.fn(), +})); + +const PERSONAL_PROJECT: SidebarBootstrapResponse["personalProject"] = { + id: "proj_personal", + kind: "personal", + name: "Personal", + gitRemoteUrl: null, + createdAt: 1, + updatedAt: 1, + sources: [], + threads: [], + defaultExecutionOptions: null, +}; + +const BOOTSTRAP: SidebarBootstrapResponse = { + sections: [], + projects: [ + { + ...PERSONAL_PROJECT, + id: "proj_felt", + kind: "standard", + name: "Felt walk", + }, + ], + personalProject: PERSONAL_PROJECT, +}; + +/** A request that never settles, so the pre-fetch render is observable. */ +const pendingForever = () => new Promise(() => {}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +describe("useSidebarNavigation", () => { + it("replays the last bootstrap while the live one loads", async () => { + // The cache validates reads against the wire schema, so the fixture must + // be a real response shape; fail here, not silently in the replay. + sidebarBootstrapResponseSchema.parse(BOOTSTRAP); + + vi.mocked(request).mockResolvedValue(BOOTSTRAP); + const warmHarness = createQueryClientTestHarness(); + const warm = renderHook(() => useSidebarNavigation(), { + wrapper: warmHarness.wrapper, + }); + await waitFor(() => expect(warm.result.current.data).toEqual(BOOTSTRAP)); + warm.unmount(); + + // A full page load starts from an empty query cache; only the profile's + // last-known bootstrap can fill the rail before the network answers. + vi.mocked(request).mockImplementation(pendingForever); + const reloadHarness = createQueryClientTestHarness(); + const { result } = renderHook(() => useSidebarNavigation(), { + wrapper: reloadHarness.wrapper, + }); + expect(result.current.isPlaceholderData).toBe(true); + expect(result.current.data?.projects[0]?.name).toBe("Felt walk"); + await waitFor(() => expect(request).toHaveBeenCalled()); + }); + + it("keeps the cold-profile skeleton: no placeholder without a stored bootstrap", () => { + vi.mocked(request).mockImplementation(pendingForever); + const harness = createQueryClientTestHarness(); + const { result } = renderHook(() => useSidebarNavigation(), { + wrapper: harness.wrapper, + }); + expect(result.current.data).toBeUndefined(); + expect(result.current.isPlaceholderData).toBe(false); + expect(result.current.isPending).toBe(true); + }); +}); diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.ts b/apps/app/src/hooks/queries/sidebar-navigation-query.ts index 7e331fbf65..843d7b9aad 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.ts +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.ts @@ -10,6 +10,11 @@ import { useThreadListRealtimeSubscription, } from "@/hooks/useRealtimeSubscription"; import { REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY } from "./query-policies"; +import { + SIDEBAR_BOOTSTRAP_CACHE_KEY, + readCachedSidebarBootstrap, + writeCachedSidebarBootstrap, +} from "@/lib/sidebar-bootstrap-cache"; export const SIDEBAR_NAVIGATION_QUERY_KEY = "sidebarNavigation"; @@ -42,9 +47,21 @@ export function useSidebarNavigation(options?: QueryOptions) { return useQuery({ queryKey: sidebarNavigationQueryKey(), - queryFn: ({ signal }) => fetchSidebarNavigation(signal), + queryFn: async ({ signal }) => { + const response = await fetchSidebarNavigation(signal); + writeCachedSidebarBootstrap(SIDEBAR_BOOTSTRAP_CACHE_KEY, response); + return response; + }, enabled, ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, + // A full load starts from an empty query cache, so the rail showed its + // two-row loading skeleton on every visit until the bootstrap resolved. + // Replay the last bootstrap this profile received instead; the live + // response replaces it in place. Consumers treat the replay like any + // sidebar data (navigation only), and a cold profile still shows the + // skeleton, so first-run behavior is unchanged. + placeholderData: () => + readCachedSidebarBootstrap(SIDEBAR_BOOTSTRAP_CACHE_KEY) ?? undefined, }); } diff --git a/apps/app/src/hooks/queries/system-queries.test.tsx b/apps/app/src/hooks/queries/system-queries.test.tsx index 5d1b3e86e6..93b25d75fe 100644 --- a/apps/app/src/hooks/queries/system-queries.test.tsx +++ b/apps/app/src/hooks/queries/system-queries.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { listBuiltInAgentProviderInfos } from "@bb/agent-providers"; +import type { AvailableModel } from "@bb/domain"; import type { OnboardingAgentOverview, SystemExecutionOptionsResponse, @@ -73,6 +75,7 @@ const PROVIDER_USAGE_RESPONSE: ProviderUsageResponse = { afterEach(() => { cleanup(); vi.clearAllMocks(); + window.localStorage.clear(); }); describe("useSystemExecutionOptions", () => { @@ -119,6 +122,220 @@ describe("useSystemExecutionOptions", () => { ); }); + const CODEX_MODEL: AvailableModel = { + id: "gpt-5.6-sol", + model: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: "medium", + isDefault: true, + }; + const CODEX_CATALOG: SystemExecutionOptionsResponse = { + ...EXECUTION_OPTIONS_RESPONSE, + providers: listBuiltInAgentProviderInfos(), + models: [CODEX_MODEL], + }; + /** A request that never settles, so the pre-fetch render is observable. */ + const pendingForever = () => new Promise(() => {}); + + it("preloads a provider's last verified catalog until the probe lands", async () => { + vi.mocked(sdk.system.executionOptions).mockResolvedValue(CODEX_CATALOG); + const first = createQueryClientTestHarness(); + const warm = renderHook( + () => + useSystemExecutionOptions({ hostId: "host-a", providerId: "codex" }), + { wrapper: first.wrapper }, + ); + await waitFor(() => + expect(warm.result.current.data).toEqual(CODEX_CATALOG), + ); + warm.unmount(); + + // A full page load starts from an empty query cache; only the profile's + // last-known catalog can fill the composer before the network answers. + vi.mocked(sdk.system.executionOptions).mockImplementation(pendingForever); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => + useSystemExecutionOptions({ hostId: "host-a", providerId: "codex" }), + { wrapper: reload.wrapper }, + ); + expect(result.current.isPlaceholderData).toBe(true); + expect(result.current.data?.models).toEqual([CODEX_MODEL]); + expect(result.current.data?.modelLoadError).toBeNull(); + // Provisional data fails safe: the widest ceiling is never replayed. + expect(result.current.data?.permissionCeiling).toBe("accept-edits"); + await waitFor(() => + expect(sdk.system.executionOptions).toHaveBeenCalledWith( + expect.objectContaining({ hostId: "host-a", providerId: "codex" }), + ), + ); + }); + + it("replays the host's provider list so a custom provider paints as itself", async () => { + const customProvider = { + id: "acp:my-agent", + displayName: "My agent", + logoUrl: null, + capabilities: CODEX_CATALOG.providers[0]!.capabilities, + composerActions: [], + available: true, + }; + const customCatalog: SystemExecutionOptionsResponse = { + ...CODEX_CATALOG, + providers: [...CODEX_CATALOG.providers, customProvider], + }; + vi.mocked(sdk.system.executionOptions).mockResolvedValue(customCatalog); + const first = createQueryClientTestHarness(); + const warm = renderHook( + () => + useSystemExecutionOptions({ + hostId: "host-a", + providerId: customProvider.id, + }), + { wrapper: first.wrapper }, + ); + await waitFor(() => + expect(warm.result.current.data).toEqual(customCatalog), + ); + warm.unmount(); + + vi.mocked(sdk.system.executionOptions).mockImplementation(pendingForever); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => + useSystemExecutionOptions({ + hostId: "host-a", + providerId: customProvider.id, + }), + { wrapper: reload.wrapper }, + ); + expect(result.current.isPlaceholderData).toBe(true); + // The remembered list, not the built-in list: the selected provider is + // present, so the composer does not fall back to the first built-in one. + expect(result.current.data?.providers).toEqual(customCatalog.providers); + expect(result.current.data?.models).toEqual([CODEX_MODEL]); + }); + + it("withholds the placeholder when the remembered provider is not in any list it can replay", async () => { + // Warm the catalog for a custom provider from a routing whose provider + // list was never stored (a bumped cache version, a cleared entry): the + // built-in fallback list cannot vouch for it, so the composer waits. + vi.mocked(sdk.system.executionOptions).mockResolvedValue({ + ...CODEX_CATALOG, + providers: [], + }); + const first = createQueryClientTestHarness(); + const warm = renderHook( + () => + useSystemExecutionOptions({ + hostId: "host-a", + providerId: "acp:my-agent", + }), + { wrapper: first.wrapper }, + ); + await waitFor(() => expect(warm.result.current.data).toBeDefined()); + warm.unmount(); + + vi.mocked(sdk.system.executionOptions).mockImplementation(pendingForever); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => + useSystemExecutionOptions({ + hostId: "host-a", + providerId: "acp:my-agent", + }), + { wrapper: reload.wrapper }, + ); + expect(result.current.isPlaceholderData).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it("does not preload a catalog that came from a failed probe", async () => { + vi.mocked(sdk.system.executionOptions).mockResolvedValue({ + ...CODEX_CATALOG, + modelLoadError: { providerId: "codex", code: "failed" }, + }); + const first = createQueryClientTestHarness(); + const warm = renderHook( + () => + useSystemExecutionOptions({ hostId: "host-a", providerId: "codex" }), + { wrapper: first.wrapper }, + ); + await waitFor(() => + expect(warm.result.current.data?.modelLoadError).not.toBeNull(), + ); + warm.unmount(); + + vi.mocked(sdk.system.executionOptions).mockImplementation(pendingForever); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => + useSystemExecutionOptions({ hostId: "host-a", providerId: "codex" }), + { wrapper: reload.wrapper }, + ); + expect(result.current.data).toBeUndefined(); + expect(result.current.isPlaceholderData).toBe(false); + }); + + it("falls back to the provider's latest catalog when the routed key was never fetched", async () => { + // A thread composer can mount before its environment is known, so its + // first query key differs from the one that later completes and is cached. + vi.mocked(sdk.system.executionOptions).mockResolvedValue(CODEX_CATALOG); + const first = createQueryClientTestHarness(); + const warm = renderHook( + () => + useSystemExecutionOptions({ + environmentId: "env-1", + providerId: "codex", + }), + { wrapper: first.wrapper }, + ); + await waitFor(() => + expect(warm.result.current.data).toEqual(CODEX_CATALOG), + ); + warm.unmount(); + + vi.mocked(sdk.system.executionOptions).mockImplementation(pendingForever); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => useSystemExecutionOptions({ providerId: "codex" }), + { wrapper: reload.wrapper }, + ); + expect(result.current.isPlaceholderData).toBe(true); + expect(result.current.data?.models).toEqual([CODEX_MODEL]); + }); + + it("never preloads one provider's catalog for another", async () => { + vi.mocked(sdk.system.executionOptions).mockResolvedValue(CODEX_CATALOG); + const first = createQueryClientTestHarness(); + const warm = renderHook( + () => + useSystemExecutionOptions({ hostId: "host-a", providerId: "codex" }), + { wrapper: first.wrapper }, + ); + await waitFor(() => + expect(warm.result.current.data).toEqual(CODEX_CATALOG), + ); + warm.unmount(); + + vi.mocked(sdk.system.executionOptions).mockImplementation(pendingForever); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => [ + useSystemExecutionOptions({ hostId: "host-a", providerId: "pi" }), + useSystemExecutionOptions({ hostId: "host-b", providerId: "codex" }), + ], + { wrapper: reload.wrapper }, + ); + // Another provider never inherits this catalog. + expect(result.current[0]!.data).toBeUndefined(); + // Another host of the same provider gets it as a provisional stand-in. + expect(result.current[1]!.isPlaceholderData).toBe(true); + expect(result.current[1]!.data?.models).toEqual([CODEX_MODEL]); + }); + it("retries one transient failure before surfacing model selector errors", async () => { vi.mocked(sdk.system.executionOptions) .mockRejectedValueOnce(new TypeError("Failed to fetch")) diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 5dd69046b9..3d72d79a7d 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -4,6 +4,7 @@ import { listClaudeCodeFallbackModels, } from "@bb/agent-providers"; import { toRecord } from "@bb/core-ui"; +import { permissionModeValues, type PermissionMode } from "@bb/domain"; import type { SystemCliSkillsStatusResponse, SystemConfigResponse, @@ -18,10 +19,15 @@ import type { import type { ProviderUsageResponse } from "@bb/host-daemon-contract"; import { BbHttpError, sdk } from "@/lib/sdk"; import { - claudeModelCatalogCacheKey, - readCachedClaudeModelCatalog, - writeCachedClaudeModelCatalog, -} from "@/lib/claude-model-catalog-cache"; + modelCatalogCacheKey, + readCachedModelCatalog, + writeCachedModelCatalog, +} from "@/lib/model-catalog-cache"; +import { + providerListCacheKey, + readCachedProviderList, + writeCachedProviderList, +} from "@/lib/provider-list-cache"; import { useSystemRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; import { hostProviderCliStatusQueryKey, @@ -61,28 +67,72 @@ const SYSTEM_EXECUTION_OPTIONS_RETRY_DELAY_MS = 250; const SYSTEM_EXECUTION_OPTIONS_RETRY_COUNT = 1; const CLAUDE_CODE_PROVIDER_ID = "claude-code"; -// Claude's account-scoped model probe spawns a CLI process on the host, so -// waiting for it leaves the composer with no model list for seconds. Render a -// provisional catalog immediately and let the authoritative rows replace it when -// the probe lands. +// Model probes run on the host (Claude's spawns a CLI process; every provider +// pays a round trip), so waiting for one leaves the composer with no model list +// for seconds on each full load. Render the last catalog this routing actually +// reported immediately and let the authoritative rows replace it when the probe +// lands: its ids match what the fresh probe will return, so a selection made +// during the preload window survives instead of snapping back to a default. +// +// On a cold cache only Claude Code has curated aliases to fall back on; other +// providers wait for the probe, exactly as before. // -// Prefer the last catalog this account actually reported: its ids match what the -// fresh probe will return, so a selection made during the preload window -// survives instead of snapping back to a default. The curated aliases are only -// for a cold cache, where no account-scoped ids are known yet. +// The provider list rides along from its own last-known cache: the live list +// carries the host's custom and installed ACP agents, so replaying only the +// built-in providers would select the first built-in one for a beat whenever +// the remembered provider is not built in. If the remembered provider is not +// in the list we can replay, there is no honest provisional frame and the +// composer waits, as it did before. // // Callers must gate model recovery on `isPlaceholderData` either way: a cached // catalog can be stale, so absence from this list is not evidence that a stored // model was retired. -function claudeCodePlaceholderExecutionOptions( - cacheKey: string, -): SystemExecutionOptionsResponse { - const cached = readCachedClaudeModelCatalog(cacheKey); +// +// The placeholder's permission ceiling is the most restrictive mode. Consumers +// ignore the ceiling while data is provisional, so the value is never used — +// but a replay must fail safe if a future reader forgets that gate. +const PLACEHOLDER_PERMISSION_CEILING: PermissionMode = permissionModeValues[0]; + +function placeholderExecutionOptions({ + cacheKey, + providerCacheKey, + providersCacheKey, + providerId, +}: { + cacheKey: string; + providerCacheKey: string | null; + providersCacheKey: string; + providerId: string | null; +}): SystemExecutionOptionsResponse | undefined { + // The routed key is exact; the provider key holds the latest verified + // catalog for the provider from any routing. A composer can mount before its + // environment is known (a thread page still loading), so its first key may + // never have been fetched to completion — the provider's latest catalog is a + // fine provisional stand-in for that frame. + const cached = + readCachedModelCatalog(cacheKey) ?? + (providerCacheKey === null + ? null + : readCachedModelCatalog(providerCacheKey)); + if (cached === null && providerId !== CLAUDE_CODE_PROVIDER_ID) { + return undefined; + } + const remembered = readCachedProviderList(providersCacheKey); + const providers = + remembered !== null && remembered.length > 0 + ? remembered + : listBuiltInAgentProviderInfos(); + if ( + providerId !== null && + !providers.some((provider) => provider.id === providerId) + ) { + return undefined; + } return { - providers: listBuiltInAgentProviderInfos(), + providers, models: cached?.models ?? listClaudeCodeFallbackModels(), selectedOnlyModels: cached?.selectedOnlyModels ?? [], - permissionCeiling: "full", + permissionCeiling: PLACEHOLDER_PERMISSION_CEILING, modelLoadError: null, }; } @@ -118,11 +168,16 @@ export function useSystemExecutionOptions( const providerId = args.providerId ?? null; const enabled = args.enabled ?? true; useSystemRealtimeSubscription({ enabled }); - const isClaudeCode = providerId === CLAUDE_CODE_PROVIDER_ID; - const catalogCacheKey = claudeModelCatalogCacheKey({ + const providersCacheKey = providerListCacheKey({ environmentId, hostId }); + const catalogCacheKey = modelCatalogCacheKey({ environmentId, hostId, + providerId, }); + const providerCatalogCacheKey = + providerId === null + ? null + : modelCatalogCacheKey({ environmentId: null, hostId: null, providerId }); return useQuery({ queryKey: systemExecutionOptionsQueryKey({ @@ -137,14 +192,21 @@ export function useSystemExecutionOptions( providerId: args.providerId, signal, }); - // Only a verified catalog is worth remembering. Caching a provisional list - // would let the server's probe-failure fallback masquerade as this - // account's real models on the next cold load. - if (isClaudeCode && response.modelLoadError === null) { - writeCachedClaudeModelCatalog(catalogCacheKey, { + // The provider list is authoritative whether or not the model probe + // succeeded. Only a verified catalog is worth remembering, though: + // caching a provisional list would let the server's probe-failure + // fallback masquerade as this routing's real models on the next cold + // load. + writeCachedProviderList(providersCacheKey, response.providers); + if (response.modelLoadError === null) { + const catalog = { models: response.models, selectedOnlyModels: response.selectedOnlyModels, - }); + }; + writeCachedModelCatalog(catalogCacheKey, catalog); + if (providerCatalogCacheKey !== null) { + writeCachedModelCatalog(providerCatalogCacheKey, catalog); + } } return response; }, @@ -152,12 +214,13 @@ export function useSystemExecutionOptions( staleTime: 60_000, retry: shouldRetrySystemExecutionOptions, retryDelay: SYSTEM_EXECUTION_OPTIONS_RETRY_DELAY_MS, - ...(isClaudeCode - ? { - placeholderData: () => - claudeCodePlaceholderExecutionOptions(catalogCacheKey), - } - : {}), + placeholderData: () => + placeholderExecutionOptions({ + cacheKey: catalogCacheKey, + providerCacheKey: providerCatalogCacheKey, + providersCacheKey, + providerId, + }), }); } diff --git a/apps/app/src/hooks/queries/thread-default-execution-options-query.test.tsx b/apps/app/src/hooks/queries/thread-default-execution-options-query.test.tsx new file mode 100644 index 0000000000..1100a6ae6d --- /dev/null +++ b/apps/app/src/hooks/queries/thread-default-execution-options-query.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ResolvedThreadExecutionOptions } from "@bb/domain"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sdk } from "@/lib/sdk"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { useThreadDefaultExecutionOptions } from "./thread-default-execution-options-query"; + +vi.mock("@/lib/sdk", () => ({ + sdk: { + threads: { + defaultExecutionOptions: vi.fn(), + }, + }, +})); + +vi.mock("@/hooks/useRealtimeSubscription", () => ({ + useThreadDetailRealtimeSubscription: vi.fn(), +})); + +const RESOLVED: ResolvedThreadExecutionOptions = { + model: "gpt-5.6-sol", + serviceTier: "default", + reasoningLevel: "xhigh", + permissionMode: "full", + source: "client/turn/start", +}; + +/** A request that never settles, so the pre-fetch render is observable. */ +const pendingForever = () => new Promise(() => {}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +describe("useThreadDefaultExecutionOptions", () => { + it("replays the thread's last resolution as placeholder data on the next mount", async () => { + vi.mocked(sdk.threads.defaultExecutionOptions).mockResolvedValue(RESOLVED); + const first = createQueryClientTestHarness(); + const warm = renderHook(() => useThreadDefaultExecutionOptions("thr_1"), { + wrapper: first.wrapper, + }); + await waitFor(() => expect(warm.result.current.data).toEqual(RESOLVED)); + expect(warm.result.current.isPlaceholderData).toBe(false); + warm.unmount(); + + // A full page load starts with an empty query cache. + vi.mocked(sdk.threads.defaultExecutionOptions).mockImplementation( + pendingForever, + ); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => useThreadDefaultExecutionOptions("thr_1"), + { wrapper: reload.wrapper }, + ); + expect(result.current.data).toEqual(RESOLVED); + // Provisional: consumers keep submission gated on this flag. + expect(result.current.isPlaceholderData).toBe(true); + await waitFor(() => + expect(sdk.threads.defaultExecutionOptions).toHaveBeenCalledWith( + expect.objectContaining({ threadId: "thr_1" }), + ), + ); + }); + + it("does not replay one thread's resolution for another", async () => { + vi.mocked(sdk.threads.defaultExecutionOptions).mockResolvedValue(RESOLVED); + const first = createQueryClientTestHarness(); + const warm = renderHook(() => useThreadDefaultExecutionOptions("thr_1"), { + wrapper: first.wrapper, + }); + await waitFor(() => expect(warm.result.current.data).toEqual(RESOLVED)); + warm.unmount(); + + vi.mocked(sdk.threads.defaultExecutionOptions).mockImplementation( + pendingForever, + ); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => useThreadDefaultExecutionOptions("thr_2"), + { wrapper: reload.wrapper }, + ); + expect(result.current.data).toBeUndefined(); + expect(result.current.isPlaceholderData).toBe(false); + }); + + it("does not remember an unresolved (null) answer", async () => { + vi.mocked(sdk.threads.defaultExecutionOptions).mockResolvedValue(null); + const first = createQueryClientTestHarness(); + const warm = renderHook(() => useThreadDefaultExecutionOptions("thr_1"), { + wrapper: first.wrapper, + }); + await waitFor(() => expect(warm.result.current.data).toBeNull()); + warm.unmount(); + + vi.mocked(sdk.threads.defaultExecutionOptions).mockImplementation( + pendingForever, + ); + const reload = createQueryClientTestHarness(); + const { result } = renderHook( + () => useThreadDefaultExecutionOptions("thr_1"), + { wrapper: reload.wrapper }, + ); + expect(result.current.data).toBeUndefined(); + }); + + it("ignores a stored value that no longer matches the schema", async () => { + window.localStorage.setItem( + "bb.thread-execution-options.1.thr_1", + JSON.stringify({ model: "gpt-5.6-sol", reasoningLevel: "cosmic" }), + ); + vi.mocked(sdk.threads.defaultExecutionOptions).mockImplementation( + pendingForever, + ); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => useThreadDefaultExecutionOptions("thr_1"), + { wrapper }, + ); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/apps/app/src/hooks/queries/thread-default-execution-options-query.ts b/apps/app/src/hooks/queries/thread-default-execution-options-query.ts index 1d0a30ec59..23641c3c70 100644 --- a/apps/app/src/hooks/queries/thread-default-execution-options-query.ts +++ b/apps/app/src/hooks/queries/thread-default-execution-options-query.ts @@ -1,6 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import type { ResolvedThreadExecutionOptions } from "@bb/domain"; import { sdk } from "@/lib/sdk"; +import { + readCachedThreadExecutionOptions, + threadExecutionOptionsCacheKey, + writeCachedThreadExecutionOptions, +} from "@/lib/thread-execution-options-cache"; import { useThreadDetailRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; import { requireEnabledQueryArg } from "./query-helpers"; import { threadDefaultExecutionOptionsQueryKey } from "./query-keys"; @@ -25,11 +30,24 @@ function requireThreadId(id: string, hookName: string): string { return requireEnabledQueryArg({ value: id, hookName, argName: "thread id" }); } -export function fetchThreadDefaultExecutionOptions( +export async function fetchThreadDefaultExecutionOptions( threadId: string, signal?: AbortSignal, ): Promise { - return sdk.threads.defaultExecutionOptions({ threadId, signal }); + const options = await sdk.threads.defaultExecutionOptions({ + threadId, + signal, + }); + // Remember a real resolution so the next mount of this thread paints it + // immediately. Null means the server could not resolve options; there is + // nothing worth replaying then. + if (options !== null) { + writeCachedThreadExecutionOptions( + threadExecutionOptionsCacheKey(threadId), + options, + ); + } + return options; } export function useThreadDefaultExecutionOptions( @@ -50,5 +68,16 @@ export function useThreadDefaultExecutionOptions( refetchOnMount: options?.refetchOnMount ?? true, ...REALTIME_OWNED_NO_FOCUS_QUERY_POLICY, staleTime: options?.staleTime, + // Composers read model/reasoning/permission defaults from this query, so + // a full page load otherwise paints neutral defaults for a beat and then + // snaps to the thread's real settings. Replay the last resolution as + // placeholder data; consumers keep submission gated on `isPlaceholderData` + // so nothing runs on a stale replay. + placeholderData: () => + id + ? (readCachedThreadExecutionOptions( + threadExecutionOptionsCacheKey(id), + ) ?? undefined) + : undefined, }); } diff --git a/apps/app/src/hooks/usePluginFrontendBoot.test.tsx b/apps/app/src/hooks/usePluginFrontendBoot.test.tsx new file mode 100644 index 0000000000..70191c4cf6 --- /dev/null +++ b/apps/app/src/hooks/usePluginFrontendBoot.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { cleanup, renderHook } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + resetPluginFrontendBootStateForTest, + usePluginFrontendsSettled, +} from "../lib/plugin-frontend-boot-state"; +import { + PLUGIN_FRONTEND_SETTLE_FLOOR_MS, + usePluginFrontendBoot, +} from "./usePluginFrontendBoot"; + +// System config never resolves in this test: the boot must not wait forever. +vi.mock("./queries/system-queries", () => ({ + useSystemConfig: () => ({ data: undefined }), +})); +vi.mock("../lib/plugin-frontend-lazy", () => ({ + bootPluginFrontends: vi.fn(async () => {}), +})); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + resetPluginFrontendBootStateForTest(); +}); + +describe("usePluginFrontendBoot", () => { + it("settles after the floor even when system config never resolves", () => { + vi.useFakeTimers(); + const { result } = renderHook(() => { + usePluginFrontendBoot(); + return usePluginFrontendsSettled(); + }); + expect(result.current).toBe(false); + act(() => vi.advanceTimersByTime(PLUGIN_FRONTEND_SETTLE_FLOOR_MS - 1)); + expect(result.current).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(result.current).toBe(true); + }); +}); diff --git a/apps/app/src/hooks/usePluginFrontendBoot.ts b/apps/app/src/hooks/usePluginFrontendBoot.ts index 326b2062f2..ad45fd70eb 100644 --- a/apps/app/src/hooks/usePluginFrontendBoot.ts +++ b/apps/app/src/hooks/usePluginFrontendBoot.ts @@ -1,7 +1,16 @@ import { useEffect } from "react"; +import { markPluginFrontendsSettled } from "../lib/plugin-frontend-boot-state"; import { bootPluginFrontends } from "../lib/plugin-frontend-lazy"; import { useSystemConfig } from "./queries/system-queries"; +/** + * Boot waits for system config; if that never resolves (backend down), plugin + * routes would otherwise stay blank forever. After this long, treat the boot + * as settled so a missing panel can say so — a later boot still registers + * panels normally. + */ +export const PLUGIN_FRONTEND_SETTLE_FLOOR_MS = 15_000; + /** * Load plugin frontend bundles (plugin design §5.1) once per page load, * after system config resolves — the loading never delays first paint. @@ -16,4 +25,11 @@ export function usePluginFrontendBoot(): void { useEffect(() => { if (resolved) void bootPluginFrontends(); }, [resolved]); + useEffect(() => { + const timeout = window.setTimeout( + markPluginFrontendsSettled, + PLUGIN_FRONTEND_SETTLE_FLOOR_MS, + ); + return () => window.clearTimeout(timeout); + }, []); } diff --git a/apps/app/src/lib/claude-model-catalog-cache.ts b/apps/app/src/lib/claude-model-catalog-cache.ts deleted file mode 100644 index c1ae69447e..0000000000 --- a/apps/app/src/lib/claude-model-catalog-cache.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { availableModelSchema } from "@bb/domain"; -import { z } from "zod"; -import { createJsonLocalStorage } from "@/lib/browser-storage"; - -const CLAUDE_MODEL_CATALOG_CACHE_PREFIX = "bb.claude-model-catalog"; -const CLAUDE_MODEL_CATALOG_CACHE_VERSION = "1"; - -const cachedClaudeModelCatalogSchema = z.object({ - models: z.array(availableModelSchema), - selectedOnlyModels: z.array(availableModelSchema), -}); - -export type CachedClaudeModelCatalog = z.infer< - typeof cachedClaudeModelCatalogSchema ->; - -interface ClaudeModelCatalogCacheKeyArgs { - environmentId: string | null; - hostId: string | null; -} - -const catalogStorage = createJsonLocalStorage(); - -/** - * Scoped to the same routing dimensions as the execution-options query, because - * two hosts can be signed into different Claude accounts with different - * entitlements. The version segment lets a shape change invalidate old entries - * instead of relying on the parse below to reject every one of them. - */ -export function claudeModelCatalogCacheKey({ - environmentId, - hostId, -}: ClaudeModelCatalogCacheKeyArgs): string { - return [ - CLAUDE_MODEL_CATALOG_CACHE_PREFIX, - CLAUDE_MODEL_CATALOG_CACHE_VERSION, - environmentId ?? "-", - hostId ?? "-", - ].join("."); -} - -/** - * The last catalog a successful account-scoped probe returned, used to preload - * the picker with this account's real model ids rather than generic aliases. A - * cached catalog can be stale, so callers must keep reporting preloaded rows as - * provisional: only a fresh probe may retire a stored selection. - */ -export function readCachedClaudeModelCatalog( - key: string, -): CachedClaudeModelCatalog | null { - const stored = catalogStorage.getItem(key, null); - if (stored === null) { - return null; - } - const parsed = cachedClaudeModelCatalogSchema.safeParse(stored); - return parsed.success ? parsed.data : null; -} - -export function writeCachedClaudeModelCatalog( - key: string, - catalog: CachedClaudeModelCatalog, -): void { - catalogStorage.setItem(key, catalog); -} diff --git a/apps/app/src/lib/last-known-cache.test.ts b/apps/app/src/lib/last-known-cache.test.ts new file mode 100644 index 0000000000..ffdf07fd92 --- /dev/null +++ b/apps/app/src/lib/last-known-cache.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { createLastKnownCache } from "./last-known-cache"; + +const schema = z.object({ models: z.array(z.string()) }); + +afterEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("createLastKnownCache", () => { + it("round-trips a value under a scoped, versioned key", () => { + const cache = createLastKnownCache({ + prefix: "bb.test", + version: "1", + schema, + }); + const key = cache.key("env-1", null, "codex"); + expect(key).toBe("bb.test.1.env-1.-.codex"); + cache.write(key, { models: ["a"] }); + expect(cache.read(key)).toEqual({ models: ["a"] }); + }); + + it("treats a stored value that fails the schema as absent", () => { + const cache = createLastKnownCache({ + prefix: "bb.test", + version: "1", + schema, + }); + window.localStorage.setItem( + cache.key("x"), + JSON.stringify({ models: "nope" }), + ); + expect(cache.read(cache.key("x"))).toBeNull(); + }); + + it("swallows storage failures on write instead of throwing", () => { + const cache = createLastKnownCache({ + prefix: "bb.test", + version: "1", + schema, + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("quota", "QuotaExceededError"); + }); + expect(() => cache.write(cache.key("x"), { models: [] })).not.toThrow(); + expect(cache.read(cache.key("x"))).toBeNull(); + }); + + it("treats storage that cannot be read as absent instead of throwing", () => { + const cache = createLastKnownCache({ + prefix: "bb.test", + version: "1", + schema, + }); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new DOMException("blocked", "SecurityError"); + }); + expect(cache.read(cache.key("x"))).toBeNull(); + // Reads on a restricted store must not poison later writes either. + vi.restoreAllMocks(); + cache.write(cache.key("x"), { models: ["c"] }); + expect(cache.read(cache.key("x"))).toEqual({ models: ["c"] }); + }); + + it("never prunes its own zero-scope entry on a fresh load", () => { + // A cache with no routing dimensions stores under the bare version key. + // Each page load constructs the cache anew and prunes once; the entry + // written by the previous load must survive that prune, or the replay + // is deleted before its first read on every visit. + const config = { prefix: "bb.test", version: "1", schema } as const; + const firstLoad = createLastKnownCache(config); + firstLoad.write(firstLoad.key(), { models: ["kept"] }); + + const nextLoad = createLastKnownCache(config); + expect(nextLoad.read(nextLoad.key())).toEqual({ models: ["kept"] }); + expect(window.localStorage.getItem("bb.test.1")).not.toBeNull(); + }); + + it("prunes entries written under another version of the same cache", () => { + window.localStorage.setItem( + "bb.test.0.old", + JSON.stringify({ models: [] }), + ); + window.localStorage.setItem("bb.other.0.keep", "1"); + const cache = createLastKnownCache({ + prefix: "bb.test", + version: "1", + schema, + }); + cache.write(cache.key("new"), { models: ["b"] }); + expect(window.localStorage.getItem("bb.test.0.old")).toBeNull(); + expect(window.localStorage.getItem("bb.other.0.keep")).toBe("1"); + expect(cache.read(cache.key("new"))).toEqual({ models: ["b"] }); + }); +}); diff --git a/apps/app/src/lib/last-known-cache.ts b/apps/app/src/lib/last-known-cache.ts new file mode 100644 index 0000000000..4f520d0850 --- /dev/null +++ b/apps/app/src/lib/last-known-cache.ts @@ -0,0 +1,100 @@ +import type { z } from "zod"; +import { createJsonLocalStorage } from "@/lib/browser-storage"; + +export interface LastKnownCache { + /** + * Storage key for one scope of this cache. `null` parts (an unset routing + * dimension) serialize as "-" so the key shape stays fixed. + */ + key(...scope: ReadonlyArray): string; + /** The remembered value, or null when absent, malformed, or unreadable. */ + read(key: string): T | null; + /** Best-effort: storage failures (quota, privacy modes) are swallowed. */ + write(key: string, value: T): void; +} + +/** + * A localStorage cache for "last-known truth": the last verified answer a + * surface received, replayed on the next mount so first paint shows real data + * instead of a neutral default that the live answer then replaces. + * + * Contract for consumers: + * - Treat replayed values as provisional (TanStack `placeholderData`, + * snapshot-seeded state) and keep every irreversible action gated on the + * live result. + * - Write only verified results, never a fallback or an error-state stand-in. + * - Bump `version` when the stored shape changes; entries from other versions + * are pruned on first use, and every read is validated against `schema` so a + * stale or hand-edited entry can never leak into the app as trusted data. + * + * Neither reads nor writes throw: a full store or a restricted browser (a + * SecurityError on `localStorage` itself) degrades to "no cache", which is + * what a cache is for. In particular a write inside a query function must + * never turn a successful fetch into a query error, and a read during render + * must never take the surface down. + */ +export function createLastKnownCache({ + prefix, + version, + schema, +}: { + prefix: string; + version: string; + schema: z.ZodType; +}): LastKnownCache { + const storage = createJsonLocalStorage(); + // A cache with no routing dimensions stores under the bare version key, so + // the prune must spare it as well as the dotted scope namespace: pruning + // "everything under my prefix that is not my version" would otherwise eat + // the cache's own entry on the next page load. + const zeroScopeKey = `${prefix}.${version}`; + const versionPrefix = `${zeroScopeKey}.`; + let pruned = false; + const pruneOtherVersions = () => { + if (pruned) return; + pruned = true; + try { + const stale: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const stored = window.localStorage.key(index); + if ( + stored !== null && + stored.startsWith(`${prefix}.`) && + stored !== zeroScopeKey && + !stored.startsWith(versionPrefix) + ) { + stale.push(stored); + } + } + for (const key of stale) window.localStorage.removeItem(key); + } catch { + // No storage, or none we may enumerate: nothing to prune. + } + }; + return { + key: (...scope) => + [prefix, version, ...scope.map((part) => part ?? "-")].join("."), + read: (key) => { + try { + pruneOtherVersions(); + const stored = storage.getItem(key, null); + if (stored === null) return null; + const parsed = schema.safeParse(stored); + return parsed.success ? parsed.data : null; + } catch { + // Unreadable storage (a SecurityError on the accessor, a blocked + // getItem) is "no cache", by contract; a read must never turn into a + // render or query failure. + return null; + } + }, + write: (key, value) => { + pruneOtherVersions(); + try { + storage.setItem(key, value); + } catch { + // Best-effort by contract; see above. + } + }, + }; +} diff --git a/apps/app/src/lib/model-catalog-cache.ts b/apps/app/src/lib/model-catalog-cache.ts new file mode 100644 index 0000000000..edf367bf46 --- /dev/null +++ b/apps/app/src/lib/model-catalog-cache.ts @@ -0,0 +1,40 @@ +import { availableModelSchema } from "@bb/domain"; +import { z } from "zod"; +import { createLastKnownCache } from "@/lib/last-known-cache"; + +const cachedModelCatalogSchema = z.object({ + models: z.array(availableModelSchema), + selectedOnlyModels: z.array(availableModelSchema), +}); + +export type CachedModelCatalog = z.infer; + +/** + * The last catalog a successful probe returned, keyed by the same routing + * dimensions as the execution-options query: two hosts can be signed into + * different accounts with different entitlements, and each provider reports + * its own catalog. Used to preload the picker with real model ids rather than + * a loading placeholder. A cached catalog can be stale, so callers must keep + * reporting preloaded rows as provisional: only a fresh probe may retire a + * stored selection. + */ +const modelCatalogCache = createLastKnownCache({ + prefix: "bb.model-catalog", + version: "1", + schema: cachedModelCatalogSchema, +}); + +export function modelCatalogCacheKey({ + environmentId, + hostId, + providerId, +}: { + environmentId: string | null; + hostId: string | null; + providerId: string | null; +}): string { + return modelCatalogCache.key(environmentId, hostId, providerId); +} + +export const readCachedModelCatalog = modelCatalogCache.read; +export const writeCachedModelCatalog = modelCatalogCache.write; diff --git a/apps/app/src/lib/plugin-frontend-boot-state.ts b/apps/app/src/lib/plugin-frontend-boot-state.ts new file mode 100644 index 0000000000..4158868927 --- /dev/null +++ b/apps/app/src/lib/plugin-frontend-boot-state.ts @@ -0,0 +1,37 @@ +import { useSyncExternalStore } from "react"; + +/** + * Whether the page's plugin frontends have finished their first load (well or + * badly). Plugin registrations arrive after first paint, so a route that + * depends on a registration cannot tell "not loaded yet" from "not installed" + * on its own; this flag is the difference between a quiet placeholder and an + * error message that is wrong for a few hundred milliseconds on every reload. + */ +let settled = false; +const listeners = new Set<() => void>(); + +export function markPluginFrontendsSettled(): void { + if (settled) return; + settled = true; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): boolean { + return settled; +} + +export function usePluginFrontendsSettled(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** Test-only. */ +export function resetPluginFrontendBootStateForTest(): void { + settled = false; +} diff --git a/apps/app/src/lib/plugin-frontend-lazy.ts b/apps/app/src/lib/plugin-frontend-lazy.ts index 31a416dc36..9082b37d71 100644 --- a/apps/app/src/lib/plugin-frontend-lazy.ts +++ b/apps/app/src/lib/plugin-frontend-lazy.ts @@ -12,6 +12,8 @@ * plugin management UI are already lazy and import it directly; they share * this module instance, so the reconcile state stays single-owner. */ +import { markPluginFrontendsSettled } from "./plugin-frontend-boot-state"; + type PluginFrontendModule = typeof import("./plugin-frontend"); /** @@ -57,6 +59,10 @@ export async function bootPluginFrontends(): Promise { console.warn( `plugin runtime load failed: ${error instanceof Error ? error.message : String(error)}`, ); + } finally { + // Either way the registrations are as complete as this load will make + // them; routes waiting on a plugin may now report it missing. + markPluginFrontendsSettled(); } } diff --git a/apps/app/src/lib/plugin-nav-panel-chrome.test.tsx b/apps/app/src/lib/plugin-nav-panel-chrome.test.tsx new file mode 100644 index 0000000000..4693d4cb78 --- /dev/null +++ b/apps/app/src/lib/plugin-nav-panel-chrome.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + markPluginFrontendsSettled, + resetPluginFrontendBootStateForTest, +} from "./plugin-frontend-boot-state"; +import { + readLastKnownPluginNavPanelChrome, + usePluginNavPanelChrome, + useRememberPluginNavPanelChrome, + writeLastKnownPluginNavPanelChrome, +} from "./plugin-nav-panel-chrome"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, + type PluginRegistrationSet, +} from "./plugin-slots"; + +function Body() { + return null; +} + +function registrations( + navPanels: PluginRegistrationSet["navPanels"], +): PluginRegistrationSet { + return { + homepageSections: [], + settingsSections: [], + navPanels, + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + }; +} + +const TASKS = { + pluginId: "tasks", + id: "tasks", + path: "tasks", + title: "Tasks", + icon: "ListTodo", +}; +const DOCS = { + pluginId: "docs", + id: "docs", + path: "docs", + title: "Docs", + icon: "Book", +}; + +afterEach(() => { + cleanup(); + resetPluginSlotStoreForTest(); + resetPluginFrontendBootStateForTest(); + window.localStorage.clear(); +}); + +describe("usePluginNavPanelChrome", () => { + it("draws remembered chrome before boot and swaps to the live registration in place", () => { + writeLastKnownPluginNavPanelChrome([TASKS, DOCS]); + const { result } = renderHook(() => usePluginNavPanelChrome()); + expect(result.current.map((entry) => entry.chrome.title)).toEqual([ + "Tasks", + "Docs", + ]); + expect(result.current.every((entry) => entry.panel === null)).toBe(true); + + // Tasks registers first: it takes over its own slot; Docs stays remembered. + act(() => + setPluginSlotRegistrations( + "tasks", + registrations([ + { + id: "tasks", + path: "tasks", + title: "Tasks", + icon: "ListTodo", + component: Body, + }, + ]), + ), + ); + expect(result.current.map((entry) => entry.chrome.title)).toEqual([ + "Tasks", + "Docs", + ]); + expect(result.current[0]!.panel).not.toBeNull(); + expect(result.current[1]!.panel).toBeNull(); + }); + + it("forgets remembered panels that never registered once frontends settle", () => { + writeLastKnownPluginNavPanelChrome([TASKS, DOCS]); + act(() => + setPluginSlotRegistrations( + "tasks", + registrations([ + { + id: "tasks", + path: "tasks", + title: "Tasks", + icon: "ListTodo", + component: Body, + }, + ]), + ), + ); + const { result } = renderHook(() => usePluginNavPanelChrome()); + expect(result.current).toHaveLength(2); + act(() => markPluginFrontendsSettled()); + expect(result.current.map((entry) => entry.chrome.title)).toEqual([ + "Tasks", + ]); + }); + + it("appends live panels the profile had not seen before", () => { + writeLastKnownPluginNavPanelChrome([TASKS]); + act(() => + setPluginSlotRegistrations( + "docs", + registrations([ + { + id: "docs", + path: "docs", + title: "Docs", + icon: "Book", + component: Body, + }, + ]), + ), + ); + const { result } = renderHook(() => usePluginNavPanelChrome()); + expect(result.current.map((entry) => entry.chrome.title)).toEqual([ + "Tasks", + "Docs", + ]); + }); +}); + +describe("useRememberPluginNavPanelChrome", () => { + it("writes the live panels only after frontends have settled, and follows later changes", () => { + act(() => + setPluginSlotRegistrations( + "tasks", + registrations([ + { + id: "tasks", + path: "tasks", + title: "Tasks", + icon: "ListTodo", + component: Body, + }, + ]), + ), + ); + renderHook(() => useRememberPluginNavPanelChrome()); + expect(readLastKnownPluginNavPanelChrome()).toEqual([]); + + act(() => markPluginFrontendsSettled()); + expect(readLastKnownPluginNavPanelChrome()).toEqual([TASKS]); + + // An uninstall after settle is remembered too, so it does not come back + // as a ghost row on the next load. + act(() => setPluginSlotRegistrations("tasks", registrations([]))); + expect(readLastKnownPluginNavPanelChrome()).toEqual([]); + }); +}); diff --git a/apps/app/src/lib/plugin-nav-panel-chrome.ts b/apps/app/src/lib/plugin-nav-panel-chrome.ts new file mode 100644 index 0000000000..cc48ac2b58 --- /dev/null +++ b/apps/app/src/lib/plugin-nav-panel-chrome.ts @@ -0,0 +1,120 @@ +import { useEffect, useMemo } from "react"; +import { z } from "zod"; +import { createLastKnownCache } from "@/lib/last-known-cache"; +import { usePluginFrontendsSettled } from "@/lib/plugin-frontend-boot-state"; +import { usePluginSlots, type PluginNavPanelSlot } from "@/lib/plugin-slots"; + +const pluginNavPanelChromeSchema = z.object({ + pluginId: z.string().min(1), + id: z.string().min(1), + path: z.string().min(1), + title: z.string(), + icon: z.string(), +}); + +/** + * The host-owned chrome of a plugin `navPanel` registration: everything the + * app header, the sidebar row, and a split pane header need to draw the panel + * without its component. Serializable, so it can be remembered across loads. + */ +export type PluginNavPanelChrome = z.infer; + +export interface PluginNavPanelChromeEntry { + chrome: PluginNavPanelChrome; + /** The live registration, or null while this entry is a remembered one. */ + panel: PluginNavPanelSlot | null; +} + +/** + * Registrations arrive only after plugin frontends boot, well after first + * paint, so a reload used to draw the header title, the sidebar's plugin rows, + * and split-pane titles empty and then pop them in. Remember the chrome of the + * panels this profile last saw and paint it first; live registrations replace + * it under the same keys once they arrive, so a matching plugin reconciles in + * place. Panel bodies never replay — a remembered row navigates to a route + * that stays quiet until the plugin loads. + * + * One entry per profile: a bb origin serves one server, and bb connect / the + * desktop app give each server its own origin, so scoping is unnecessary. + */ +const chromeCache = createLastKnownCache({ + prefix: "bb.plugin-nav-panels", + version: "1", + schema: z.array(pluginNavPanelChromeSchema), +}); +const CHROME_CACHE_KEY = chromeCache.key("all"); + +export function pluginNavPanelChromeOf( + panel: PluginNavPanelSlot, +): PluginNavPanelChrome { + return { + pluginId: panel.pluginId, + id: panel.id, + path: panel.path, + title: panel.title, + icon: panel.icon, + }; +} + +function chromeKey(chrome: Pick) { + return `${chrome.pluginId}/${chrome.id}`; +} + +export function readLastKnownPluginNavPanelChrome(): PluginNavPanelChrome[] { + return chromeCache.read(CHROME_CACHE_KEY) ?? []; +} + +export function writeLastKnownPluginNavPanelChrome( + chrome: readonly PluginNavPanelChrome[], +): void { + chromeCache.write(CHROME_CACHE_KEY, [...chrome]); +} + +/** + * The nav panels to draw right now: live registrations, plus — until plugin + * frontends have settled — remembered chrome for panels that have not + * registered yet, in the order they were last seen. After settle the list is + * live registrations only, so a removed plugin does not linger. + */ +export function usePluginNavPanelChrome(): PluginNavPanelChromeEntry[] { + const settled = usePluginFrontendsSettled(); + const { navPanels } = usePluginSlots(); + // Read once per boot phase; the store is not subscribed to because only + // this app writes it, and it writes after settle. + const remembered = useMemo( + () => (settled ? [] : readLastKnownPluginNavPanelChrome()), + [settled], + ); + return useMemo(() => { + const live = navPanels.map((panel) => ({ + chrome: pluginNavPanelChromeOf(panel), + panel, + })); + if (remembered.length === 0) return live; + const liveByKey = new Map( + live.map((entry) => [chromeKey(entry.chrome), entry]), + ); + const entries: PluginNavPanelChromeEntry[] = remembered.map( + (chrome) => liveByKey.get(chromeKey(chrome)) ?? { chrome, panel: null }, + ); + const rememberedKeys = new Set(remembered.map(chromeKey)); + for (const entry of live) { + if (!rememberedKeys.has(chromeKey(entry.chrome))) entries.push(entry); + } + return entries; + }, [navPanels, remembered]); +} + +/** + * Keeps the remembered chrome current: after plugin frontends have settled, + * every change to the live registrations is written back, so the next load + * paints exactly the panels this profile ended with (including none). + */ +export function useRememberPluginNavPanelChrome(): void { + const settled = usePluginFrontendsSettled(); + const { navPanels } = usePluginSlots(); + useEffect(() => { + if (!settled) return; + writeLastKnownPluginNavPanelChrome(navPanels.map(pluginNavPanelChromeOf)); + }, [navPanels, settled]); +} diff --git a/apps/app/src/lib/provider-list-cache.ts b/apps/app/src/lib/provider-list-cache.ts new file mode 100644 index 0000000000..c3da94e384 --- /dev/null +++ b/apps/app/src/lib/provider-list-cache.ts @@ -0,0 +1,32 @@ +import { providerInfoSchema } from "@bb/domain"; +import { z } from "zod"; +import { createLastKnownCache } from "@/lib/last-known-cache"; + +/** + * The provider list the execution-options endpoint last returned for a + * routing (environment, host): the built-in providers plus whatever custom + * and installed ACP agents that host reports. Replayed with the model-catalog + * placeholder so a composer whose selected provider is not built in paints + * that provider from the first frame instead of the first built-in one, and + * so the provider picker does not grow by a few rows when the live answer + * lands. Provisional like every last-known value: consumers keep gating + * irreversible choices on the live response. + */ +const providerListCache = createLastKnownCache({ + prefix: "bb.provider-list", + version: "1", + schema: z.array(providerInfoSchema), +}); + +export function providerListCacheKey({ + environmentId, + hostId, +}: { + environmentId: string | null; + hostId: string | null; +}): string { + return providerListCache.key(environmentId, hostId); +} + +export const readCachedProviderList = providerListCache.read; +export const writeCachedProviderList = providerListCache.write; diff --git a/apps/app/src/lib/sidebar-bootstrap-cache.ts b/apps/app/src/lib/sidebar-bootstrap-cache.ts new file mode 100644 index 0000000000..5cb8c89ea1 --- /dev/null +++ b/apps/app/src/lib/sidebar-bootstrap-cache.ts @@ -0,0 +1,25 @@ +import { sidebarBootstrapResponseSchema } from "@bb/server-contract"; +import { createLastKnownCache } from "@/lib/last-known-cache"; + +/** + * The last sidebar bootstrap this profile received: sections, projects with + * their thread lists, and the personal project. Replayed as placeholder data + * on the next full load so the sidebar (and every surface that reads project + * names from the shared cache) paints the rail this browser last saw instead + * of a loading skeleton the real rows then replace. One entry per origin: the + * endpoint has no routing dimensions. + * + * Provisional like every last-known value: rows are navigation, so a stale + * row degrades to an in-page load failure at worst, and the live response + * replaces the replay in place when it lands. + */ +const sidebarBootstrapCache = createLastKnownCache({ + prefix: "bb.sidebar-bootstrap", + version: "1", + schema: sidebarBootstrapResponseSchema, +}); + +export const SIDEBAR_BOOTSTRAP_CACHE_KEY = sidebarBootstrapCache.key(); + +export const readCachedSidebarBootstrap = sidebarBootstrapCache.read; +export const writeCachedSidebarBootstrap = sidebarBootstrapCache.write; diff --git a/apps/app/src/lib/thread-execution-options-cache.ts b/apps/app/src/lib/thread-execution-options-cache.ts new file mode 100644 index 0000000000..7a34581f83 --- /dev/null +++ b/apps/app/src/lib/thread-execution-options-cache.ts @@ -0,0 +1,29 @@ +import { resolvedThreadExecutionOptionsSchema } from "@bb/domain"; +import { createLastKnownCache } from "@/lib/last-known-cache"; + +/** + * The last execution options the server resolved for a thread (provider, + * model, reasoning, permission mode), replayed to paint the composer's + * controls on the first frame instead of the hook's neutral defaults. The + * server owns the resolution policy; this only remembers its last answer, and + * a replay can be stale, so callers must keep treating it as provisional until + * the live query settles. + * + * Keyed by thread id alone, unlike the model catalog's environment/host/ + * provider scoping: thread ids are globally unique ULIDs, and the resolved + * options already carry the provider and host context that produced them. + */ +const threadExecutionOptionsCache = createLastKnownCache({ + prefix: "bb.thread-execution-options", + version: "1", + schema: resolvedThreadExecutionOptionsSchema, +}); + +export function threadExecutionOptionsCacheKey(threadId: string): string { + return threadExecutionOptionsCache.key(threadId); +} + +export const readCachedThreadExecutionOptions = + threadExecutionOptionsCache.read; +export const writeCachedThreadExecutionOptions = + threadExecutionOptionsCache.write; diff --git a/apps/app/src/views/PluginPanelView.tsx b/apps/app/src/views/PluginPanelView.tsx index 0f1dd88bd3..4d353d8daf 100644 --- a/apps/app/src/views/PluginPanelView.tsx +++ b/apps/app/src/views/PluginPanelView.tsx @@ -9,6 +9,7 @@ import { } from "@/lib/diff-worker-pool"; import { useResolvedCodeThemePair } from "@/lib/code-theme"; import { useSyncPierreWorkerPoolTheme } from "@/lib/pierre-worker-pool-theme"; +import { usePluginFrontendsSettled } from "@/lib/plugin-frontend-boot-state"; import { usePluginSlots } from "@/lib/plugin-slots"; // Plugins can render `@pierre/diffs` FileDiff (the specifier is shimmed to @@ -55,6 +56,7 @@ export function PluginPanelView(props: PluginPanelViewProps = {}) { // The route's trailing splat: panel-internal location ("" at the root). const subPath = props.subPath ?? params["*"] ?? ""; const { navPanels } = usePluginSlots(); + const pluginsSettled = usePluginFrontendsSettled(); const panel = navPanels.find( (candidate) => @@ -62,11 +64,17 @@ export function PluginPanelView(props: PluginPanelViewProps = {}) { ) ?? null; if (panel === null) { + // Registrations arrive after first paint, so on a reload or deep link this + // is the normal state for a moment: stay blank rather than announce a + // problem. Only a settled boot that still has no panel is worth a message. + if (!pluginsSettled) { + return {null}; + } return ( - This plugin panel is not available. The plugin may still be loading, - or it has been disabled or removed. + This plugin panel is not available. The plugin may have been disabled + or removed. ); diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index 232bfd2d64..29935b16fe 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -82,7 +82,7 @@ import { resolveAutomationBreadcrumbs } from "@/components/tools/tools-navigatio import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@/components/ui/chromeStyleTokens"; -import { usePluginSlots } from "@/lib/plugin-slots"; +import { usePluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome"; import { PluginPanelHeaderActions, PluginPanelHeaderCenter, @@ -1021,7 +1021,7 @@ function NonThreadPaneContent({ isTopRow: boolean; ownsWindowTopLeft: boolean; }) { - const { navPanels } = usePluginSlots(); + const navPanelChrome = usePluginNavPanelChrome(); const resourceRouteLabel = useAtomValue(resourceRouteLabelAtom); const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom); const { reservesWindowPanelToggle, isFocused } = useOptionalPaneContext() ?? { @@ -1033,14 +1033,16 @@ function NonThreadPaneContent({ const showsWindowPanelToggle = hostLayout?.pinsCornerToggle === true; const [desktopInfo] = useState(getBbDesktopInfo); const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); - const panel = + const panelEntry = content.kind === "plugin-panel" - ? navPanels.find( + ? navPanelChrome.find( (candidate) => - candidate.pluginId === content.pluginId && - candidate.path === content.panelPath, + candidate.chrome.pluginId === content.pluginId && + candidate.chrome.path === content.panelPath, ) : undefined; + const panel = panelEntry?.panel ?? undefined; + const panelChrome = panelEntry?.chrome; const automationBreadcrumbs = content.kind === "plugin-panel" ? resolveAutomationBreadcrumbs( @@ -1048,7 +1050,7 @@ function NonThreadPaneContent({ isFocused ? resourceRouteLabel : null, ) : null; - const label = panel?.title ?? "New thread"; + const label = panelChrome?.title ?? "New thread"; const handlePointerDown = (event: ReactPointerEvent) => { if ( event.target instanceof Element && @@ -1140,8 +1142,8 @@ function NonThreadPaneContent({ breadcrumbs={automationBreadcrumbs} usesDesktopChrome={usesDesktopChrome} /> - ) : panel ? ( - + ) : panelChrome ? ( + ) : (

From child thread: {item.childTitle} - +

) : (