diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index d2dd52c280..ac87f42445 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -552,8 +552,12 @@ describe("PluginsOverview", () => { expect(officialPills[0]?.parentElement?.className).toContain("px-2"); expect(officialPills[0]?.parentElement?.className).toContain("py-1"); - fireEvent.pointerDown(screen.getByRole("button", { name: "Sort" })); - fireEvent.click(screen.getByRole("menuitem", { name: "Plugin name" })); + const sortTrigger = screen.getByRole("button", { + name: "Sort: Plugin name, ascending", + }); + expect(sortTrigger.querySelector('[data-icon="ArrowUpDown"]')).toBeTruthy(); + fireEvent.pointerDown(sortTrigger); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); expect( [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( (row) => row.getAttribute("data-testid"), @@ -566,9 +570,12 @@ describe("PluginsOverview", () => { "plugin-row-inactive-local", ]); - fireEvent.keyDown(screen.getByRole("menu", { name: "Sort" }), { - key: "Escape", - }); + fireEvent.keyDown( + screen.getByRole("menu", { + name: "Sort: Plugin name, descending", + }), + { key: "Escape" }, + ); fireEvent.click(screen.getByRole("tab", { name: "Browse" })); await screen.findByText("GitHub"); fireEvent.click(screen.getByRole("tab", { name: "Installed, 5 plugins" })); diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index 4e1a353e21..c178395739 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -125,8 +125,12 @@ describe("BrowsePluginsTab", () => { "Open Zulu details", ]); - fireEvent.pointerDown(screen.getByRole("button", { name: "Sort" })); - fireEvent.click(screen.getByRole("menuitem", { name: "Plugin name" })); + const sortTrigger = screen.getByRole("button", { + name: "Sort: Plugin name, ascending", + }); + expect(sortTrigger.querySelector('[data-icon="ArrowUpDown"]')).toBeTruthy(); + fireEvent.pointerDown(sortTrigger); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); expect(cardOrder()).toEqual([ "Open Zulu details", "Open Middle details", @@ -362,7 +366,17 @@ describe("BrowsePluginsTab", () => { expect(installed.querySelector('[data-icon="Check"]')).toBeNull(); expect(installed.className).toContain("border-success/40"); expect(installed.className).toContain("bg-success/15"); - expect(installed.className).toContain("text-success-foreground"); + expect(installed.className).toContain( + "text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]", + ); + expect(installed.className).not.toContain("text-success-foreground"); + expect(installed.className).toContain( + "hover:text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]", + ); + expect(installed.className).toContain( + "focus-visible:text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]", + ); + expect(installed.className).not.toContain("hover:text-foreground"); expect(installed.className).toContain("hover:bg-success/25"); expect(screen.queryByRole("button", { name: "Install" })).toBeNull(); fireEvent.click(installed); diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 4d10bc55f2..3de704b235 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -197,7 +197,7 @@ function BrowseCard({ pending={uninstall.isPending} presentation="icon" tooltip={`Uninstall ${entry.displayName}`} - className="border-success/40 bg-success/15 text-success-foreground hover:border-success/55 hover:bg-success/25 hover:text-success-foreground focus-visible:border-success/55 focus-visible:bg-success/25 focus-visible:text-success-foreground" + className="border-success/40 bg-success/15 text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))] hover:border-success/55 hover:bg-success/25 hover:text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))] focus-visible:border-success/55 focus-visible:bg-success/25 focus-visible:text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]" onAction={() => setConfirmingUninstall(true)} /> ) : ( diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts index 07c1b02a16..aab6f57837 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts @@ -1,29 +1,90 @@ +// @vitest-environment jsdom + +import { act, cleanup, render } from "@testing-library/react"; +import { createElement } from "react"; +import { afterEach, vi } from "vitest"; import { describe, expect, it } from "vitest"; import { - getTabStripChevronEdgeClass, - getTabStripChevronVisibilityClass, + SecondaryPanelTabStrip, SECONDARY_PANEL_TAB_STRIP_FADE_TONE, } from "./SecondaryPanelTabStrip"; +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + describe("secondary panel tab-strip edge fades", () => { - it("uses one opaque themed edge fade and no second caret gradient", () => { + it("uses the themed edge fade without overlay scroll controls", () => { expect(SECONDARY_PANEL_TAB_STRIP_FADE_TONE).toBe("sidebar"); - expect(getTabStripChevronEdgeClass("left")).toBe( - "left-0 justify-start", - ); - expect(getTabStripChevronEdgeClass("right")).toBe( - "right-0 justify-end", - ); }); - it("keeps an available scroll control visible without requiring hover", () => { - const visibleClass = getTabStripChevronVisibilityClass(true); + it("observes the intrinsic tab row so async title changes refresh overflow", () => { + const observed: Element[] = []; + let resizeCallback: ResizeObserverCallback | undefined; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe(element: Element) { + observed.push(element); + } + disconnect() {} + }, + ); + + const { container } = render( + createElement(SecondaryPanelTabStrip, { + fileTabs: [ + { + id: "browser", + filename: "Browser", + isActive: true, + isPinned: false, + leadingVisual: null, + statusLabel: null, + onSelect: vi.fn(), + onClose: vi.fn(), + }, + ], + onReorderTab: vi.fn(), + usesDesktopChrome: false, + }), + ); - expect(visibleClass).toContain("pointer-events-auto"); - expect(visibleClass).toContain("opacity-100"); - expect(visibleClass).not.toContain("hover:"); - expect(getTabStripChevronVisibilityClass(false)).toBe( - "pointer-events-none opacity-0", + const viewport = container.querySelector(".no-scrollbar"); + const content = container.querySelector( + "[data-secondary-panel-tab-content]", ); + expect(content).not.toBeNull(); + expect(observed).toContain(viewport); + expect(observed).toContain(content); + expect(resizeCallback).toBeDefined(); + expect(container.querySelectorAll("[data-overflow-fade]")).toHaveLength(2); + expect( + container + .querySelector("[data-overflow-fade='left']") + ?.classList.contains("w-6"), + ).toBe(true); + expect( + container.querySelector('[aria-label="Scroll tabs left"]'), + ).toBeNull(); + expect( + container.querySelector('[aria-label="Scroll tabs right"]'), + ).toBeNull(); + + const rightFade = container.querySelector("[data-overflow-fade='right']"); + expect(rightFade?.classList.contains("opacity-0")).toBe(true); + Object.defineProperties(viewport!, { + clientWidth: { configurable: true, value: 120 }, + scrollWidth: { configurable: true, value: 240 }, + scrollLeft: { configurable: true, value: 0, writable: true }, + }); + act(() => { + resizeCallback?.([], {} as ResizeObserver); + }); + expect(rightFade?.classList.contains("opacity-100")).toBe(true); }); }); diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx index c7707e8293..335bdb2cbd 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx @@ -27,9 +27,6 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Button } from "@bb/shared-ui/button"; -import { COARSE_POINTER_COMPACT_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; -import { Icon } from "@bb/shared-ui/icon"; import { OverflowFade, type OverflowFadeTone, @@ -37,40 +34,18 @@ import { import { TabPill } from "@/components/ui/tab-pill"; import { useDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; import { cn } from "@bb/shared-ui/lib/utils"; -import { - MACOS_APP_REGION_NO_DRAG_CLASS, - MACOS_WINDOW_NO_DRAG_CLASS, -} from "@/lib/bb-desktop"; +import { MACOS_WINDOW_NO_DRAG_CLASS } from "@/lib/bb-desktop"; import type { SecondaryPanelFileTab, SecondaryPanelTabReorderHandler, } from "./secondaryPanelFileTab"; export type { SecondaryPanelFileTab } from "./secondaryPanelFileTab"; -// How far a chevron click nudges the strip, in CSS pixels. Roughly one wide -// file tab so a click reveals the next tab without overshooting. -const CHEVRON_SCROLL_STEP_PX = 140; - -// Slack so sub-pixel scroll offsets don't leave a fade/chevron stuck on at a -// hard edge. +// Slack so sub-pixel scroll offsets don't leave a fade stuck on at a hard edge. const EDGE_EPSILON_PX = 1; export const SECONDARY_PANEL_TAB_STRIP_FADE_TONE: OverflowFadeTone = "sidebar"; -export function getTabStripChevronEdgeClass( - direction: "left" | "right", -): string { - return direction === "left" - ? "left-0 justify-start" - : "right-0 justify-end"; -} - -export function getTabStripChevronVisibilityClass(canScroll: boolean): string { - return canScroll - ? "pointer-events-auto opacity-100" - : "pointer-events-none opacity-0"; -} - interface TabStripOverflowState { /** Scrolled away from the left edge (content hidden to the left). */ canScrollLeft: boolean; @@ -103,8 +78,8 @@ interface SortableFileTabProps { * * Only the file tabs scroll; the leading Info/Diff controls and trailing * new-tab/panel controls stay anchored outside this component. Edge - * fades and scroll chevrons appear only on a side that has more tabs, and the - * active tab is auto-scrolled into view on mount and whenever it changes + * fades appear only on a side that has more tabs, and the active tab is + * auto-scrolled into view on mount and whenever it changes * (covering pointer, keyboard, and programmatic selection). */ export function SecondaryPanelTabStrip({ @@ -114,6 +89,7 @@ export function SecondaryPanelTabStrip({ activeTreatment = "fill", }: SecondaryPanelTabStripProps) { const viewportRef = useRef(null); + const contentRef = useRef(null); const activeTabRef = useRef(null); const [overflow, setOverflow] = useState( INITIAL_OVERFLOW_STATE, @@ -178,9 +154,9 @@ export function SecondaryPanelTabStrip({ applyEdgeFlags(); }, [applyEdgeFlags]); - // Track the viewport's own scrolling and resizing. The ResizeObserver fires - // once on observe (seeding the initial capacity + flags) and on every resize - // (including the panel's drag-resize, which changes clientWidth). + // Track the viewport's own scrolling and both dimensions that determine its + // capacity. The content row can change intrinsic width without the viewport + // resizing (for example, when an async browser title replaces "Browser"). useEffect(() => { const viewport = viewportRef.current; if (viewport === null) { @@ -200,6 +176,9 @@ export function SecondaryPanelTabStrip({ viewport.addEventListener("scroll", handleScroll, { passive: true }); const resizeObserver = new ResizeObserver(measureCapacity); resizeObserver.observe(viewport); + if (contentRef.current !== null) { + resizeObserver.observe(contentRef.current); + } return () => { viewport.removeEventListener("scroll", handleScroll); resizeObserver.disconnect(); @@ -284,12 +263,6 @@ export function SecondaryPanelTabStrip({ }; }, []); - const scrollByStep = (direction: -1 | 1) => { - viewportRef.current?.scrollBy({ - left: direction * CHEVRON_SCROLL_STEP_PX, - behavior: "smooth", - }); - }; const handleDragStart = useCallback( (event: DragStartEvent) => { setDraggingTabId(String(event.active.id)); @@ -329,12 +302,9 @@ export function SecondaryPanelTabStrip({ ); const noDragClass = usesDesktopChrome ? MACOS_WINDOW_NO_DRAG_CLASS : null; - const chevronNoDragClass = usesDesktopChrome - ? MACOS_APP_REGION_NO_DRAG_CLASS - : null; // Memoize the sortable tab tree so the overflow-flag state — which flips every // time you reach a scroll edge, i.e. constantly at narrow widths — re-renders - // only the edge fades/chevrons, never the tabs. Without this, each edge + // only the edge fades, never the tabs. Without this, each edge // crossing reconciles the whole list and re-runs useSortable for every tab, // which is what kept narrow-width scrolling stuttery. const dndTabs = useMemo( @@ -391,22 +361,22 @@ export function SecondaryPanelTabStrip({ return ( // Hugs its tabs (no `flex-1`) and shrinks (`min-w-0`) to scroll them under - // the edge fades/chevrons when they overflow. The New Tab button follows this + // the edge fades when they overflow. The New Tab button follows this // viewport as an anchored sibling, so it stays visible at the trailing edge // while overflowing tabs scroll beneath the fades.
- {/* The single surface-colored fade stays opaque beneath the caret at the - outer edge while progressively obscuring only the tab content moving - behind it. The caret itself deliberately adds no second gradient: a - stacked gradient turns this transition into a mismatched solid tile. */} + {/* Keep the overflow cue stationary. Overlay scroll buttons used to + animate above partially visible tabs, making the tab text and selected + fill look clipped while the strip moved. Native wheel, trackpad, and + active-tab scrolling already provide the interaction. */} @@ -414,7 +384,7 @@ export function SecondaryPanelTabStrip({ placement="right" tone={SECONDARY_PANEL_TAB_STRIP_FADE_TONE} className={cn( - "z-10 transition-opacity", + "z-10", overflow.canScrollRight ? "opacity-100" : "opacity-0", )} /> @@ -425,24 +395,17 @@ export function SecondaryPanelTabStrip({ // (see the wheel handler), and CSS smooth-scroll would turn each wheel // notch into its own ~150ms animation — the strip advances, sits frozen // between notches, then jumps. Letting it track 1:1 matches native - // horizontal trackpad scrolling. The chevron buttons opt back into smooth - // per-call via `scrollBy({ behavior: "smooth" })`. - className="no-scrollbar flex min-w-0 items-center gap-1 overflow-x-auto overflow-y-hidden" + // horizontal trackpad scrolling. + className="no-scrollbar min-w-0 overflow-x-auto overflow-y-hidden" > - {dndTabs} +
+ {dndTabs} +
- scrollByStep(-1)} - /> - scrollByStep(1)} - /> ); } @@ -495,68 +458,6 @@ function SortableFileTab({ ); } -interface TabStripScrollChevronProps { - direction: "left" | "right"; - canScroll: boolean; - className: string | null; - onClick: () => void; -} - -function TabStripScrollChevron({ - direction, - canScroll, - className, - onClick, -}: TabStripScrollChevronProps) { - return ( - - ); -} - function FileTab({ tab, activeTreatment, diff --git a/apps/app/src/components/tools/Automations.stories.tsx b/apps/app/src/components/tools/Automations.stories.tsx index 24c9e6fb77..c58e3e3a1a 100644 --- a/apps/app/src/components/tools/Automations.stories.tsx +++ b/apps/app/src/components/tools/Automations.stories.tsx @@ -522,9 +522,11 @@ function AutomationDetail({ actionPending={false} editing={false} executionOptions={executionOptions} + permissionModes={["accept-edits", "auto", "full"]} executionOptionsError={null} onToggle={noop} onEdit={noop} + onCancelEdit={noop} onUpdateAgent={async () => {}} onRunNow={noop} onDelete={noop} diff --git a/apps/app/src/components/tools/SkillDetailView.tsx b/apps/app/src/components/tools/SkillDetailView.tsx index 9812832078..d8fe9dfb99 100644 --- a/apps/app/src/components/tools/SkillDetailView.tsx +++ b/apps/app/src/components/tools/SkillDetailView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { formatHomePathForDisplay } from "@bb/shared-ui/lib/utils"; @@ -129,6 +129,10 @@ function getSkillDirectoryPath(path: string): string { return path.replace(/[\\/]SKILL\.md$/i, ""); } +const SKILL_PAGE_WHEEL_THRESHOLD_PX = 40; +const SKILL_PAGE_WHEEL_GESTURE_RESET_MS = 160; +const WHEEL_LINE_HEIGHT_PX = 16; + function SkillFileList({ files, selectedPath, @@ -173,6 +177,11 @@ function PagedSkillContent({ pageHeight: 0, pageCount: 1, }); + const pageRef = useRef(page); + const pageCountRef = useRef(measurement.pageCount); + const wheelDeltaRef = useRef(0); + const wheelPageChangedRef = useRef(false); + const wheelResetTimeoutRef = useRef(null); useEffect(() => { if (viewport === null || pages === null) return; @@ -209,6 +218,84 @@ function PagedSkillContent({ }, [pages, viewport]); const safePage = Math.min(page, measurement.pageCount - 1); + pageRef.current = safePage; + pageCountRef.current = measurement.pageCount; + + useEffect(() => { + if (viewport === null) return; + const viewportElement = viewport; + + const resetWheelGesture = () => { + wheelDeltaRef.current = 0; + wheelPageChangedRef.current = false; + if (wheelResetTimeoutRef.current !== null) { + window.clearTimeout(wheelResetTimeoutRef.current); + wheelResetTimeoutRef.current = null; + } + }; + + const refreshWheelGestureReset = () => { + if (wheelResetTimeoutRef.current !== null) { + window.clearTimeout(wheelResetTimeoutRef.current); + } + wheelResetTimeoutRef.current = window.setTimeout( + resetWheelGesture, + SKILL_PAGE_WHEEL_GESTURE_RESET_MS, + ); + }; + + const handleWheel = (event: WheelEvent) => { + if ( + event.ctrlKey || + event.deltaY === 0 || + Math.abs(event.deltaX) >= Math.abs(event.deltaY) + ) { + return; + } + + refreshWheelGestureReset(); + const direction = event.deltaY > 0 ? 1 : -1; + const currentPage = pageRef.current; + const pageCount = pageCountRef.current; + const nextPage = currentPage + direction; + if (nextPage < 0 || nextPage >= pageCount) { + if (!wheelPageChangedRef.current) { + wheelDeltaRef.current = 0; + } + return; + } + + event.preventDefault(); + if (wheelPageChangedRef.current) return; + if ( + wheelDeltaRef.current !== 0 && + Math.sign(wheelDeltaRef.current) !== direction + ) { + wheelDeltaRef.current = 0; + } + const normalizedDelta = + event.deltaMode === 1 + ? event.deltaY * WHEEL_LINE_HEIGHT_PX + : event.deltaMode === 2 + ? event.deltaY * viewportElement.clientHeight + : event.deltaY; + wheelDeltaRef.current += normalizedDelta; + if (Math.abs(wheelDeltaRef.current) < SKILL_PAGE_WHEEL_THRESHOLD_PX) { + return; + } + + wheelPageChangedRef.current = true; + wheelDeltaRef.current = 0; + pageRef.current = nextPage; + setPage(nextPage); + }; + + viewportElement.addEventListener("wheel", handleWheel, { passive: false }); + return () => { + viewportElement.removeEventListener("wheel", handleWheel); + resetWheelGesture(); + }; + }, [viewport]); return (
diff --git a/apps/app/src/components/tools/SkillsCollection.tsx b/apps/app/src/components/tools/SkillsCollection.tsx index 5dea01ebb2..248767f2e4 100644 --- a/apps/app/src/components/tools/SkillsCollection.tsx +++ b/apps/app/src/components/tools/SkillsCollection.tsx @@ -79,7 +79,7 @@ function skillSourceFilterId( } function skillSourceFilterLabel(source: ResourceSkillSourceFilter): string { - return source === "bb-official" ? "bb official" : "Included in plugin"; + return source === "bb-official" ? "bb official" : "Plugin"; } function isResourceSkillSourceFilter( diff --git a/apps/app/src/components/tools/automation-overview.test.tsx b/apps/app/src/components/tools/automation-overview.test.tsx index d6ffed83cb..de0f85a842 100644 --- a/apps/app/src/components/tools/automation-overview.test.tsx +++ b/apps/app/src/components/tools/automation-overview.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { AutomationOverviewView } from "bb-plugin-automations/overview-view"; import type { AutomationsOverviewResponse } from "bb-plugin-automations/rpc-types"; @@ -141,8 +142,16 @@ describe("AutomationOverviewView", () => { ); fireEvent.blur(projectsTrigger); fireEvent.pointerDown(projectsTrigger); - expect(screen.getByText("Projects")).toBeTruthy(); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "bb" })); + const projectsMenu = screen.getByRole("menu", { name: "Projects" }); + expect(projectsMenu.className).toContain("md:p-0.5"); + expect(projectsMenu.className).toContain("w-max"); + const projectOption = screen.getByRole("menuitemcheckbox", { name: "bb" }); + expect(projectOption.className).toContain("md:py-1"); + expect(projectOption.querySelector('[data-icon="Folder"]')).toBeTruthy(); + expect( + projectOption.querySelector(".truncate")?.getAttribute("title"), + ).toBe("bb"); + fireEvent.click(projectOption); fireEvent.keyDown(document, { key: "Escape" }); expect( @@ -172,12 +181,100 @@ describe("AutomationOverviewView", () => { fireEvent.blur(statusTrigger); fireEvent.pointerDown(statusTrigger); expect(screen.getByText("Status")).toBeTruthy(); + const activeOption = screen.getByRole("menuitemcheckbox", { + name: "Active", + }); + const pausedOption = screen.getByRole("menuitemcheckbox", { + name: "Paused", + }); + expect(activeOption.querySelector('[data-icon="Play"]')).toBeTruthy(); + expect(pausedOption.querySelector('[data-icon="Pause"]')).toBeTruthy(); + }); + + it("uses compact, icon-labelled sort options and preserves disabled state", () => { + render( + {}} + onOpenDetail={() => {}} + onEnabledChange={async () => {}} + onCreateViaChat={() => {}} + activeMode="installed" + onModeChange={() => {}} + />, + ); + + const sortTrigger = screen.getByRole("button", { + name: "Sort: Automation name, ascending", + }); + expect(sortTrigger.querySelector('[data-icon="ArrowUp"]')).toBeTruthy(); + fireEvent.pointerDown(sortTrigger); + const projectOption = screen.getByRole("menuitemradio", { + name: "Project", + }); + const nameOption = screen.getByRole("menuitemradio", { + name: "Automation name", + }); + expect(projectOption.getAttribute("aria-disabled")).toBe("true"); + expect(projectOption.getAttribute("aria-checked")).toBe("false"); + expect(nameOption.getAttribute("aria-checked")).toBe("true"); + expect(projectOption.querySelector('[data-icon="Folder"]')).toBeTruthy(); + expect(nameOption.querySelector('[data-icon="Sort"]')).toBeTruthy(); + expect(nameOption.className).toContain("md:py-1"); + fireEvent.click(nameOption); + expect(sortTrigger.querySelector('[data-icon="ArrowDown"]')).toBeTruthy(); + expect(sortTrigger.getAttribute("aria-label")).toBe( + "Sort: Automation name, descending", + ); + }); + + it("preserves sort selection semantics in the compact viewport drawer", async () => { + render( + + {}} + onOpenDetail={() => {}} + onEnabledChange={async () => {}} + onCreateViaChat={() => {}} + activeMode="installed" + onModeChange={() => {}} + /> + , + ); + + const sortTrigger = screen.getByRole("button", { + name: "Sort: Automation name, ascending", + }); + fireEvent.click(sortTrigger); + + const projectOption = await screen.findByRole("menuitemradio", { + name: "Project", + }); + const nameOption = screen.getByRole("menuitemradio", { + name: "Automation name", + }); + expect(projectOption.getAttribute("aria-checked")).toBe("false"); + expect(projectOption.getAttribute("aria-disabled")).toBe("true"); + expect((projectOption as HTMLButtonElement).disabled).toBe(true); + expect(nameOption.getAttribute("aria-checked")).toBe("true"); expect( - screen.getByRole("menuitemcheckbox", { name: "Active" }), - ).toBeTruthy(); - expect( - screen.getByRole("menuitemcheckbox", { name: "Paused" }), - ).toBeTruthy(); + screen + .getAllByRole("menuitemradio") + .filter((option) => option.getAttribute("aria-checked") === "true"), + ).toHaveLength(1); + + fireEvent.click(projectOption); + expect(sortTrigger.getAttribute("aria-label")).toBe( + "Sort: Automation name, ascending", + ); + fireEvent.click(nameOption); + expect(sortTrigger.getAttribute("aria-label")).toBe( + "Sort: Automation name, descending", + ); + expect(nameOption.getAttribute("aria-checked")).toBe("true"); }); it("renders template actions as icon-only controls with specific labels", () => { diff --git a/apps/app/src/components/tools/detail-page-recipes.test.tsx b/apps/app/src/components/tools/detail-page-recipes.test.tsx index d11e451dd2..b103de4d39 100644 --- a/apps/app/src/components/tools/detail-page-recipes.test.tsx +++ b/apps/app/src/components/tools/detail-page-recipes.test.tsx @@ -245,6 +245,7 @@ describe("Plugin detail recipe", () => { expect(screen.getByText(item).className).toContain("text-xs"); } const skillName = screen.getByText("review"); + expect(skillName.closest("th")?.className).toContain("items-center"); expect(skillName.parentElement?.className).toContain("items-center"); expect(skillName.previousElementSibling?.className).not.toContain("mt-px"); }); @@ -269,15 +270,16 @@ describe("Plugin detail recipe", () => { name: "Show full description", }); expect(disclosure.getAttribute("aria-expanded")).toBe("false"); + expect(disclosure.className).toContain("text-subtle-foreground"); fireEvent.click(disclosure); expect(detail.className).not.toContain("line-clamp-3"); - expect( - screen - .getByRole("button", { name: "Show less" }) - .getAttribute("aria-expanded"), - ).toBe("true"); + const collapseDisclosure = screen.getByRole("button", { + name: "Show less", + }); + expect(collapseDisclosure.getAttribute("aria-expanded")).toBe("true"); + expect(collapseDisclosure.className).toContain("text-subtle-foreground"); expect(container.textContent).toContain(description); }); @@ -616,6 +618,70 @@ describe("Skill detail recipe", () => { expect(content?.style.transform).toBe("translateY(-540px)"); expect(next.getAttribute("disabled")).not.toBeNull(); }); + + it("pages once per vertical wheel or trackpad gesture", () => { + vi.useFakeTimers(); + try { + const { container } = renderSkill(["/skills/writing-voice/SKILL.md"]); + const viewport = container.querySelector( + "[data-skill-content-viewport]", + ); + const content = container.querySelector( + "[data-skill-content-pages]", + ); + expect(viewport).not.toBeNull(); + expect(content).not.toBeNull(); + + Object.defineProperty(viewport, "clientHeight", { + configurable: true, + value: 240, + }); + Object.defineProperty(content, "scrollHeight", { + configurable: true, + value: 720, + }); + act(() => window.dispatchEvent(new Event("resize"))); + + const pagination = screen.getByRole("navigation", { + name: "Skill content pagination", + }); + fireEvent.wheel(viewport!, { deltaY: -100 }); + expect(pagination.textContent).toContain("Page 1 of 3"); + + // Trackpads emit several small pixel deltas. Accumulate them, then move + // exactly one page for the gesture even if momentum events continue. + fireEvent.wheel(viewport!, { deltaY: 24 }); + expect(pagination.textContent).toContain("Page 1 of 3"); + fireEvent.wheel(viewport!, { deltaY: 24 }); + expect(pagination.textContent).toContain("Page 2 of 3"); + fireEvent.wheel(viewport!, { deltaY: 100 }); + expect(pagination.textContent).toContain("Page 2 of 3"); + + act(() => { + vi.advanceTimersByTime(161); + }); + // Line-mode wheel input is normalized to pixels and uses the same + // threshold and one-page-per-gesture behavior. + fireEvent.wheel(viewport!, { deltaY: 3, deltaMode: 1 }); + expect(pagination.textContent).toContain("Page 3 of 3"); + + // Momentum can keep moving toward the boundary, then briefly rebound in + // the opposite direction. Both events are still part of the gesture that + // moved from page 2 to page 3, so the rebound must not navigate back. + fireEvent.wheel(viewport!, { deltaY: 100 }); + expect(pagination.textContent).toContain("Page 3 of 3"); + fireEvent.wheel(viewport!, { deltaY: -40 }); + expect(pagination.textContent).toContain("Page 3 of 3"); + + act(() => { + vi.advanceTimersByTime(161); + }); + fireEvent.wheel(viewport!, { deltaY: -40 }); + expect(pagination.textContent).toContain("Page 2 of 3"); + } finally { + vi.useRealTimers(); + } + }); }); const AUTOMATION: AutomationResponse = { @@ -662,12 +728,22 @@ const AUTOMATION_EXECUTION_OPTIONS: AutomationExecutionOptionsResponse = { type TestAutomationDetailProps = Omit< ComponentProps, - "editing" | "executionOptions" | "executionOptionsError" | "onUpdateAgent" + | "editing" + | "executionOptions" + | "executionOptionsError" + | "permissionModes" + | "onCancelEdit" + | "onUpdateAgent" > & Partial< Pick< ComponentProps, - "editing" | "executionOptions" | "executionOptionsError" | "onUpdateAgent" + | "editing" + | "executionOptions" + | "executionOptionsError" + | "permissionModes" + | "onCancelEdit" + | "onUpdateAgent" > >; @@ -675,6 +751,8 @@ function AutomationDetailView({ editing = false, executionOptions = AUTOMATION_EXECUTION_OPTIONS, executionOptionsError = null, + permissionModes = AUTOMATION_EXECUTION_OPTIONS.permissionModes, + onCancelEdit = () => {}, onUpdateAgent = async () => {}, ...props }: TestAutomationDetailProps) { @@ -684,6 +762,8 @@ function AutomationDetailView({ editing={editing} executionOptions={executionOptions} executionOptionsError={executionOptionsError} + permissionModes={permissionModes} + onCancelEdit={onCancelEdit} onUpdateAgent={onUpdateAgent} /> ); @@ -731,6 +811,7 @@ describe("Automation detail recipe", () => { }} onToggle={() => {}} onEdit={() => setEditing(true)} + onCancelEdit={() => setEditing(false)} onRunNow={() => {}} onDelete={() => {}} onOpenThread={() => {}} @@ -897,18 +978,53 @@ describe("Automation detail recipe", () => { expect( container.querySelector('[data-automation-provider-icon="claude"] svg'), ).not.toBeNull(); + expect( + container.querySelector( + '[data-automation-provider-icon="claude"] svg.block', + ), + ).not.toBeNull(); const savePrompt = screen.getByRole("button", { name: "Save Prompt" }); expect(promptPanel.contains(savePrompt)).toBe(true); + expect(savePrompt.querySelector('[data-icon="Check"]')).not.toBeNull(); expect((savePrompt as HTMLButtonElement).disabled).toBe(true); - fireEvent.change(promptContent, { + const cancelEditing = screen.getByRole("button", { name: "Cancel" }); + expect((cancelEditing as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(cancelEditing); + expect( + await screen.findByRole("textbox", { name: "Saved prompt" }), + ).toBeTruthy(); + expect(updateAgent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Edit prompt" })); + const reopenedPrompt = screen.getByRole("textbox", { + name: "Automation prompt", + }) as HTMLTextAreaElement; + const reopenedPanel = reopenedPrompt.closest("form") as HTMLElement; + const reopenedModelSelector = reopenedPanel.querySelector( + '[data-automation-selector="Provider and model"]', + ) as HTMLButtonElement; + const reopenedAccessSelector = container.querySelector( + '[data-automation-selector="Permission mode"]', + ) as HTMLButtonElement; + const reopenedSavePrompt = screen.getByRole("button", { + name: "Save Prompt", + }); + fireEvent.change(reopenedPrompt, { target: { value: "Summarize the last two days." }, }); - fireEvent.keyDown(modelSelector, { key: "Enter" }); + fireEvent.keyDown(reopenedModelSelector, { key: "Enter" }); + const modelOptions = await screen.findByRole("listbox"); + expect(modelOptions.className).toContain("w-max"); + expect(modelOptions.className).toContain("min-w-0"); fireEvent.click(await screen.findByRole("option", { name: "Sonnet 5" })); - fireEvent.keyDown(accessSelector, { key: "Enter" }); + fireEvent.keyDown(reopenedAccessSelector, { key: "Enter" }); fireEvent.click(await screen.findByRole("option", { name: "Full Access" })); - expect((savePrompt as HTMLButtonElement).disabled).toBe(false); - fireEvent.click(savePrompt); + expect((reopenedSavePrompt as HTMLButtonElement).disabled).toBe(false); + expect( + (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + fireEvent.click(reopenedSavePrompt); expect(updateAgent).toHaveBeenCalledWith({ prompt: "Summarize the last two days.", model: "claude-sonnet-5", @@ -922,6 +1038,44 @@ describe("Automation detail recipe", () => { ).toBeNull(); }); + it("does not make permission editing wait for model discovery", () => { + const { container } = render( + + {}, + retry: () => {}, + }} + actionPending={false} + editing + executionOptions={null} + permissionModes={["accept-edits", "auto", "full"]} + onToggle={() => {}} + onEdit={() => {}} + onRunNow={() => {}} + onDelete={() => {}} + onOpenThread={() => {}} + /> + , + ); + + const permissionSelector = container.querySelector( + '[data-automation-selector="Permission mode"]', + ) as HTMLButtonElement; + const modelSelector = container.querySelector( + '[data-automation-selector="Provider and model"]', + ) as HTMLButtonElement; + expect(permissionSelector.disabled).toBe(false); + expect(modelSelector.disabled).toBe(true); + }); + it("uses the composer metadata treatment without inventing reasoning", () => { const { container } = render( diff --git a/apps/app/src/components/tools/plugin-detail-table.tsx b/apps/app/src/components/tools/plugin-detail-table.tsx index 96c925ffc2..bd67a310f5 100644 --- a/apps/app/src/components/tools/plugin-detail-table.tsx +++ b/apps/app/src/components/tools/plugin-detail-table.tsx @@ -166,7 +166,7 @@ export function PluginDetailRow({ className={cn( CELL, PLUGIN_DETAIL_HEADER_CELL_CLASS, - "text-left font-normal", + "flex items-center text-left font-normal", hasDetail ? "border-r border-border pl-4 pr-2" : "px-4", )} colSpan={hasDetail ? undefined : 2} @@ -210,7 +210,7 @@ export function PluginDetailRow({ type="button" aria-expanded={expanded} aria-controls={detailId} - className="mt-2 rounded-sm text-xs font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + className="mt-2 rounded-sm text-xs font-medium text-subtle-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" onClick={() => setExpanded((current) => !current)} > {expanded ? "Show less" : "Show full description"} diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx index 614ea69c85..595c4956ad 100644 --- a/apps/app/src/views/SkillsView.test.tsx +++ b/apps/app/src/views/SkillsView.test.tsx @@ -319,12 +319,10 @@ describe("SkillsOverview", () => { ).toBe("true"); expect( screen - .getByRole("menuitemcheckbox", { name: "Included in plugin" }) + .getByRole("menuitemcheckbox", { name: "Plugin" }) .getAttribute("aria-checked"), ).toBe("false"); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), - ); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); expect(await screen.findByText("automations")).toBeTruthy(); expect( @@ -332,14 +330,12 @@ describe("SkillsOverview", () => { "automations is included with Automations (bb plugin)", ).textContent, ).toBe("Included"); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), - ); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); expect(screen.queryByText("automations")).toBeNull(); expect(screen.getByText("official-skill")).toBeTruthy(); }); - it("toggles BB official independently from Included", async () => { + it("toggles BB official independently from Plugin", async () => { renderDom( { ); fireEvent.pointerDown(screen.getByRole("button", { name: "bb official" })); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), - ); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); fireEvent.click( screen.getByRole("menuitemcheckbox", { name: "bb official" }), ); diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx index 0691b64f11..4a0fdae993 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx @@ -419,6 +419,25 @@ function pluginSplitLayout(): SplitLayout { }; } +function twoPluginSplitLayout(): SplitLayout { + return { + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [ + { + type: "pane", + paneId: "pane-automations", + content: pluginContent("automations"), + }, + { type: "pane", paneId: "pane-docs", content: docsContent }, + ], + }, + focusedPaneId: "pane-docs", + }; +} + function threadPath(threadId: string): string { return `/threads/${threadId}`; } @@ -1233,17 +1252,15 @@ describe("SplitThreadArea", () => { expect(showPanel.hasAttribute("disabled")).toBe(false); fireEvent.click(showPanel); + expect(await screen.findByTestId("hosted-new-thread-panel")).toBeTruthy(); expect( - await screen.findByTestId("hosted-new-thread-panel"), - ).toBeTruthy(); - expect( - screen.getByRole("button", { name: "Hide right panel" }).getAttribute( - "aria-expanded", - ), + screen + .getByRole("button", { name: "Hide right panel" }) + .getAttribute("aria-expanded"), ).toBe("true"); }); - it("suppresses and disables the panel on a pane with no panel support", async () => { + it("omits app panel and full-screen controls from plugin panes", async () => { const layout = pluginSplitLayout(); layout.focusedPaneId = "pane-1"; renderSplitArea({ @@ -1264,23 +1281,24 @@ describe("SplitThreadArea", () => { throw new Error("Expected plugin split pane"); } - // Focusing the plugin pane hides the unavailable panel and disables its - // disclosure without discarding the window-level open state. + // Focusing the plugin pane hides the app panel without layering disabled + // app controls over the plugin's own header and right panel. fireEvent.pointerDown(pluginPane); - const unavailableToggle = await screen.findByRole("button", { - name: "Right panel unavailable", - }); - expect(unavailableToggle.hasAttribute("disabled")).toBe(true); - expect(unavailableToggle.getAttribute("aria-expanded")).toBe("false"); + await waitFor(() => + expect(screen.queryByTestId("split-workspace-panel-toggle")).toBeNull(), + ); + expect( + document.getElementById("split-workspace-empty-secondary-panel"), + ).toBeNull(); + expect( + document.getElementById("split-workspace-empty-secondary-panel-handle"), + ).toBeNull(); + expect( + pluginPane.querySelector('button[aria-label*="Full Screen"]'), + ).toBeNull(); expect( screen.queryByTestId("split-workspace-empty-panel-state"), ).toBeNull(); - const emptyPanelHandle = document.getElementById( - "split-workspace-empty-secondary-panel-handle", - ); - expect(emptyPanelHandle?.classList).toContain("w-0"); - expect(emptyPanelHandle?.classList).toContain("pointer-events-none"); - // Refocusing the thread pane restores the remembered open panel. fireEvent.pointerDown(screen.getByTestId("pane-thr-a")); const restoredOpenToggle = await screen.findByRole("button", { @@ -1293,6 +1311,82 @@ describe("SplitThreadArea", () => { ).toBeNull(); }); + it("preserves plugin-owned right panels with and without a plugin split", async () => { + setPluginSlotRegistrations("test-plugin", { + homepageSections: [], + settingsSections: [], + navPanels: [ + { + id: "automations", + title: "Automations", + icon: "Clock", + path: "automations", + component: () =>
Automations content
, + }, + ], + threadPanelActions: [], + pendingInteractions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + }); + setPluginSlotRegistrations("docs", { + homepageSections: [], + settingsSections: [], + navPanels: [ + { + id: "docs", + title: "Docs", + icon: "FileText", + path: "docs", + component: () =>
Docs content with notes sidebar
, + headerContent: () => ( + + ), + }, + ], + threadPanelActions: [], + pendingInteractions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + }); + + renderSplitArea({ + path: "/plugins/docs/docs", + layout: twoPluginSplitLayout(), + routeContent: docsContent, + }); + + expect(await screen.findByText("Automations content")).toBeTruthy(); + const docsPanelContent = screen.getByText( + "Docs content with notes sidebar", + ); + expect(docsPanelContent).toBeTruthy(); + expect(docsPanelContent.closest(".isolate")).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Collapse notes sidebar" }), + ).toBeTruthy(); + expect(screen.queryByTestId("split-workspace-panel-toggle")).toBeNull(); + expect(screen.queryByRole("button", { name: /Full Screen/ })).toBeNull(); + + fireEvent.click(screen.getAllByRole("button", { name: "Close pane" })[0]!); + + await waitFor(() => + expect(screen.queryByText("Automations content")).toBeNull(), + ); + expect( + screen + .getByText("Docs content with notes sidebar") + .closest(".isolate"), + ).toBeNull(); + expect( + screen.getByRole("button", { name: "Collapse notes sidebar" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Close pane" })).toBeNull(); + expect(screen.queryByTestId("split-workspace-panel-toggle")).toBeNull(); + }); + it("mounts both panes with independent, threadId-keyed drafts", async () => { renderSplitArea({ path: threadPath("thr-b"), @@ -1504,7 +1598,7 @@ describe("SplitThreadArea", () => { } }); - it("reserves collapsed window-left chrome only for the structural top-left pane", async () => { + it("reserves collapsed window-left chrome only for the structural top-left plugin pane", async () => { const desktopInfo: BbDesktopInfo = { lastCheckedAt: null, latestVersion: null, @@ -1550,24 +1644,7 @@ describe("SplitThreadArea", () => { expect((await contentRow(path))?.className).not.toContain("pl-[104px]"); } - fireEvent.click(screen.getAllByRole("button", { name: /Full Screen/ })[3]!); - await waitFor(() => - expect(contentRow("bottom-right")).resolves.toHaveProperty( - "className", - expect.stringContaining("pl-[104px]"), - ), - ); - expect((await contentRow("top-left"))?.className).not.toContain( - "pl-[104px]", - ); - - fireEvent.click(screen.getByRole("button", { name: /Exit Full Screen/ })); - await waitFor(() => - expect(contentRow("top-left")).resolves.toHaveProperty( - "className", - expect.stringContaining("pl-[104px]"), - ), - ); + expect(screen.queryByRole("button", { name: /Full Screen/ })).toBeNull(); }); it("assigns exactly one top-left owner through eight-pane structural changes", async () => { diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index 0ae4077a1c..d12ddc3076 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -266,11 +266,16 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { : null); const panes = layout === null ? [] : listPanes(layout.root); const isSplitActive = threadSplitsEnabled && !isCompact && panes.length > 1; + const maximizedPane = + layout !== null && maximizedPaneId !== null + ? findPane(layout.root, maximizedPaneId) + : null; const effectiveMaximizedPaneId = layout !== null && countPanes(layout.root) > 1 && maximizedPaneId !== null && - findPane(layout.root, maximizedPaneId) !== null + maximizedPane !== null && + maximizedPane.content.kind !== "plugin-panel" ? maximizedPaneId : null; const { @@ -328,7 +333,8 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { if ( layout === null || countPanes(layout.root) < 2 || - findPane(layout.root, maximizedPaneId) === null + maximizedPane === null || + maximizedPane.content.kind === "plugin-panel" ) { setMaximizedPaneId(null); return; @@ -336,7 +342,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { if (layout.focusedPaneId !== maximizedPaneId) { setMaximizedPaneId(layout.focusedPaneId); } - }, [layout, maximizedPaneId, setMaximizedPaneId]); + }, [layout, maximizedPane, maximizedPaneId, setMaximizedPaneId]); // Content navigation inside a pane pushes history like the page surface does // today. replacePaneContent focuses the pane, so the pushed URL matches it. @@ -397,10 +403,12 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { const toggleMaximizePane = useCallback( (paneId: string) => { const current = store.get(splitLayoutAtom); + const pane = current === null ? null : findPane(current.root, paneId); if ( current === null || countPanes(current.root) < 2 || - findPane(current.root, paneId) === null + pane === null || + pane.content.kind === "plugin-panel" ) { return; } @@ -783,11 +791,22 @@ function SplitTree(props: SplitTreeProps) { isFocused={isFocused} isSplitPane secondaryPanelRegistry={props.secondaryPanelRegistry} - reservesWindowPanelToggle={isMaximized || (isTopRow && isRightEdge)} + reservesWindowPanelToggle={ + node.content.kind !== "plugin-panel" && + (isMaximized || (isTopRow && isRightEdge)) + } onRequestClose={() => props.onClosePane(node.paneId)} isMaximized={isMaximized} - onToggleMaximize={() => props.onToggleMaximizePane(node.paneId)} - onMoveToSide={(side) => props.onMovePaneToSide(node.paneId, side)} + onToggleMaximize={ + node.content.kind === "plugin-panel" + ? null + : () => props.onToggleMaximizePane(node.paneId) + } + onMoveToSide={ + node.content.kind === "plugin-panel" + ? undefined + : (side) => props.onMovePaneToSide(node.paneId, side) + } isBoundedPane isTopRow={isMaximized || isTopRow} ownsWindowTopLeft={ @@ -1051,7 +1070,7 @@ function NonThreadPaneContent({ subPath={content.kind === "plugin-panel" ? content.subPath : ""} /> ) : null} - + {content.kind === "plugin-panel" ? null : } {onRequestClose ? (
- {model === null ? ( - <> - {/* Keep a collapsed second panel registered with the group while - the focused plugin pane has no secondary-panel model. */} - - - - ) : ( - - {model.panel} - - )} + + {model.panel} + diff --git a/official-plugins/docs/app.test.tsx b/official-plugins/docs/app.test.tsx index 1422b18c6e..3e7d7e69ac 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -1,5 +1,12 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadPluginApp, renderSlot } from "@bb/plugin-sdk/testing/app"; @@ -22,7 +29,10 @@ beforeEach(() => { }); }); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); interface NoteSummary { path: string; @@ -101,7 +111,7 @@ describe("Docs nav panel", () => { }); }); - it("moves the sidebar toggle into the shared panel header", async () => { + it("keeps the sidebar toggle in the page header above sidebar actions", async () => { const panel = app.navPanels[0]!; const slot = renderSlot( panel, @@ -112,20 +122,73 @@ describe("Docs nav panel", () => { const HeaderContent = panel.headerContent!; const header = render(); - await waitFor(() => { - expect( - slot.container.querySelector('[aria-label="Collapse notes sidebar"]'), - ).toBeNull(); + const headerSegment = header.getByTestId("notes-sidebar-header"); + const headerBackground = header.getByTestId( + "notes-sidebar-header-background", + ); + expect(headerSegment.classList.contains("w-8")).toBe(true); + expect(headerBackground.classList.contains("bg-sidebar")).toBe(true); + expect(headerBackground.style.width).toBe("288px"); + expect(headerBackground.style.right).toBe("-16px"); + const toolbar = slot.getByRole("toolbar", { + name: "Notes sidebar actions", }); - + expect(slot.getByRole("navigation", { name: "Notes" })).toBeTruthy(); + expect( + slot.container.querySelector("aside")?.classList.contains("bg-sidebar"), + ).toBe(true); + expect( + within(toolbar).getByRole("button", { name: "Search notes" }), + ).toBeTruthy(); + expect( + within(toolbar).getByRole("button", { name: "New note" }), + ).toBeTruthy(); + expect( + within(toolbar).getByRole("button", { name: "New folder" }), + ).toBeTruthy(); + expect( + within(toolbar).queryByRole("button", { + name: "Collapse notes sidebar", + }), + ).toBeNull(); fireEvent.click( header.getByRole("button", { name: "Collapse notes sidebar" }), ); expect(slot.container.querySelector("aside")?.style.width).toBe("0px"); + expect(headerBackground.style.width).toBe("48px"); fireEvent.click( header.getByRole("button", { name: "Expand notes sidebar" }), ); expect(slot.container.querySelector("aside")?.style.width).toBe("288px"); + expect(headerBackground.style.width).toBe("288px"); + + header.unmount(); + const fallbackToggle = await slot.findByRole("button", { + name: "Collapse notes sidebar", + }); + fireEvent.click(slot.getByRole("button", { name: "Search notes" })); + await waitFor(() => { + expect(slot.getByRole("button", { name: "Close search" })).toBeTruthy(); + }); + expect(fallbackToggle.parentElement?.classList.contains("gap-1")).toBe( + true, + ); + }); + + it("keeps the sidebar header background aligned behind split host controls", () => { + const HeaderContent = app.navPanels[0]!.headerContent!; + const header = render( +
+ +
, + ); + + const headerSegment = header.getByTestId("notes-sidebar-header"); + const background = header.getByTestId("notes-sidebar-header-background"); + expect(headerSegment.classList.contains("-mr-4")).toBe(false); + expect(headerSegment.classList.contains("w-8")).toBe(true); + expect(background.style.right).toBe("-48px"); + expect(background.style.width).toBe("288px"); }); it("keeps the right sidebar pinned while a note loads", async () => { @@ -159,6 +222,19 @@ describe("Docs nav panel", () => { }); it("keeps folder children together and lets the sidebar collapse and resize", async () => { + let nextFrameId = 1; + const frames = new Map(); + const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + const frameId = nextFrameId; + nextFrameId += 1; + frames.set(frameId, callback); + return frameId; + }); + const cancelAnimationFrame = vi.fn((frameId: number) => { + frames.delete(frameId); + }); + vi.stubGlobal("requestAnimationFrame", requestAnimationFrame); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); const slot = renderSlot( app.navPanels[0]!, { subPath: "personal" }, @@ -218,11 +294,124 @@ describe("Docs nav panel", () => { fireEvent.pointerDown(resizeHandle, { clientX: 288, pointerId: 7 }); expect(setPointerCapture).toHaveBeenCalledWith(7); fireEvent.pointerMove(resizeHandle, { clientX: 176, pointerId: 7 }); - expect(slot.container.querySelector("aside")?.style.width).toBe("400px"); + fireEvent.pointerMove(resizeHandle, { clientX: 156, pointerId: 7 }); + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + expect(slot.container.querySelector("aside")?.style.width).toBe("288px"); + const firstFrame = frames.entries().next().value as + | [number, FrameRequestCallback] + | undefined; + expect(firstFrame).toBeTruthy(); + act(() => firstFrame?.[1](0)); + frames.delete(firstFrame?.[0] ?? -1); + expect(slot.container.querySelector("aside")?.style.width).toBe("420px"); + fireEvent.pointerMove(resizeHandle, { clientX: 146, pointerId: 7 }); + expect(requestAnimationFrame).toHaveBeenCalledTimes(2); fireEvent.pointerUp(resizeHandle, { pointerId: 7 }); + expect(cancelAnimationFrame).toHaveBeenCalledTimes(1); + expect(slot.container.querySelector("aside")?.style.width).toBe("430px"); expect(releasePointerCapture).toHaveBeenCalledWith(7); }); + it("isolates sidebar width and collapse state between panes on one vault", async () => { + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 17), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + const panel = app.navPanels[0]!; + const PanelContent = panel.component; + const rpc = { listNotes: () => listNotesResult([]) }; + const first = renderSlot(panel, { subPath: "personal" }, { rpc }); + const second = renderSlot(panel, { subPath: "personal" }, { rpc }); + first.rerender( +
+ +
, + ); + second.rerender( +
+ +
, + ); + await within(first.container).findByText("Select a note or HTML page."); + await within(second.container).findByText("Select a note or HTML page."); + + const HeaderContent = panel.headerContent!; + const firstHeader = render( +
+ +
, + ); + const secondHeader = render( +
+ +
, + ); + const firstAside = first.container.querySelector("aside"); + const secondAside = second.container.querySelector("aside"); + expect(firstAside?.style.width).toBe("288px"); + expect(secondAside?.style.width).toBe("288px"); + + const resizeHandle = within(first.container).getByRole("separator", { + name: "Resize notes sidebar", + }); + resizeHandle.setPointerCapture = vi.fn(); + resizeHandle.hasPointerCapture = () => true; + resizeHandle.releasePointerCapture = vi.fn(); + fireEvent.pointerDown(resizeHandle, { clientX: 288, pointerId: 11 }); + fireEvent.pointerMove(resizeHandle, { clientX: 176, pointerId: 11 }); + fireEvent.pointerUp(resizeHandle, { pointerId: 11 }); + + expect(firstAside?.style.width).toBe("400px"); + expect(secondAside?.style.width).toBe("288px"); + expect( + within(firstHeader.container).getByTestId( + "notes-sidebar-header-background", + ).style.width, + ).toBe("400px"); + expect( + within(secondHeader.container).getByTestId( + "notes-sidebar-header-background", + ).style.width, + ).toBe("288px"); + + fireEvent.click( + within(firstHeader.container).getByRole("button", { + name: "Collapse notes sidebar", + }), + ); + expect(firstAside?.style.width).toBe("0px"); + expect(secondAside?.style.width).toBe("288px"); + expect( + within(secondHeader.container).getByRole("button", { + name: "Collapse notes sidebar", + }), + ).toBeTruthy(); + + firstHeader.unmount(); + first.unmount(); + const reopened = renderSlot(panel, { subPath: "personal" }, { rpc }); + reopened.rerender( +
+ +
, + ); + await within(reopened.container).findByText("Select a note or HTML page."); + const reopenedHeader = render( +
+ +
, + ); + expect(reopened.container.querySelector("aside")?.style.width).toBe( + "288px", + ); + expect( + within(reopenedHeader.container).getByRole("button", { + name: "Collapse notes sidebar", + }), + ).toBeTruthy(); + }); + it("defaults the sidebar to collapsed in a narrow pane but allows expanding", async () => { class FakeResizeObserver { observe() {} diff --git a/official-plugins/docs/app.tsx b/official-plugins/docs/app.tsx index b98d7b19a5..c49dc7f288 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -1241,10 +1241,16 @@ function orderEntries( } const SIDEBAR_AUTO_COLLAPSE_PANE_WIDTH = 640; +const HEADER_STANDALONE_EDGE_OFFSET = 16; +// Split host actions follow plugin header content: 4px action gap + 28px close +// button + the header's 16px edge padding. The visual sidebar background can +// extend behind that host-owned chrome, but its layout box must not overlap it. +const HEADER_SPLIT_EDGE_OFFSET = 48; interface NotesSidebarState { headerMounted: boolean; paneNarrow: boolean; + width: number; userCollapsed: boolean | null; } @@ -1256,12 +1262,36 @@ interface NotesSidebarStore { } const notesSidebarStores = new Map(); +const STANDALONE_SIDEBAR_SCOPE = "standalone"; -function notesSidebarKey(subPath: string): string { +function notesSidebarVaultKey(subPath: string): string { const firstSegment = subPath.split("/", 1)[0]; return firstSegment ? decodeURIComponent(firstSegment) : ""; } +function useNotesSidebarScope(subPath: string): { + isSplitPane: boolean; + scopeRef(element: HTMLElement | null): void; + storeKey: string; +} { + const [paneScope, setPaneScope] = useState(STANDALONE_SIDEBAR_SCOPE); + const scopeRef = useCallback((element: HTMLElement | null) => { + if (element === null) return; + const nextPaneScope = + element + .closest("[data-split-pane-id]") + ?.getAttribute("data-split-pane-id") ?? STANDALONE_SIDEBAR_SCOPE; + setPaneScope((current) => + current === nextPaneScope ? current : nextPaneScope, + ); + }, []); + return { + isSplitPane: paneScope !== STANDALONE_SIDEBAR_SCOPE, + scopeRef, + storeKey: `${paneScope}:${notesSidebarVaultKey(subPath)}`, + }; +} + function getNotesSidebarStore(key: string): NotesSidebarStore { const existing = notesSidebarStores.get(key); if (existing) return existing; @@ -1269,6 +1299,7 @@ function getNotesSidebarStore(key: string): NotesSidebarStore { state: { headerMounted: false, paneNarrow: false, + width: 288, userCollapsed: null, }, headerMounts: 0, @@ -1287,6 +1318,7 @@ function updateNotesSidebarState( if ( next.headerMounted === store.state.headerMounted && next.paneNarrow === store.state.paneNarrow && + next.width === store.state.width && next.userCollapsed === store.state.userCollapsed ) { return; @@ -1295,6 +1327,19 @@ function updateNotesSidebarState( for (const listener of store.listeners) listener(); } +function deleteUnusedNotesSidebarStore( + key: string, + store: NotesSidebarStore, +): void { + if ( + store.headerMounts === 0 && + store.viewMounts === 0 && + notesSidebarStores.get(key) === store + ) { + notesSidebarStores.delete(key); + } +} + function useNotesSidebarState(key: string): { state: NotesSidebarState; store: NotesSidebarStore; @@ -1312,10 +1357,30 @@ function useNotesSidebarState(key: string): { return { state, store }; } -function NotesPanelHeader({ subPath }: PluginNavPanelProps) { - const { state: sidebar, store } = useNotesSidebarState( - notesSidebarKey(subPath), +function NotesSidebarToggle({ + collapsed, + onCollapsedChange, +}: { + collapsed: boolean; + onCollapsedChange(collapsed: boolean): void; +}) { + return ( + ); +} + +function NotesPanelHeader({ subPath }: PluginNavPanelProps) { + const { isSplitPane, scopeRef, storeKey } = useNotesSidebarScope(subPath); + const { state: sidebar, store } = useNotesSidebarState(storeKey); const collapsed = sidebar.userCollapsed ?? sidebar.paneNarrow; useLayoutEffect(() => { store.headerMounts += 1; @@ -1325,21 +1390,119 @@ function NotesPanelHeader({ subPath }: PluginNavPanelProps) { if (store.headerMounts === 0) { updateNotesSidebarState(store, { headerMounted: false }); } + deleteUnusedNotesSidebarStore(storeKey, store); }; - }, [store]); + }, [store, storeKey]); return ( - + + + updateNotesSidebarState(store, { userCollapsed }) + } + /> + + ); +} + +interface NotesSidebarNavigationProps { + query: string; + searchOpen: boolean; + onQueryChange(value: string): void; + onSearchOpenChange(open: boolean): void; + onNewNote(): void; + onNewFolder(): void; +} + +function NotesSidebarNavigation(props: NotesSidebarNavigationProps) { + return ( +
+ {props.searchOpen ? ( + <> +
+ + props.onQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + props.onQueryChange(""); + props.onSearchOpenChange(false); + }} + placeholder="Search this vault" + /> +
+ + + ) : null} + {!props.searchOpen ? ( + <> + + + + + + ) : null} +
); } @@ -1383,17 +1546,26 @@ function Tree({ edge: "before" | "after"; } | null>(null); const [folderDropTarget, setFolderDropTarget] = useState(null); + const { scopeRef, storeKey } = useNotesSidebarScope(sidebarKey); const { state: sidebar, store: sidebarStore } = - useNotesSidebarState(sidebarKey); + useNotesSidebarState(storeKey); // null = follow the responsive default (collapsed in narrow panes) until // the user toggles the sidebar explicitly. const sidebarCollapsed = sidebar.userCollapsed ?? sidebar.paneNarrow; - const [sidebarWidth, setSidebarWidth] = useState(288); + const sidebarWidth = sidebar.width; const asideRef = useRef(null); + const setAsideRef = useCallback( + (element: HTMLElement | null) => { + asideRef.current = element; + scopeRef(element); + }, + [scopeRef], + ); useLayoutEffect(() => { if (sidebarStore.viewMounts === 0) { updateNotesSidebarState(sidebarStore, { paneNarrow: false, + width: 288, userCollapsed: null, }); } @@ -1403,11 +1575,13 @@ function Tree({ if (sidebarStore.viewMounts === 0) { updateNotesSidebarState(sidebarStore, { paneNarrow: false, + width: 288, userCollapsed: null, }); } + deleteUnusedNotesSidebarStore(storeKey, sidebarStore); }; - }, [sidebarStore]); + }, [sidebarStore, storeKey]); useLayoutEffect(() => { const pane = asideRef.current?.parentElement; if (!pane || typeof ResizeObserver === "undefined") return; @@ -1559,10 +1733,20 @@ function Tree({ const pointerId = event.pointerId; const startX = event.clientX; const startWidth = sidebarWidth; + let frame: number | null = null; + let pendingWidth = startWidth; + const commitPendingWidth = () => { + frame = null; + updateNotesSidebarState(sidebarStore, { width: pendingWidth }); + }; const move = (moveEvent: PointerEvent) => { - setSidebarWidth( - Math.min(480, Math.max(220, startWidth + startX - moveEvent.clientX)), + pendingWidth = Math.min( + 480, + Math.max(220, startWidth + startX - moveEvent.clientX), ); + if (frame === null) { + frame = window.requestAnimationFrame(commitPendingWidth); + } }; const cleanup = () => { if (resizeCleanupRef.current !== cleanup) return; @@ -1571,6 +1755,10 @@ function Tree({ handle.removeEventListener("pointercancel", cleanup); handle.removeEventListener("lostpointercapture", cleanup); window.removeEventListener("blur", cleanup); + if (frame !== null) { + window.cancelAnimationFrame(frame); + commitPendingWidth(); + } resizeCleanupRef.current = null; if (handle.hasPointerCapture(pointerId)) { handle.releasePointerCapture(pointerId); @@ -1588,26 +1776,21 @@ function Tree({ if (sidebarCollapsed) { return ( ); @@ -1615,88 +1798,26 @@ function Tree({ return (