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 (
     <QuickCreateProjectProvider>
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 } }) => (
-    <span data-testid="plugin-panel-header-center">{panel.title}</span>
+  PluginPanelHeaderCenter: ({ chrome }: { chrome: { title: string } }) => (
+    <span data-testid="plugin-panel-header-center">{chrome.title}</span>
   ),
   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}
       />
     </div>
-  ) : pluginPanel ? (
-    <PluginPanelHeaderCenter panel={pluginPanel} />
+  ) : pluginPanelChrome ? (
+    <PluginPanelHeaderCenter chrome={pluginPanelChrome} />
   ) : hasCenterContent ? (
     <div className="min-w-0 flex-1">
       {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 (
-      <ProjectActionsProvider>
-        <ThreadTitleMentionResourcesProvider {...titleMentionResources}>
-          <ThreadActionsProvider>
-            <IframeDragGuardOverlay active={isSidebarResizing} />
-            <SidebarStateBridge
-              providerRef={providerRef}
-              style={sidebarProviderStyle}
-            >
-              <AppLayoutSidebar
-                mode={
-                  isGlobalSettingsView
-                    ? "settings"
-                    : isGlobalToolsView
-                      ? "tools"
-                      : "app"
-                }
-                onResizeMouseDown={handleResizeMouseDown}
-                isResizing={isSidebarResizing}
-                appRoutePath={appRoutePath}
-                settingsRoutePath={settingsRoutePath}
-                toolsBackRoutePath={toolsBackRoutePath}
-                toolsRoutePath={toolsRoutePath}
-              />
-              <SidebarInset>
-                <div
-                  ref={contentShellRef}
-                  data-testid="app-layout-content-shell"
-                  className="relative flex h-full min-h-0 min-w-0 w-full flex-col pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
-                >
-                  {showHeader ? (
-                    <AppHeader
-                      usesDesktopChrome={usesDesktopChrome}
-                      usesProjectChromeStyle={
-                        isRootView || isArchivedView || isSettingsView
-                      }
-                      isSettingsView={isSettingsView}
-                      projectId={projectId}
-                      project={project}
-                      pluginPanel={pluginPanel}
-                      pluginPanelSubPath={pluginPanelMatch?.params["*"] ?? ""}
-                      meta={meta}
-                    />
-                  ) : null}
-                  <main className="flex min-h-0 flex-1 flex-col p-4 md:p-5">
-                    {children}
-                  </main>
-                </div>
-              </SidebarInset>
-              <SidebarTriggerOverlay
-                reserveMacosTrafficLights={reserveMacosTrafficLights}
-                usesDesktopChrome={usesDesktopChrome}
-              />
-            </SidebarStateBridge>
+    <ProjectActionsProvider>
+      <ThreadTitleMentionResourcesProvider {...titleMentionResources}>
+        <ThreadActionsProvider>
+          <IframeDragGuardOverlay active={isSidebarResizing} />
+          <SidebarStateBridge
+            providerRef={providerRef}
+            style={sidebarProviderStyle}
+          >
+            <AppLayoutSidebar
+              mode={
+                isGlobalSettingsView
+                  ? "settings"
+                  : isGlobalToolsView
+                    ? "tools"
+                    : "app"
+              }
+              onResizeMouseDown={handleResizeMouseDown}
+              isResizing={isSidebarResizing}
+              appRoutePath={appRoutePath}
+              settingsRoutePath={settingsRoutePath}
+              toolsBackRoutePath={toolsBackRoutePath}
+              toolsRoutePath={toolsRoutePath}
+            />
+            <SidebarInset>
+              <div
+                ref={contentShellRef}
+                data-testid="app-layout-content-shell"
+                className="relative flex h-full min-h-0 min-w-0 w-full flex-col pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
+              >
+                {showHeader ? (
+                  <AppHeader
+                    usesDesktopChrome={usesDesktopChrome}
+                    usesProjectChromeStyle={
+                      isRootView || isArchivedView || isSettingsView
+                    }
+                    isSettingsView={isSettingsView}
+                    projectId={projectId}
+                    project={project}
+                    pluginPanel={pluginPanel}
+                    pluginPanelChrome={pluginPanelChrome}
+                    pluginPanelSubPath={pluginPanelMatch?.params["*"] ?? ""}
+                    meta={meta}
+                  />
+                ) : null}
+                <main className="flex min-h-0 flex-1 flex-col p-4 md:p-5">
+                  {children}
+                </main>
+              </div>
+            </SidebarInset>
+            <SidebarTriggerOverlay
+              reserveMacosTrafficLights={reserveMacosTrafficLights}
+              usesDesktopChrome={usesDesktopChrome}
+            />
+          </SidebarStateBridge>
           <ProjectPathDialog
             target={quickCreateProject.projectPathDialog.target}
             pending={quickCreateProject.isCreating}
diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
index 8fdab7dee0..cdab71532c 100644
--- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
+++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
@@ -35,7 +35,10 @@ import { PluginIcon } from "@/components/plugin/PluginIcon";
 import { PluginSlotMount } from "@/components/plugin/PluginSlotMount";
 import { PROJECT_LIST_ACTION_BUTTON_CLASS } from "@/components/sidebar/ProjectList";
 import { getPluginPanelRoutePath } from "@/lib/route-paths";
-import { usePluginSlots } from "@/lib/plugin-slots";
+import {
+  usePluginNavPanelChrome,
+  type PluginNavPanelChrome,
+} from "@/lib/plugin-nav-panel-chrome";
 import { cn } from "@bb/shared-ui/lib/utils";
 import type { PluginNavPanelSlot } from "@/lib/plugin-slots";
 import { usePaneContentSplitDrag } from "@/components/sidebar/usePaneContentSplitDrag";
@@ -101,7 +104,12 @@ type SidebarNavRow =
       pluginId: string;
       id: string;
       title: string;
-      panel: PluginNavPanelSlot;
+      chrome: PluginNavPanelChrome;
+      /**
+       * The live registration; null while the row is drawn from remembered
+       * chrome before plugin frontends have booted (no sidebar accessory).
+       */
+      panel: PluginNavPanelSlot | null;
     };
 
 /**
@@ -126,13 +134,14 @@ export function PluginNavSidebarItems({
   /** Omit when a host surface should render plugin rows without Extensions. */
   toolsRoutePath?: string;
 }) {
-  const { navPanels } = usePluginSlots();
+  const navPanels = usePluginNavPanelChrome();
   const rows = useMemo<SidebarNavRow[]>(() => {
-    const pluginRows = navPanels.map<SidebarNavRow>((panel) => ({
+    const pluginRows = navPanels.map<SidebarNavRow>(({ 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<SidebarNavRowItemProps, "row"> & {
   row: Extract<SidebarNavRow, { kind: "plugin" }>;
 }) {
-  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 ? (
       <PluginSlotMount
         key={`${panel.pluginId}/${panel.id}/${panel.generation}`}
         pluginId={panel.pluginId}
@@ -489,8 +498,8 @@ function PluginNavSidebarItem({
     <SidebarNavRowChrome
       {...props}
       rowKey={getPluginNavPanelKey(row)}
-      title={panel.title}
-      icon={<PluginIcon pluginId={panel.pluginId} icon={panel.icon} />}
+      title={chrome.title}
+      icon={<PluginIcon pluginId={chrome.pluginId} icon={chrome.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<PluginNavPanelChrome, "pluginId" | "icon" | "title">;
 }) {
   return (
     <div className="flex min-w-0 flex-1 items-center gap-2">
       <PluginIcon
-        pluginId={panel.pluginId}
-        icon={panel.icon}
+        pluginId={chrome.pluginId}
+        icon={chrome.icon}
         className="text-muted-foreground"
       />
-      <p className="truncate text-sm font-semibold">{panel.title}</p>
+      <p className="truncate text-sm font-semibold">{chrome.title}</p>
     </div>
   );
 }
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(
+      <MemoryRouter>
+        <PluginNavSidebarItems />
+      </MemoryRouter>,
+    );
+    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(
+      <MemoryRouter>
+        <PluginNavSidebarItems />
+      </MemoryRouter>,
+    );
+    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(
       <MemoryRouter initialEntries={["/plugins/ghost/board"]}>
         <Routes>
@@ -1403,6 +1475,9 @@ describe("PluginNavSidebarItems + PluginPanelView", () => {
         </Routes>
       </MemoryRouter>,
     );
+    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(
       <>
-        <PluginPanelHeaderCenter panel={panel} />
+        <PluginPanelHeaderCenter chrome={panel} />
         <PluginPanelHeaderActions panel={panel} subPath="" />
       </>,
     );
@@ -1464,7 +1539,7 @@ describe("plugin panel shared title bar and full-bleed body", () => {
     const panel = panelSlot({ headerContent: Accessory });
     render(
       <>
-        <PluginPanelHeaderCenter panel={panel} />
+        <PluginPanelHeaderCenter chrome={panel} />
         <PluginPanelHeaderActions panel={panel} subPath="notes/today.md" />
       </>,
     );
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<typeof import("@/lib/api")>();
+  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<never>(() => {});
+
+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<SidebarBootstrapResponse>({
     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<never>(() => {});
+
+  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<SystemExecutionOptionsResponse>({
     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<never>(() => {});
+
+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<ResolvedThreadExecutionOptions | null> {
-  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<unknown>();
-
-/**
- * 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<T> {
+  /**
+   * 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 | null>): 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<T>({
+  prefix,
+  version,
+  schema,
+}: {
+  prefix: string;
+  version: string;
+  schema: z.ZodType<T>;
+}): LastKnownCache<T> {
+  const storage = createJsonLocalStorage<unknown>();
+  // 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<typeof cachedModelCatalogSchema>;
+
+/**
+ * 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<void> {
     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<typeof pluginNavPanelChromeSchema>;
+
+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<PluginNavPanelChrome, "pluginId" | "id">) {
+  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 <PageShell contentClassName="pt-4 md:pt-5">{null}</PageShell>;
+    }
     return (
       <PageShell contentClassName="pt-4 md:pt-5">
         <EmptyStatePanel className="rounded-lg p-6 text-sm">
-          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.
         </EmptyStatePanel>
       </PageShell>
     );
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 ? (
-                <PluginPanelHeaderCenter panel={panel} />
+              ) : panelChrome ? (
+                <PluginPanelHeaderCenter chrome={panelChrome} />
               ) : (
                 <p
                   className={cn(
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index a6ecc31457..84476f1c34 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -354,10 +354,18 @@ export function ThreadDetailPromptArea({
     },
   );
   const defaultExecutionOptions = defaultExecutionOptionsQuery.data;
+  // A replayed (placeholder) resolution seeds the pickers so the first frame
+  // shows the thread's last-known settings, but it is not proof of anything:
+  // submission and the permission controls wait for the live resolution.
+  const verifiedDefaultExecutionOptions =
+    defaultExecutionOptionsQuery.isPlaceholderData
+      ? undefined
+      : defaultExecutionOptions;
   const hasResolvedDefaultExecutionOptions =
-    defaultExecutionOptions !== undefined;
+    verifiedDefaultExecutionOptions !== undefined;
   const hasConcreteDefaultExecutionOptions =
-    defaultExecutionOptions !== undefined && defaultExecutionOptions !== null;
+    verifiedDefaultExecutionOptions !== undefined &&
+    verifiedDefaultExecutionOptions !== null;
   const defaultExecutionOptionsState = resolveDefaultExecutionOptionsState({
     hasConcreteDefaultExecutionOptions,
     hasResolvedDefaultExecutionOptions,
@@ -1421,9 +1429,7 @@ export function ThreadDetailPromptArea({
             >
               From child thread: {item.childTitle}
             </NavLink>
-            <PluginPendingInteractionComposer
-              interaction={item.interaction}
-            />
+            <PluginPendingInteractionComposer interaction={item.interaction} />
           </div>
         ) : (
           <ThreadPendingInteractionBanner
