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({