From cdfab1fff4fac5bda44c6bc18b7e18abfc281b7b Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 8 Oct 2026 20:45:36 +0000 Subject: [PATCH] Make the navigation rail the sidebar's only layout and remove the navigation plugin API The rail was the navigationRail experiment on wide windows. It now draws on every screen size, including inside the compact drawer, and the experiment key is gone. With the rail always on, the navigation rows at the top of the sidebar had no renderer left, so they are deleted along with everything that fed them: - app.slots.experimental_sidebarNavigation, experimental_useSidebarNavigation, experimental_useSidebarNavigationSplit and experimental_SidebarNavigationIcon - app.slots.experimental_sidebarHeader, which only mounted without the rail - the built-in Navigation plugin and the sidebar-navigation example - the sidebar.navigationProvider and sidebar.headerProvider preferences and their Appearance pickers - the footer's built-in Settings action, the Back to app rows, and the non-rail sidebar shells In the compact drawer, Home, Plugins, Skills and Settings swap the list beside the rail and leave the drawer open; a plugin page closes it. Customize rail opens as a sheet from More. PluginNavPanelRegistration.experimental_sidebarAccessory is still accepted, but no host surface mounts it. Co-Authored-By: Claude Code --- .bb/skills/verify-bb/features/settings.md | 3 +- apps/app/.ladle/plugin-sdk-app.ts | 3 - apps/app/.ladle/story-settings-chrome.tsx | 26 +- apps/app/package.json | 1 - .../AppLayout.plugin-panel-header.test.tsx | 15 +- apps/app/src/components/layout/AppLayout.tsx | 21 +- .../layout/AppLayoutSidebar.test.tsx | 193 +- .../components/layout/AppLayoutSidebar.tsx | 109 +- .../plugin/PluginSidebarFooterItems.test.tsx | 39 +- .../plugin/docs-anatomy-manifest.test.tsx | 6 +- .../plugin/plugin-slot-mounts.test.tsx | 211 +- .../theme-preview-fixture-anatomy.test.ts | 6 +- .../settings/SettingsSidebar.test.tsx | 2 - .../components/settings/SettingsSidebar.tsx | 19 - .../settings/SidebarHeaderSetting.tsx | 26 - .../SidebarNavigationSetting.test.tsx | 79 - .../settings/SidebarNavigationSetting.tsx | 28 - .../components/sidebar/AppNavRail.test.tsx | 168 +- .../app/src/components/sidebar/AppNavRail.tsx | 32 +- .../app/src/components/sidebar/AppSidebar.tsx | 171 +- .../sidebar/NavigationPlugin.test.tsx | 1822 ----------------- .../src/components/sidebar/SectionSidebar.tsx | 40 +- .../src/components/sidebar/SidebarChrome.tsx | 30 +- .../sidebar/SidebarHeaderSlot.test.tsx | 213 -- .../components/sidebar/SidebarHeaderSlot.tsx | 97 - .../sidebar/SidebarNavigationCustomize.tsx | 20 +- .../sidebar/SidebarNavigationModel.tsx | 131 +- .../sidebar/SidebarNavigationPlaceholder.tsx | 123 -- .../sidebar/SidebarNavigationRegion.test.tsx | 589 ------ .../sidebar/SidebarNavigationRegion.tsx | 289 --- .../sidebar/SidebarUpdatesBadge.stories.tsx | 5 +- .../sidebar/navigationRailExperiment.test.tsx | 56 - .../sidebar/navigationRailExperiment.ts | 23 - .../sidebar/sidebarFooterPreferences.ts | 16 +- .../sidebar/sidebarHeaderProvider.ts | 18 - .../sidebar/sidebarNavigationItems.ts | 84 +- .../sidebar/sidebarNavigationProvider.ts | 35 - .../sidebar/usePaneContentSplitDrag.ts | 34 +- .../useSidebarNavigationArrangement.ts | 86 +- .../components/tools/PluginCapabilities.tsx | 14 - .../src/components/tools/ResourceSidebar.tsx | 15 - .../tools/ResourceSidebars.test.tsx | 46 +- apps/app/src/lib/plugin-sdk-app-impl.tsx | 8 - apps/app/src/lib/plugin-sidebar-navigation.ts | 130 -- apps/app/src/lib/plugin-slots.ts | 16 - apps/app/src/lib/system-config-atoms.ts | 1 - .../src/test/fixtures/navigation-plugin.ts | 43 - apps/app/src/test/fixtures/plugins.ts | 2 - apps/app/src/test/navigation-harness.tsx | 112 - .../views/SettingsView.experiments.test.tsx | 2 - .../SettingsView.palette-preview.test.tsx | 1 - apps/app/src/views/SettingsView.stories.tsx | 1 - apps/app/src/views/SettingsView.tsx | 12 - apps/demo-server/package.json | 1 - apps/demo-server/scripts/build-assets.ts | 2 +- apps/demo-server/src/demo-world.ts | 4 - apps/demo-server/src/worker.test.ts | 5 +- apps/desktop/test/preload-build.test.ts | 1 - .../src/services/plugins/builtin-registry.ts | 5 - .../test/public/public-ui-preferences.test.ts | 74 +- .../services/plugins/builtin-registry.test.ts | 1 - .../plugins/plugin-authoring-docs.test.ts | 9 - apps/server/test/system/experiments.test.ts | 5 - docs/api_to_audit.md | 150 +- docs/configuration.md | 47 +- .../plugins/sidebar-navigation/.gitignore | 2 - examples/plugins/sidebar-navigation/README.md | 16 - examples/plugins/sidebar-navigation/app.tsx | 138 -- .../plugins/sidebar-navigation/package.json | 27 - examples/plugins/sidebar-navigation/server.ts | 5 - .../plugins/sidebar-navigation/tsconfig.json | 13 - packages/bundled-plugins/package.json | 1 - packages/domain/src/experiments.ts | 2 - packages/domain/src/plugin-sdk-version.ts | 2 +- packages/domain/src/ui-preferences.ts | 16 - packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/app-contract.ts | 283 +-- packages/plugin-sdk/src/app.ts | 9 - .../src/internal/plugin-app-collector.ts | 26 - .../testing/__tests__/app-harness.test.tsx | 144 -- packages/plugin-sdk/src/testing/app.tsx | 120 -- .../src/templates/bb-guide-customization.md | 33 +- .../src/templates/bb-guide-plugins.md | 14 +- .../skills/bb-cli/references/app-settings.md | 32 +- .../references/backend-ui-lifecycle.md | 14 +- .../references/frontend-api-index.md | 21 - .../references/frontend-core-slots.md | 31 +- .../references/frontend-registration.md | 126 +- .../references/frontend-testing-api-index.md | 1 - plugins/bb-official.json | 4 - plugins/navigation/PLUGIN_OVERVIEW.md | 11 - plugins/navigation/app.test.tsx | 277 --- plugins/navigation/app.tsx | 12 - plugins/navigation/app/Navigation.tsx | 447 ---- .../app/ui/AppCommandShortcutPill.tsx | 25 - .../app/ui/SidebarVisibilityControls.tsx | 302 --- .../navigation/app/ui/SplitPaneMiniMap.tsx | 72 - .../app/ui/sidebar-hover-actions.ts | 7 - .../navigation/app/ui/sidebarRowClasses.ts | 29 - plugins/navigation/app/ui/sortableMotion.ts | 66 - .../app/ui/use-drag-click-suppression.ts | 75 - plugins/navigation/app/ui/useReorderDnd.ts | 179 -- .../navigation/app/ui/useSidebarReorderDnd.ts | 85 - plugins/navigation/package.json | 49 - plugins/navigation/server.ts | 3 - plugins/navigation/tsconfig.json | 27 - plugins/navigation/vitest.config.ts | 11 - .../plugin-api-docs/src/anatomy-manifest.json | 40 +- .../src/first-party-plugins.json | 5 - plugins/plugin-api-docs/src/product-map.tsx | 1 - plugins/plugin-api-docs/src/surfaces.ts | 37 +- plugins/plugin-api-docs/src/wireframes.tsx | 127 +- .../plugin-api-docs/test/mobile-guide.test.ts | 5 +- plugins/plugin-api-docs/test/surfaces.test.ts | 1 - .../plugin-api-docs/test/wireframes.test.ts | 47 +- plugins/theme-preview/app.tsx | 4 +- pnpm-lock.yaml | 76 - scripts/forkable-plugins.json | 1 - turbo.json | 27 +- 119 files changed, 680 insertions(+), 8026 deletions(-) delete mode 100644 apps/app/src/components/settings/SidebarHeaderSetting.tsx delete mode 100644 apps/app/src/components/settings/SidebarNavigationSetting.test.tsx delete mode 100644 apps/app/src/components/settings/SidebarNavigationSetting.tsx delete mode 100644 apps/app/src/components/sidebar/NavigationPlugin.test.tsx delete mode 100644 apps/app/src/components/sidebar/SidebarHeaderSlot.test.tsx delete mode 100644 apps/app/src/components/sidebar/SidebarHeaderSlot.tsx delete mode 100644 apps/app/src/components/sidebar/SidebarNavigationPlaceholder.tsx delete mode 100644 apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx delete mode 100644 apps/app/src/components/sidebar/SidebarNavigationRegion.tsx delete mode 100644 apps/app/src/components/sidebar/navigationRailExperiment.test.tsx delete mode 100644 apps/app/src/components/sidebar/navigationRailExperiment.ts delete mode 100644 apps/app/src/components/sidebar/sidebarHeaderProvider.ts delete mode 100644 apps/app/src/components/sidebar/sidebarNavigationProvider.ts delete mode 100644 apps/app/src/lib/plugin-sidebar-navigation.ts delete mode 100644 apps/app/src/test/fixtures/navigation-plugin.ts delete mode 100644 apps/app/src/test/navigation-harness.tsx delete mode 100644 examples/plugins/sidebar-navigation/.gitignore delete mode 100644 examples/plugins/sidebar-navigation/README.md delete mode 100644 examples/plugins/sidebar-navigation/app.tsx delete mode 100644 examples/plugins/sidebar-navigation/package.json delete mode 100644 examples/plugins/sidebar-navigation/server.ts delete mode 100644 examples/plugins/sidebar-navigation/tsconfig.json delete mode 100644 plugins/navigation/PLUGIN_OVERVIEW.md delete mode 100644 plugins/navigation/app.test.tsx delete mode 100644 plugins/navigation/app.tsx delete mode 100644 plugins/navigation/app/Navigation.tsx delete mode 100644 plugins/navigation/app/ui/AppCommandShortcutPill.tsx delete mode 100644 plugins/navigation/app/ui/SidebarVisibilityControls.tsx delete mode 100644 plugins/navigation/app/ui/SplitPaneMiniMap.tsx delete mode 100644 plugins/navigation/app/ui/sidebar-hover-actions.ts delete mode 100644 plugins/navigation/app/ui/sidebarRowClasses.ts delete mode 100644 plugins/navigation/app/ui/sortableMotion.ts delete mode 100644 plugins/navigation/app/ui/use-drag-click-suppression.ts delete mode 100644 plugins/navigation/app/ui/useReorderDnd.ts delete mode 100644 plugins/navigation/app/ui/useSidebarReorderDnd.ts delete mode 100644 plugins/navigation/package.json delete mode 100644 plugins/navigation/server.ts delete mode 100644 plugins/navigation/tsconfig.json delete mode 100644 plugins/navigation/vitest.config.ts diff --git a/.bb/skills/verify-bb/features/settings.md b/.bb/skills/verify-bb/features/settings.md index 373c325b82a..972f28fb348 100644 --- a/.bb/skills/verify-bb/features/settings.md +++ b/.bb/skills/verify-bb/features/settings.md @@ -38,7 +38,8 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls. | Voice configuration | Load microphones, select one, pick the Voice input service in Settings → AI services, and transcribe a harmless fixture. | Choice is applied to recording/transcription; the microphone hides when the selected service is not ready or Voice input is Off. | | Usage | Inspect Usage limits and settings usage; compare provider-reported windows. | Unavailable data remains unavailable rather than zero. | | AI services picker | Open Settings → AI services. For Thread titles and Commit messages pick Automatic, a registered service, and Off; press Test; mirror with `settings ai-services`, `settings ai-services set `, and `settings ai-services test `. Create a thread and use the Commit action under each choice. | Each row lists only services that handle the task, with their status line; Automatic names the first ready builtin service; a picked service is used alone and a failure falls back to prompt-text titles and `bb: automated commit`; Off never calls a service; a failed Test shows the reason in the row; choices persist across reload and match the CLI. | -| Experiments | Exercise changelogPreview, serverMove, and navigationRail on/off in isolated data. For navigationRail, open Settings, Plugins, Skills, and a plugin panel from the rail on a wide window and confirm the rail stays, the matching tab is current, and Home returns to the last thread. | Only the named feature gate changes; disabled routes/actions fail or disappear as designed; state restores. | +| Experiments | Exercise changelogPreview and serverMove on/off in isolated data. | Only the named feature gate changes; disabled routes/actions fail or disappear as designed; state restores. | +| Navigation rail | Open Settings, Plugins, Skills, and a plugin panel from the rail on a wide window and in the compact drawer; confirm the rail stays, the matching destination is current, and Home returns to the last thread. Hide and reorder a destination through More → Customize rail. | Rail order and visibility persist in `sidebar.pluginPanelOrder` and `sidebar.visiblePluginPanels`; in the drawer, Home, Plugins, Skills, and Settings leave it open and a plugin page closes it. | | Storage & retention plugin | Enable bb--storage-retention in a fresh instance. Verify Never/off defaults, immediate saves without success toasts, failed-save rollback, reload persistence, CLI parity, scans, background cleanup progress, and inline deletion confirmations. Using disposable fixtures, verify archive storage cleanup after the undo grace, cancellation on unarchive/pin, and development cleanup after checkout removal, including offline/reload recovery. | Automatic cleanup requires the corresponding opt-in. Clearing files preserves conversations and attachment originals; large-file cleanup preserves small files. Pins exempt affected groups; automation protection is deferred. Removal-feed cursors and pending work survive reload; expired cursors trigger reconciliation. Disabling stops plugin schedules and actions; re-enabling preserves policy. Verify compact Safari without app-root inert/aria-hidden. | | Debug events | Toggle Show unhandled provider events and render a trusted unsupported-event fixture. | The diagnostic row visibility follows the toggle without changing persisted event data. | | Community and update surfaces | Open Community links, version/update view, changelog, and CLI skills status. | Destinations and installed/latest status are correct; viewing does not perform an update. | diff --git a/apps/app/.ladle/plugin-sdk-app.ts b/apps/app/.ladle/plugin-sdk-app.ts index cb56eafa28b..ffcbfa1f6c0 100644 --- a/apps/app/.ladle/plugin-sdk-app.ts +++ b/apps/app/.ladle/plugin-sdk-app.ts @@ -32,9 +32,6 @@ export const { experimental_useSidebarThreadActions, experimental_useSidebarThreadPullRequest, experimental_useSidebarThreadSplit, - experimental_useSidebarNavigation, - experimental_useSidebarNavigationSplit, - experimental_SidebarNavigationIcon, useSidebarThreadDraft, useSidebarThreadDraftIds, useSidebarThreadRowStatus, diff --git a/apps/app/.ladle/story-settings-chrome.tsx b/apps/app/.ladle/story-settings-chrome.tsx index 00c7c436ee6..d0ff8322047 100644 --- a/apps/app/.ladle/story-settings-chrome.tsx +++ b/apps/app/.ladle/story-settings-chrome.tsx @@ -7,6 +7,7 @@ import { type SettingsSectionId, } from "@/components/settings/settings-sections"; import { + Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, @@ -65,18 +66,19 @@ export function SettingsStoryChrome({ className="h-screen min-h-[640px] bg-background" style={{ "--bb-shell-height": "100vh" } as CSSProperties} > - {}} - testIdPrefix="settings-story" - /> + + {}} + testIdPrefix="settings-story" + /> +
({ compact: false, macosChrome: false, - navigationRail: false, })); vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ @@ -43,7 +42,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({ changelogPreview: false, serverMove: false, performanceDiagnostics: false, - navigationRail: viewportState.navigationRail, }, }, }), @@ -210,7 +208,6 @@ describe("AppLayout plugin panel header", () => { beforeEach(() => { viewportState.compact = false; viewportState.macosChrome = false; - viewportState.navigationRail = false; setCompactSecondaryPanelPresentation("closed"); }); @@ -287,7 +284,6 @@ describe("AppLayout plugin panel header", () => { it("moves the trigger and history controls into a window title bar beside the macOS rail", () => { viewportState.macosChrome = true; - viewportState.navigationRail = true; renderPluginPanelRoute(); const titleBar = screen.getByTestId("app-window-title-bar"); @@ -303,8 +299,9 @@ describe("AppLayout plugin panel header", () => { expect(screen.queryByTestId("app-desktop-sidebar-trigger")).toBeNull(); }); - it("keeps the floating macOS trigger without the rail and on compact windows", () => { + it("keeps the floating macOS trigger on compact windows", () => { viewportState.macosChrome = true; + viewportState.compact = true; renderPluginPanelRoute(); expect(screen.queryByTestId("app-window-title-bar")).toBeNull(); @@ -312,13 +309,5 @@ describe("AppLayout plugin panel header", () => { expect( screen.getByTestId("app-layout-root").dataset.framed, ).toBeUndefined(); - - cleanup(); - viewportState.compact = true; - viewportState.navigationRail = true; - renderPluginPanelRoute(); - - expect(screen.queryByTestId("app-window-title-bar")).toBeNull(); - expect(screen.getByTestId("app-desktop-sidebar-trigger")).toBeTruthy(); }); }); diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index 21398e0d35e..6459860fee9 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -105,7 +105,6 @@ import { dispatchBrowserViewBoundsSync } from "@/lib/browser-view-bounds-sync"; import { useFaviconBadge } from "@/lib/favicon-color-preference"; import { shouldShowFaviconAttentionDot } from "./faviconAttentionDot"; import { AppLayoutSidebar } from "./AppLayoutSidebar"; -import { useNavigationRailExperiment } from "@/components/sidebar/navigationRailExperiment"; import { NAV_RAIL_COLLAPSED_SIDEBAR_WIDTH } from "@/components/sidebar/navRailWidth"; import { SidebarHistoryNavigationControls } from "@/components/sidebar/SidebarHistoryNavigationControls"; import { @@ -179,7 +178,6 @@ const sidebarOpenAtom = atomWithStorage( ); interface SidebarStateBridgeProps { - navigationRail: boolean; framed: boolean; children: ReactNode; } @@ -187,11 +185,7 @@ interface SidebarStateBridgeProps { type SidebarResizeMouseEvent = ReactMouseEvent; type SidebarOpenChangeHandler = (open: boolean) => void; -function SidebarStateBridge({ - navigationRail, - framed, - children, -}: SidebarStateBridgeProps) { +function SidebarStateBridge({ framed, children }: SidebarStateBridgeProps) { const [open, setOpen] = useAtom(sidebarOpenAtom); const sidebarWidth = useAtomValue(sidebarWidthAtom); const sidebarLiveWidth = useAtomValue(sidebarLiveWidthAtom); @@ -209,9 +203,7 @@ function SidebarStateBridge({ return ( @@ -830,10 +821,7 @@ export function AppLayout({ children }: AppLayoutProps) { sections={sidebarNavigationQuery.data?.sections ?? []} > - + {backToAppRoutePath !== null && !isSidebarResizing ? ( ) : null} @@ -848,12 +836,9 @@ export function AppLayout({ children }: AppLayoutProps) { ? "skills" : "app" } - navigationRail={navigationRail} onResizeMouseDown={handleResizeMouseDown} isResizing={isSidebarResizing} - appRoutePath={appRoutePath} settingsRoutePath={settingsRoutePath} - toolsBackRoutePath={toolsBackRoutePath} />
{ const { useEffect } = await vi.importActual("react"); return { AppSidebar: ({ - mobileHosted, - navRail, + isBodyHidden, + renderRail, + alternateBody, }: { - mobileHosted?: { hidden: boolean }; - navRail?: { - hidden: boolean; - renderRail: (customize: { - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - }) => ReactNode; - alternateBody: ReactNode; - }; + isBodyHidden: boolean; + renderRail: (customize: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + }) => ReactNode; + alternateBody: ReactNode; }) => { useEffect(() => { mountCounts.appSidebar += 1; }, []); - if (navRail) { - return ( - - {navRail.renderRail({ isOpen: false, onOpenChange: () => {} })} - - {navRail.alternateBody} - - ); - } - if (mobileHosted) { - return ( - } /> - } - /> - - ), - }); - fireEvent.click(screen.getByText("Demo board")); - expect(screen.getByText("board panel body")).toBeDefined(); - }); - it("releases the plugin stylesheet when navigation unmounts the panel route", async () => { vi.useFakeTimers(); setPluginSlotRegistrations( @@ -1760,146 +1693,6 @@ describe("Navigation plugin + PluginPanelView", () => { ).toBeNull(); }); - it("shows a plugin panel's position when it is open in a split", async () => { - await registerNavigationPlugin(); - setPluginSlotRegistrations( - "demo", - registrationSet({ - navPanels: [ - { - id: "board", - title: "Demo board", - icon: "columns", - path: "board", - component: Board, - }, - ], - }), - ); - const store = createStore(); - store.set(splitLayoutAtom, { - focusedPaneId: "pane-thread", - root: { - type: "split", - dir: "row", - sizes: [0.5, 0.5], - children: [ - { - type: "pane", - paneId: "pane-plugin", - content: { - kind: "plugin-panel", - pluginId: "demo", - panelPath: "board", - subPath: "card/1", - }, - }, - { - type: "pane", - paneId: "pane-thread", - content: { - kind: "thread", - projectId: "proj_test", - threadId: "thr_test", - }, - }, - ], - }, - }); - - renderNavigationHarness({ store, splitEnabled: true }); - - const splitMap = screen.getByRole("img", { - name: "Demo board — open in split", - }); - const label = screen.getByText("Demo board"); - expect(label.nextElementSibling).toBe(splitMap); - }); - - it("keeps the sidebar entry active on nested plugin panel routes", async () => { - await registerNavigationPlugin(); - setPluginSlotRegistrations( - "simple-notes", - registrationSet({ - navPanels: [ - { - id: "simple-notes", - title: "Simple notes", - icon: "note", - path: "simple-notes", - component: Board, - }, - ], - }), - ); - renderNavigationHarness({ - initialEntries: [ - "/plugins/simple-notes/simple-notes/bb-plugin-marketplaces-and-compatible-updates.md", - ], - }); - - expect( - screen - .getByRole("button", { name: "Simple notes" }) - .getAttribute("aria-current"), - ).toBe("page"); - }); - - it("draws a remembered plugin row before boot and keeps the same node when the plugin registers", async () => { - await registerNavigationPlugin(); - resetPluginFrontendBootStateForTest(); - writeLastKnownPluginNavPanelChrome([ - { - pluginId: "demo", - id: "board", - path: "board", - title: "Demo board", - icon: "columns", - }, - ]); - renderNavigationHarness(); - const rememberedRow = screen.getByRole("button", { name: "Demo board" }); - - 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", async () => { - await registerNavigationPlugin(); - resetPluginFrontendBootStateForTest(); - writeLastKnownPluginNavPanelChrome([ - { - pluginId: "ghost", - id: "board", - path: "board", - title: "Ghost board", - icon: "columns", - }, - ]); - renderNavigationHarness(); - 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(); render( diff --git a/apps/app/src/components/plugin/theme-preview-fixture-anatomy.test.ts b/apps/app/src/components/plugin/theme-preview-fixture-anatomy.test.ts index dd169a6312f..6ca8c9d96a9 100644 --- a/apps/app/src/components/plugin/theme-preview-fixture-anatomy.test.ts +++ b/apps/app/src/components/plugin/theme-preview-fixture-anatomy.test.ts @@ -216,11 +216,7 @@ const VIEW_FIXTURE_ANCHORS: Record = { settings: [ { file: "apps/app/src/components/settings/SettingsSidebar.tsx", - mustContain: [ - 'backLabel="Back to app"', - "Settings", - "activeSection === section.id", - ], + mustContain: ["activeSection === section.id"], because: "The Appearance projection uses BB's settings navigation hierarchy and selected-row state.", }, diff --git a/apps/app/src/components/settings/SettingsSidebar.test.tsx b/apps/app/src/components/settings/SettingsSidebar.test.tsx index b99fcec7f8b..6e805e736d5 100644 --- a/apps/app/src/components/settings/SettingsSidebar.test.tsx +++ b/apps/app/src/components/settings/SettingsSidebar.test.tsx @@ -18,9 +18,7 @@ function renderSidebar(activePluginId: string | null = null) { ) => void; isResizing: boolean; - appRoutePath: string; - mobileHosted?: boolean; - navRailHosted?: boolean; } type SettingsSidebarNavigation = Pick< @@ -34,9 +31,6 @@ interface SettingsSidebarContentProps extends SettingsSidebarProps { export function SettingsSidebarContent({ onResizeMouseDown, isResizing, - appRoutePath, - mobileHosted, - navRailHosted = false, navigation, testIdPrefix = "settings", }: SettingsSidebarContentProps) { @@ -45,17 +39,10 @@ export function SettingsSidebarContent({ return ( - {navRailHosted ? null : ( - Settings - )}
{sections .filter((section) => section.id !== "archived") @@ -137,18 +124,12 @@ export function SettingsSidebarContent({ export function SettingsSidebar({ onResizeMouseDown, isResizing, - appRoutePath, - mobileHosted, - navRailHosted, }: SettingsSidebarProps) { const navigation = useSettingsNavState(); return ( diff --git a/apps/app/src/components/settings/SidebarHeaderSetting.tsx b/apps/app/src/components/settings/SidebarHeaderSetting.tsx deleted file mode 100644 index 5e269ba70c8..00000000000 --- a/apps/app/src/components/settings/SidebarHeaderSetting.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { sidebarHeaderProviderAtom } from "@/components/sidebar/sidebarHeaderProvider"; -import { usePluginSlots } from "@/lib/plugin-slots"; -import { ReplacementProviderSetting } from "./ReplacementProviderSetting"; - -export function SidebarHeaderSetting({ - navigationRail, -}: { - navigationRail: boolean; -}) { - const { experimentalSidebarHeaders } = usePluginSlots(); - return ( - - ); -} diff --git a/apps/app/src/components/settings/SidebarNavigationSetting.test.tsx b/apps/app/src/components/settings/SidebarNavigationSetting.test.tsx deleted file mode 100644 index 730ce352c26..00000000000 --- a/apps/app/src/components/settings/SidebarNavigationSetting.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { createStore, Provider } from "jotai"; -import { afterEach, describe, expect, it } from "vitest"; -import { - resetPluginSlotStoreForTest, - setPluginSlotRegistrations, -} from "@/lib/plugin-slots"; -import { sidebarNavigationProviderAtom } from "@/components/sidebar/sidebarNavigationProvider"; -import { SidebarNavigationSetting } from "./SidebarNavigationSetting"; -import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; - -afterEach(async () => { - cleanup(); - await new Promise((resolve) => setTimeout(resolve, 0)); - window.localStorage.clear(); - resetPluginSlotStoreForTest(); -}); - -function registerNavigation(pluginId: string, id: string, title: string) { - setPluginSlotRegistrations( - pluginId, - makePluginRegistrationSet({ - experimentalSidebarNavigations: [{ id, title, component: () => null }], - }), - ); -} - -describe("SidebarNavigationSetting", () => { - it("defaults to Automatic, which prefers an installed plugin over the bundled Navigation", async () => { - registerNavigation("navigation", "navigation", "Navigation"); - registerNavigation("navbar", "grid", "Navigation grid"); - const store = createStore(); - render( - - - , - ); - - expect(store.get(sidebarNavigationProviderAtom)).toBe("__automatic__"); - fireEvent.pointerDown( - screen.getByRole("button", { name: "Sidebar navigation" }), - { button: 0 }, - ); - const options = (await screen.findAllByRole("menuitem")).map( - (item) => item.textContent ?? "", - ); - expect(options.find((option) => option.startsWith("Automatic"))).toContain( - "Chooses Navigation grid (navbar).", - ); - expect(options).toHaveLength(3); - expect(options[1]).toContain("Navigation gridFrom the navbar plugin."); - expect(options[2]).toContain("Navigation (built-in)BB default."); - - fireEvent.click( - screen.getByRole("menuitem", { name: /^Navigation \(built-in\)/u }), - ); - expect(store.get(sidebarNavigationProviderAtom)).toBe( - "navigation/navigation", - ); - }); - - it("says the choice is on hold while the navigation rail experiment draws the navigation", () => { - registerNavigation("navigation", "navigation", "Navigation"); - render( - - - , - ); - - expect( - screen.getByText(/Not used while the Navigation rail experiment is on/u), - ).toBeTruthy(); - expect( - screen.getByRole("button", { name: "Sidebar navigation" }), - ).toBeTruthy(); - }); -}); diff --git a/apps/app/src/components/settings/SidebarNavigationSetting.tsx b/apps/app/src/components/settings/SidebarNavigationSetting.tsx deleted file mode 100644 index e13e70c22a7..00000000000 --- a/apps/app/src/components/settings/SidebarNavigationSetting.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { - BUNDLED_NAVIGATION_PROVIDER, - sidebarNavigationProviderAtom, -} from "@/components/sidebar/sidebarNavigationProvider"; -import { usePluginSlots } from "@/lib/plugin-slots"; -import { ReplacementProviderSetting } from "./ReplacementProviderSetting"; - -export function SidebarNavigationSetting({ - navigationRail, -}: { - navigationRail: boolean; -}) { - const { experimentalSidebarNavigations } = usePluginSlots(); - return ( - - ); -} diff --git a/apps/app/src/components/sidebar/AppNavRail.test.tsx b/apps/app/src/components/sidebar/AppNavRail.test.tsx index f86d8ca9d45..23741b05bad 100644 --- a/apps/app/src/components/sidebar/AppNavRail.test.tsx +++ b/apps/app/src/components/sidebar/AppNavRail.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -19,6 +20,7 @@ import { it, vi, } from "vitest"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { SidebarProvider } from "@/components/ui/sidebar"; import { @@ -33,20 +35,22 @@ import { resetPluginSlotStoreForTest, setPluginSlotRegistrations, } from "@/lib/plugin-slots"; +import { writeLastKnownPluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome"; import { + AUTOMATIONS_PLUGIN_ID, getPluginPanelRoutePath, getThreadRoutePath, isPluginsRoutePath, SETTINGS_ROUTE_PATH, } from "@/lib/route-paths"; import { makePluginRegistrationSet as registrationSet } from "@/test/fixtures/plugins"; -import { useSidebarNavigation } from "@/lib/plugin-sidebar-navigation"; import { AppNavRail, NavRailNewThreadButton } from "./AppNavRail"; import { SidebarVisibilityCustomize } from "./SidebarVisibilityControls"; import { SidebarNavigationModelProvider } from "./SidebarNavigationModel"; const mocks = vi.hoisted(() => ({ dispatch: vi.fn(), + onNavigate: vi.fn(), onNewChat: vi.fn(), })); @@ -71,15 +75,7 @@ const ALL_KEYS = [ "garden/docs", ]; const DOCS_PATH = getPluginPanelRoutePath({ pluginId: "garden", path: "docs" }); - -function HostCustomizeTrigger() { - const { actions } = useSidebarNavigation(); - return ( - - ); -} +const AUTOMATIONS_PLUGIN_PANEL_PATH = "automations"; function RailHarness() { const location = useLocation(); @@ -88,8 +84,8 @@ function RailHarness() { const [isCustomizing, setCustomizing] = useState(false); return ( setCustomizing(true)} splitEnabled={false} > - {location.pathname} ); @@ -107,7 +102,7 @@ function RailHarness() { function renderRail( initialPath: string, - options: { visibleKeys?: string[] } = {}, + options: { visibleKeys?: string[]; isCompactViewport?: boolean } = {}, ) { const store = createStore(); if (options.visibleKeys) { @@ -117,11 +112,15 @@ function renderRail( return render( - - - - - + + + + + + + , ); @@ -218,6 +217,89 @@ describe("AppNavRail", () => { expect(currentRailLabels()).toEqual(["Plugins"]); }); + it("keeps a plugin panel highlighted on its nested routes", () => { + renderRail(`${DOCS_PATH}/guides/getting-started.md`); + + expect(currentRailLabels()).toEqual(["Docs"]); + }); + + it("lists the Automations panel as a destination", () => { + setPluginSlotRegistrations( + AUTOMATIONS_PLUGIN_ID, + registrationSet({ + navPanels: [ + { + id: AUTOMATIONS_PLUGIN_PANEL_PATH, + title: "Automations", + icon: "Calendar", + path: AUTOMATIONS_PLUGIN_PANEL_PATH, + component: () => null, + }, + ], + }), + ); + + renderRail(THREAD_PATH); + + expect(railButton("Automations")).toBeDefined(); + }); + + it("draws a remembered plugin panel before boot and keeps the same button when the plugin registers", () => { + resetPluginSlotStoreForTest(); + resetPluginFrontendBootStateForTest(); + writeLastKnownPluginNavPanelChrome([ + { + pluginId: "demo", + id: "board", + path: "board", + title: "Demo board", + icon: "columns", + }, + ]); + renderRail(THREAD_PATH); + const remembered = railButton("Demo board"); + + act(() => { + setPluginSlotRegistrations( + "demo", + registrationSet({ + navPanels: [ + { + id: "board", + title: "Demo board", + icon: "columns", + path: "board", + component: () => null, + }, + ], + }), + ); + markPluginFrontendsSettled(); + }); + + expect(railButton("Demo board")).toBe(remembered); + }); + + it("drops a remembered plugin panel that never registers once frontends have settled", () => { + resetPluginSlotStoreForTest(); + resetPluginFrontendBootStateForTest(); + writeLastKnownPluginNavPanelChrome([ + { + pluginId: "ghost", + id: "board", + path: "board", + title: "Ghost board", + icon: "columns", + }, + ]); + renderRail(THREAD_PATH); + expect(railButton("Ghost board")).toBeDefined(); + + act(() => markPluginFrontendsSettled()); + + expect(railLabels()).not.toContain("Ghost board"); + }); + it("returns Home to the last thread, skipping plugin panels and Settings visited since", () => { renderRail(THREAD_PATH); @@ -238,6 +320,21 @@ describe("AppNavRail", () => { expect(pathname()).toBe("/"); }); + it("dismisses the drawer for a plugin panel but not for destinations that swap the list beside the rail", () => { + renderRail(THREAD_PATH); + + fireEvent.click(railButton("Plugins")); + fireEvent.click(railButton("Skills")); + fireEvent.click(railButton("Settings")); + fireEvent.click(railButton("Home")); + expect(pathname()).toBe(THREAD_PATH); + expect(mocks.onNavigate).not.toHaveBeenCalled(); + + fireEvent.click(railButton("Docs")); + expect(pathname()).toBe(DOCS_PATH); + expect(mocks.onNavigate).toHaveBeenCalledTimes(1); + }); + it("does not mount a plugin panel's sidebar accessory", () => { const accessoryMounted = vi.fn(); setPluginSlotRegistrations( @@ -285,6 +382,28 @@ describe("AppNavRail", () => { expect(mocks.dispatch).toHaveBeenCalledWith("thread.search", null); }); + it("opens the customize sheet from More on a compact viewport", async () => { + renderRail(THREAD_PATH, { isCompactViewport: true }); + + fireEvent.click(railButton("More")); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Customize rail" }), + ); + + const editor = await screen.findByTestId("nav-rail-customize"); + const list = within(editor).getByRole("list", { + name: "Sidebar navigation", + }); + fireEvent.click(within(list).getByRole("checkbox", { name: /Skills/u })); + expect(railLabels()).toEqual([ + "Home", + "Plugins", + "Docs", + "More", + "Settings", + ]); + }); + it("customizes the rail from a popover beside it without leaving Settings", async () => { renderRail(THREAD_PATH); fireEvent.click(railButton("Settings")); @@ -340,7 +459,10 @@ describe("AppNavRail", () => { () => new DOMRect(12, moreTop, 28, 28), ); - fireEvent.click(screen.getByRole("button", { name: "Plugin customize" })); + fireEvent.keyDown(railButton("More"), { key: "Enter" }); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Customize rail" }), + ); const editor = await screen.findByTestId("nav-rail-customize"); const wrapper = editor.closest( "[data-radix-popper-content-wrapper]", @@ -356,14 +478,6 @@ describe("AppNavRail", () => { expect(wrapper.style.transform).toBe(openedAt); }); - it("opens the same popover when a plugin asks the host to customize navigation", async () => { - renderRail(THREAD_PATH); - - fireEvent.click(screen.getByRole("button", { name: "Plugin customize" })); - - expect(await screen.findByTestId("nav-rail-customize")).toBeTruthy(); - }); - it("drops the header New thread button when the user hid New thread", () => { renderRail(THREAD_PATH, { visibleKeys: ["__bb__/extensions"] }); diff --git a/apps/app/src/components/sidebar/AppNavRail.tsx b/apps/app/src/components/sidebar/AppNavRail.tsx index 4d9da42f9dd..834610464b7 100644 --- a/apps/app/src/components/sidebar/AppNavRail.tsx +++ b/apps/app/src/components/sidebar/AppNavRail.tsx @@ -8,7 +8,6 @@ import { type RefObject, } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import type { ExperimentalSidebarNavigationItem } from "@get-bb/plugin-sdk"; import { Button } from "@bb/shared-ui/button"; import { ContextMenu, @@ -25,6 +24,7 @@ import { DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { Icon } from "@bb/shared-ui/icon"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { cn } from "@bb/shared-ui/lib/utils"; import { Popover, PopoverAnchor, PopoverContent } from "@bb/shared-ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; @@ -38,15 +38,18 @@ import { MACOS_WINDOW_DRAG_CLASS, shouldUseMacosDesktopChrome, } from "@/lib/bb-desktop"; -import { - useSidebarNavigation, - useSidebarNavigationSplit, -} from "@/lib/plugin-sidebar-navigation"; import { getRootComposeRoutePath } from "@/lib/route-paths"; import { NAV_RAIL_WIDTH_CLASS } from "./navRailWidth"; import { SidebarNavigationCustomize } from "./SidebarNavigationCustomize"; -import { SidebarNavigationIcon } from "./SidebarNavigationModel"; -import { NEW_THREAD_NAVIGATION_ITEM_ID } from "./sidebarNavigationItems"; +import { + SidebarNavigationIcon, + useSidebarNavigation, + useSidebarNavigationSplit, +} from "./SidebarNavigationModel"; +import { + NEW_THREAD_NAVIGATION_ITEM_ID, + type SidebarNavigationItem, +} from "./sidebarNavigationItems"; import { PROJECT_LIST_ACTION_BUTTON_CLASS } from "./sidebarRowClasses"; export interface NavRailCustomizeState { @@ -101,7 +104,7 @@ function RailItem({ onCustomize, onMenuCloseAutoFocus, }: { - item: ExperimentalSidebarNavigationItem; + item: SidebarNavigationItem; onCustomize: () => void; onMenuCloseAutoFocus: (event: Event) => void; }) { @@ -202,7 +205,7 @@ function RailMoreMenu({ onMenuCloseAutoFocus, }: { buttonRef: RefObject; - hidden: readonly ExperimentalSidebarNavigationItem[]; + hidden: readonly SidebarNavigationItem[]; onCustomize: () => void; onMenuCloseAutoFocus: (event: Event) => void; }) { @@ -258,6 +261,7 @@ export function AppNavRail({ const settingsShortcut = useAppCommandShortcut("settings.open"); const [desktopInfo] = useState(getBbDesktopInfo); const isFramed = useIsSidebarFramed(); + const isCompactViewport = useIsCompactViewport(); const isHomeActive = isAppMode && @@ -338,7 +342,11 @@ export function AppNavRail({