From 8a76ac2508d21742959efec8ba42530d447057bc Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 4 Aug 2026 10:58:00 -0700 Subject: [PATCH 1/4] Compact automation filter menus --- .../tools/automation-overview.test.tsx | 52 ++++++++++++--- .../src/components/ui/resource/toolbar.tsx | 63 ++++++++++++++++--- plugins/automations/overview-view.tsx | 38 ++++++++--- 3 files changed, 128 insertions(+), 25 deletions(-) diff --git a/apps/app/src/components/tools/automation-overview.test.tsx b/apps/app/src/components/tools/automation-overview.test.tsx index d6ffed83cb..a79c7ad7ac 100644 --- a/apps/app/src/components/tools/automation-overview.test.tsx +++ b/apps/app/src/components/tools/automation-overview.test.tsx @@ -141,8 +141,13 @@ 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(); + fireEvent.click(projectOption); fireEvent.keyDown(document, { key: "Escape" }); expect( @@ -172,12 +177,43 @@ describe("AutomationOverviewView", () => { fireEvent.blur(statusTrigger); fireEvent.pointerDown(statusTrigger); expect(screen.getByText("Status")).toBeTruthy(); - expect( - screen.getByRole("menuitemcheckbox", { name: "Active" }), - ).toBeTruthy(); - expect( - screen.getByRole("menuitemcheckbox", { name: "Paused" }), - ).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" }); + expect(sortTrigger.querySelector('[data-icon="ArrowUp"]')).toBeTruthy(); + fireEvent.pointerDown(sortTrigger); + const projectOption = screen.getByRole("menuitem", { name: "Project" }); + const nameOption = screen.getByRole("menuitem", { + name: "Automation name", + }); + expect(projectOption.getAttribute("aria-disabled")).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(); }); it("renders template actions as icon-only controls with specific labels", () => { diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index d79a247bda..59be4cd6f2 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -87,9 +87,20 @@ export interface ResourceOption { disabled?: boolean; } -function ResourceOptionContent({ option }: { option: ResourceOption }) { +function ResourceOptionContent({ + option, + compact = false, +}: { + option: ResourceOption; + compact?: boolean; +}) { return ( - + {option.leading ? ( ReactNode; allOptionLabel?: string; emptySelectionLabel?: string; + compact?: boolean; }) { const selected = new Set(selectedValues); const enabledOptions = options.filter((option) => !option.disabled); @@ -258,13 +271,25 @@ export function ResourceMultiSelectMenu({ active={activeSelectedCount > 0} tooltip={triggerTooltip} /> - - + + {label} {allOptionLabel ? ( event.preventDefault()} onCheckedChange={(checked) => onChange( @@ -280,10 +305,11 @@ export function ResourceMultiSelectMenu({ key={option.id} checked={selected.has(option.id)} disabled={option.disabled} + className={cn(compact && "md:py-1 md:pl-1.5 md:pr-7")} onSelect={(event) => event.preventDefault()} onCheckedChange={(checked) => updateValue(option, checked === true)} > - + ))} @@ -296,17 +322,31 @@ export function ResourceSortMenu({ direction, options, onChange, + compact = false, }: { value: string; direction: "asc" | "desc"; options: readonly ResourceOption[]; onChange: (value: string) => void; + compact?: boolean; }) { return ( - - - + + + Sort by {options.map((option) => { @@ -320,9 +360,12 @@ export function ResourceSortMenu({ if (option.disabled) return; onChange(option.id); }} - className="flex items-center justify-between gap-3" + className={cn( + "flex items-center justify-between gap-3", + compact && "md:gap-2 md:px-1.5 md:py-1", + )} > - + + ); +} + +const AUTOMATION_STATUS_FILTER_OPTIONS = [ + { id: "active", label: "Active", leading: automationMenuIcon("Play") }, + { id: "paused", label: "Paused", leading: automationMenuIcon("Pause") }, +] as const; + export const CREATE_AUTOMATION_PROMPT = "Create a new bb automation to "; export const AUTOMATION_CREATE_TEMPLATES = [ { @@ -312,7 +327,11 @@ export function AutomationOverviewView({ automationProjectLabel(entry.project), ); } - return [...options].map(([id, label]) => ({ id, label })); + return [...options].map(([id, label]) => ({ + id, + label, + leading: automationMenuIcon("Folder"), + })); }, [entries]); useEffect(() => { setProjectFilters((current) => @@ -485,6 +504,7 @@ export function AutomationOverviewView({ @@ -494,11 +514,9 @@ export function AutomationOverviewView({ setStatusFilters(values as AutomationStatusFilter[]) } @@ -506,13 +524,19 @@ export function AutomationOverviewView({ From f16aea4dd71bc3c43aa373dcba107d55f5474e03 Mon Sep 17 00:00:00 2001 From: brsbl Date: Tue, 4 Aug 2026 15:49:51 -0700 Subject: [PATCH 2/4] Unify Docs right-panel navigation (#1000) ## Summary - keep the Docs side-panel collapse control in the shared page/pane header, separate from the plugin-owned panel toolbar - reuse one toggle component for host-header and embedded fallback layouts - keep the header segment aligned to the resizable right panel and paint both with the themed sidebar surface - cover expanded, collapsed, restored, and fallback placement behavior ## Verification - `pnpm exec turbo run test --filter=bb-plugin-simple-notes -- --run app.test.tsx` (22 passed) - `pnpm exec turbo run typecheck --filter=bb-plugin-simple-notes` - branch desktop app: full-page Docs expanded/collapsed/restored; header separation and surface continuity verified --- .../management/BrowsePluginsTab.test.tsx | 12 +- .../plugin/management/BrowsePluginsTab.tsx | 2 +- .../SecondaryPanelTabStrip.test.ts | 76 ++++-- .../SecondaryPanelTabStrip.tsx | 155 ++--------- .../components/tools/Automations.stories.tsx | 2 + .../src/components/tools/SkillDetailView.tsx | 89 +++++- .../src/components/tools/SkillsCollection.tsx | 2 +- .../tools/detail-page-recipes.test.tsx | 178 +++++++++++- .../components/tools/plugin-detail-table.tsx | 4 +- apps/app/src/views/SkillsView.test.tsx | 16 +- .../thread-detail/SplitThreadArea.test.tsx | 155 ++++++++--- .../views/thread-detail/SplitThreadArea.tsx | 45 ++- .../SplitWorkspaceSecondaryPanelHost.tsx | 94 ++----- official-plugins/docs/app.test.tsx | 46 +++- official-plugins/docs/app.tsx | 257 +++++++++++------- plugins/automations/app.tsx | 112 +++++++- plugins/automations/detail-view.tsx | 48 +++- plugins/automations/lib/provider-icon.tsx | 2 +- plugins/automations/src/cli.ts | 9 +- .../automations/src/provider-permissions.ts | 21 +- plugins/automations/src/rpc-types.ts | 9 + plugins/automations/src/rpc.ts | 10 + .../automations/src/server-harness.test.ts | 52 +++- plugins/automations/src/service.ts | 59 +++- 24 files changed, 1011 insertions(+), 444 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index 4e1a353e21..a2f500eb9a 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -362,7 +362,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..6a27dff279 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts @@ -1,29 +1,73 @@ +// @vitest-environment jsdom + +import { 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[] = []; + vi.stubGlobal( + "ResizeObserver", + class { + 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(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(); }); }); 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/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..4c3bc8b997 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -1,5 +1,11 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { + 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"; @@ -101,7 +107,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 +118,46 @@ 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"); + expect(headerSegment.classList.contains("bg-sidebar")).toBe(true); + expect(headerSegment.style.width).toBe("288px"); + const navigation = slot.getByRole("navigation", { + name: "Notes sidebar", }); - + expect( + slot.container.querySelector("aside")?.classList.contains("bg-sidebar"), + ).toBe(true); + expect( + within(navigation).getByRole("button", { name: "Search notes" }), + ).toBeTruthy(); + expect( + within(navigation).getByRole("button", { name: "New note" }), + ).toBeTruthy(); + expect( + within(navigation).getByRole("button", { name: "New folder" }), + ).toBeTruthy(); + expect( + within(navigation).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(headerSegment.style.width).toBe("48px"); fireEvent.click( header.getByRole("button", { name: "Expand notes sidebar" }), ); expect(slot.container.querySelector("aside")?.style.width).toBe("288px"); + expect(headerSegment.style.width).toBe("288px"); + + header.unmount(); + await waitFor(() => { + expect( + slot.getByRole("button", { name: "Collapse notes sidebar" }), + ).toBeTruthy(); + }); }); it("keeps the right sidebar pinned while a note loads", async () => { diff --git a/official-plugins/docs/app.tsx b/official-plugins/docs/app.tsx index b98d7b19a5..62fc198e26 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -1245,6 +1245,7 @@ const SIDEBAR_AUTO_COLLAPSE_PANE_WIDTH = 640; interface NotesSidebarState { headerMounted: boolean; paneNarrow: boolean; + width: number; userCollapsed: boolean | null; } @@ -1269,6 +1270,7 @@ function getNotesSidebarStore(key: string): NotesSidebarStore { state: { headerMounted: false, paneNarrow: false, + width: 288, userCollapsed: null, }, headerMounts: 0, @@ -1287,6 +1289,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; @@ -1312,6 +1315,27 @@ function useNotesSidebarState(key: string): { return { state, store }; } +function NotesSidebarToggle({ + collapsed, + onCollapsedChange, +}: { + collapsed: boolean; + onCollapsedChange(collapsed: boolean): void; +}) { + return ( + + ); +} + function NotesPanelHeader({ subPath }: PluginNavPanelProps) { const { state: sidebar, store } = useNotesSidebarState( notesSidebarKey(subPath), @@ -1328,18 +1352,103 @@ function NotesPanelHeader({ subPath }: PluginNavPanelProps) { }; }, [store]); 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 ( + ); } @@ -1388,12 +1497,13 @@ function Tree({ // 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); useLayoutEffect(() => { if (sidebarStore.viewMounts === 0) { updateNotesSidebarState(sidebarStore, { paneNarrow: false, + width: 288, userCollapsed: null, }); } @@ -1403,6 +1513,7 @@ function Tree({ if (sidebarStore.viewMounts === 0) { updateNotesSidebarState(sidebarStore, { paneNarrow: false, + width: 288, userCollapsed: null, }); } @@ -1560,9 +1671,12 @@ function Tree({ const startX = event.clientX; const startWidth = sidebarWidth; const move = (moveEvent: PointerEvent) => { - setSidebarWidth( - Math.min(480, Math.max(220, startWidth + startX - moveEvent.clientX)), - ); + updateNotesSidebarState(sidebarStore, { + width: Math.min( + 480, + Math.max(220, startWidth + startX - moveEvent.clientX), + ), + }); }; const cleanup = () => { if (resizeCleanupRef.current !== cleanup) return; @@ -1592,22 +1706,17 @@ function Tree({ className={cn( "order-2 flex shrink-0 flex-col items-center", !sidebar.headerMounted && - "w-10 border-l border-border bg-muted/20 py-2", + "w-10 border-l border-border bg-sidebar py-2", )} style={{ width: sidebar.headerMounted ? 0 : 40 }} > {!sidebar.headerMounted ? ( - + /> ) : null} ); @@ -1616,87 +1725,25 @@ function Tree({ return ( ); diff --git a/plugins/automations/app.tsx b/plugins/automations/app.tsx index d8b8d7e520..b5ca1373c5 100644 --- a/plugins/automations/app.tsx +++ b/plugins/automations/app.tsx @@ -21,6 +21,7 @@ import { toast } from "sonner"; import type { AutomationResponse, AutomationExecutionOptionsResponse, + AutomationPermissionOptionsResponse, AgentExecutionUpdate, AutomationRunListResponse, AutomationRunResponse, @@ -247,37 +248,101 @@ function useAutomation(route: DetailRoute): { function useAutomationExecutionOptions( route: DetailRoute, enabled: boolean, + executionKey: string, ): { options: AutomationExecutionOptionsResponse | null; error: string | null; } { const rpc = useRpc(); const { projectId, automationId } = route; + const requestKey = `${projectId}:${automationId}:${executionKey}`; + const requestedKeyRef = useRef(null); const [state, setState] = useState<{ options: AutomationExecutionOptionsResponse | null; error: string | null; }>({ options: null, error: null }); useEffect(() => { - if (!enabled) { - setState({ options: null, error: null }); - return; - } + requestedKeyRef.current = null; + setState({ options: null, error: null }); + }, [requestKey]); + + useEffect(() => { + if (!enabled || requestedKeyRef.current === requestKey) return; + requestedKeyRef.current = requestKey; let active = true; + setState({ options: null, error: null }); rpc.call("automations_execution_options", { projectId, automationId }).then( (options) => { if (active) setState({ options, error: null }); }, (error: unknown) => { - if (active) setState({ options: null, error: errorText(error) }); + if (active) { + requestedKeyRef.current = null; + setState({ options: null, error: errorText(error) }); + } }, ); return () => { active = false; }; - }, [automationId, enabled, projectId, rpc]); + }, [automationId, enabled, projectId, requestKey, rpc]); + + return { options: state.options, error: state.error }; +} + +function useAutomationPermissionOptions( + route: DetailRoute, + enabled: boolean, + executionKey: string, +): { + options: AutomationPermissionOptionsResponse | null; + error: string | null; + retry: () => void; +} { + const rpc = useRpc(); + const { projectId, automationId } = route; + const requestKey = `${projectId}:${automationId}:${executionKey}`; + const requestedKeyRef = useRef(null); + const [attempt, setAttempt] = useState(0); + const [state, setState] = useState<{ + options: AutomationPermissionOptionsResponse | null; + error: string | null; + }>({ options: null, error: null }); + + useEffect(() => { + requestedKeyRef.current = null; + setState({ options: null, error: null }); + }, [requestKey]); + + useEffect(() => { + if (!enabled || requestedKeyRef.current === requestKey) return; + requestedKeyRef.current = requestKey; + let active = true; + setState({ options: null, error: null }); + rpc + .call("automations_permission_options", { projectId, automationId }) + .then( + (options) => { + if (active) setState({ options, error: null }); + }, + (error: unknown) => { + if (active) { + requestedKeyRef.current = null; + setState({ options: null, error: errorText(error) }); + } + }, + ); + return () => { + active = false; + }; + }, [attempt, automationId, enabled, projectId, requestKey, rpc]); - return state; + const retry = useCallback(() => { + requestedKeyRef.current = null; + setAttempt((current) => current + 1); + }, []); + return { options: state.options, error: state.error, retry }; } interface RunsState { @@ -538,11 +603,25 @@ function DetailView({ }) { const navigate = useBbNavigate(); const { automation, error, missing, refetch } = useAutomation(route); - const [editing, setEditing] = useState(initialEditing); + const [editingRequested, setEditingRequested] = useState(initialEditing); + const editingExecutionKey = + automation?.execution.mode === "agent" + ? JSON.stringify({ + providerId: automation.execution.providerId, + environment: automation.execution.environment, + }) + : "not-agent"; const executionOptionsState = useAutomationExecutionOptions( route, - editing && automation?.execution.mode === "agent", + editingRequested && automation?.execution.mode === "agent", + editingExecutionKey, ); + const permissionOptionsState = useAutomationPermissionOptions( + route, + editingRequested && automation?.execution.mode === "agent", + editingExecutionKey, + ); + const editing = editingRequested && permissionOptionsState.options !== null; const overviewState = useOverview(); const runsState = useRuns(route); const mutations = useMutations(); @@ -590,11 +669,14 @@ function DetailView({ const openEdit = useCallback(() => { if (automation === null) return; if (automation.execution.mode === "agent") { - setEditing(true); + if (permissionOptionsState.error !== null) { + permissionOptionsState.retry(); + } + setEditingRequested(true); return; } editViaThread(automation); - }, [automation, editViaThread]); + }, [automation, editViaThread, permissionOptionsState]); const updateAgent = useCallback( async (agent: AgentExecutionUpdate) => { @@ -602,7 +684,7 @@ function DetailView({ try { await mutations.update(route, agent); toast.success("Automation updated"); - setEditing(false); + setEditingRequested(false); refetch(); } catch (rpcError: unknown) { toast.error(`Failed to update automation: ${errorText(rpcError)}`); @@ -674,10 +756,14 @@ function DetailView({ runsState={runsState} actionPending={actionPending} executionOptions={executionOptionsState.options} - executionOptionsError={executionOptionsState.error} + executionOptionsError={ + executionOptionsState.error ?? permissionOptionsState.error + } + permissionModes={permissionOptionsState.options?.permissionModes ?? []} editing={editing} onToggle={(checked) => runAction(checked ? "resume" : "pause")} onEdit={openEdit} + onCancelEdit={() => setEditingRequested(false)} onUpdateAgent={updateAgent} onRunNow={() => runAction("run")} onDelete={() => setDeleteOpen(true)} diff --git a/plugins/automations/detail-view.tsx b/plugins/automations/detail-view.tsx index 58ab33ef71..c1d5df40be 100644 --- a/plugins/automations/detail-view.tsx +++ b/plugins/automations/detail-view.tsx @@ -7,6 +7,7 @@ import type { AutomationRunResponse, AutomationRunStatus, AgentExecutionUpdate, + PermissionMode, } from "./src/rpc-types"; import { AUTOMATION_PROMPT_MAX_LENGTH } from "./src/rpc-types"; import { Button } from "@bb/shared-ui/button"; @@ -80,9 +81,11 @@ export interface AutomationDetailViewProps { actionPending: boolean; executionOptions: AutomationExecutionOptionsResponse | null; executionOptionsError: string | null; + permissionModes: readonly PermissionMode[]; editing: boolean; onToggle: (enabled: boolean) => void; onEdit: () => void; + onCancelEdit: () => void; onUpdateAgent: (update: AgentExecutionUpdate) => Promise; onRunNow: () => void; onDelete: () => void; @@ -329,10 +332,12 @@ function AutomationSelector({ className={OPTION_CONTENT_CLASS_NAME} > {leading} - + + +
- + {options.map((option) => ( ; @@ -664,6 +671,8 @@ function AgentAutomationDefinition({ personalProject: boolean; projectContextLabel: string; pending: boolean; + permissionModes: readonly PermissionMode[]; + onCancel: () => void; onUpdate: (update: AgentExecutionUpdate) => Promise; }) { const [prompt, setPrompt] = useState(execution.prompt); @@ -696,12 +705,10 @@ function AgentAutomationDefinition({ label: formatAutomationModelLabel(model, execution.providerId), }); } - const permissionOptions = (options?.permissionModes ?? [permissionMode]).map( - (mode) => ({ - value: mode, - label: formatPermissionMode(mode), - }), - ); + const permissionOptions = permissionModes.map((mode) => ({ + value: mode, + label: formatPermissionMode(mode), + })); const promptBox = editing ? (
+ @@ -822,11 +840,9 @@ function AgentAutomationDefinition({ label="Permission mode" value={permissionMode} options={permissionOptions} - disabled={pending || options === null} + disabled={pending} onValueChange={(value) => { - const next = options?.permissionModes.find( - (mode) => mode === value, - ); + const next = permissionModes.find((mode) => mode === value); if (next !== undefined) setPermissionMode(next); }} className="h-6 shrink-0" @@ -857,7 +873,7 @@ function AgentAutomationDefinition({ )} {optionsError ? (

- Couldn't load model options. {optionsError} + Couldn't load editing options. {optionsError}

) : null} {editing ? promptFooter : null} @@ -872,9 +888,11 @@ export function AutomationDetailView({ actionPending, executionOptions, executionOptionsError, + permissionModes, editing, onToggle, onEdit, + onCancelEdit, onUpdateAgent, onRunNow, onDelete, @@ -983,8 +1001,10 @@ export function AutomationDetailView({ optionsError={executionOptionsError} editing={editing} pending={actionPending} + permissionModes={permissionModes} personalProject={personalProject} projectContextLabel={projectContextLabel} + onCancel={onCancelEdit} onUpdate={onUpdateAgent} /> ) : ( diff --git a/plugins/automations/lib/provider-icon.tsx b/plugins/automations/lib/provider-icon.tsx index 67e070d1fe..5d7d226f97 100644 --- a/plugins/automations/lib/provider-icon.tsx +++ b/plugins/automations/lib/provider-icon.tsx @@ -110,7 +110,7 @@ export function AutomationProviderIcon({ providerId }: { providerId: string }) { aria-hidden="true" className="inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground" > - + ); } diff --git a/plugins/automations/src/cli.ts b/plugins/automations/src/cli.ts index 86e0b9ad70..4a4391a5d8 100644 --- a/plugins/automations/src/cli.ts +++ b/plugins/automations/src/cli.ts @@ -18,7 +18,10 @@ import type { ResolvedCreateAutomationInput, UpdateAutomationInput, } from "./rpc-types.js"; -import { resolvePermissionMode } from "./provider-permissions.js"; +import { + providerRoutingForEnvironment, + resolvePermissionMode, +} from "./provider-permissions.js"; import { AUTOMATION_SCRIPT_TIMEOUT_DEFAULT_MS, automationScriptInterpreterSchema, @@ -324,6 +327,7 @@ async function buildExecution( ); } validateAgentTargetOptions(args); + const environment = await buildAgentEnvironment(bb, args); return { mode: "agent", prompt, @@ -333,8 +337,9 @@ async function buildExecution( bb, provider, parsePermissionMode(flag(args, "permission-mode")), + providerRoutingForEnvironment(environment), ), - environment: await buildAgentEnvironment(bb, args), + environment, ...(flag(args, "target-thread") ? { targetThreadId: flag(args, "target-thread") } : {}), diff --git a/plugins/automations/src/provider-permissions.ts b/plugins/automations/src/provider-permissions.ts index 14686f3710..233efc04d7 100644 --- a/plugins/automations/src/provider-permissions.ts +++ b/plugins/automations/src/provider-permissions.ts @@ -1,5 +1,5 @@ import type { BbPluginApi } from "@bb/plugin-sdk"; -import type { PermissionMode } from "./rpc-types.js"; +import type { AgentEnvironment, PermissionMode } from "./rpc-types.js"; type ProviderPermissionApi = { sdk: { @@ -7,12 +7,29 @@ type ProviderPermissionApi = { }; }; +type ProviderRouting = NonNullable< + Parameters[0] +>; + +export function providerRoutingForEnvironment( + environment: AgentEnvironment, +): ProviderRouting { + if (environment.type === "reuse") { + return { environmentId: environment.environmentId }; + } + if (environment.type === "host" && environment.hostId !== undefined) { + return { hostId: environment.hostId }; + } + return {}; +} + export async function resolvePermissionMode( bb: ProviderPermissionApi, providerId: string, requested: PermissionMode | undefined, + routing: ProviderRouting = {}, ): Promise { - const providers = await bb.sdk.providers.list(); + const providers = await bb.sdk.providers.list(routing); const provider = providers.find((candidate) => candidate.id === providerId); if (provider === undefined || provider.available === false) { throw new Error(`Provider ${providerId} is not available.`); diff --git a/plugins/automations/src/rpc-types.ts b/plugins/automations/src/rpc-types.ts index 00459ff49b..f548cd7807 100644 --- a/plugins/automations/src/rpc-types.ts +++ b/plugins/automations/src/rpc-types.ts @@ -228,6 +228,15 @@ export type AutomationExecutionOptionsResponse = z.infer< typeof automationExecutionOptionsResponseSchema >; +export const automationPermissionOptionsResponseSchema = z + .object({ + permissionModes: z.array(permissionModeSchema), + }) + .strict(); +export type AutomationPermissionOptionsResponse = z.infer< + typeof automationPermissionOptionsResponseSchema +>; + export const automationResponseSchema = z .object({ id: z.string(), diff --git a/plugins/automations/src/rpc.ts b/plugins/automations/src/rpc.ts index 236ee7df6a..32ca304066 100644 --- a/plugins/automations/src/rpc.ts +++ b/plugins/automations/src/rpc.ts @@ -1,6 +1,7 @@ import { automationListResponseSchema, automationExecutionOptionsResponseSchema, + automationPermissionOptionsResponseSchema, automationResponseSchema, automationRunListResponseSchema, automationRunRpcResponseSchema, @@ -36,6 +37,10 @@ export const automationRpcContract = defineRpcContract({ input: projectAutomationInputSchema, output: automationExecutionOptionsResponseSchema, }, + automations_permission_options: { + input: projectAutomationInputSchema, + output: automationPermissionOptionsResponseSchema, + }, automations_create: { input: createAutomationInputSchema, output: automationResponseSchema, @@ -82,6 +87,11 @@ export function createRpcHandlers(service: AutomationService) { ) { return service.executionOptions(input); }, + automations_permission_options( + input: z.output, + ) { + return service.permissionOptions(input); + }, automations_create(input: z.output) { return service.create(input); }, diff --git a/plugins/automations/src/server-harness.test.ts b/plugins/automations/src/server-harness.test.ts index 47cc6ec07d..bde8227439 100644 --- a/plugins/automations/src/server-harness.test.ts +++ b/plugins/automations/src/server-harness.test.ts @@ -25,6 +25,7 @@ const rpcMethods = [ "automations_list", "automations_get", "automations_execution_options", + "automations_permission_options", "automations_create", "automations_update", "automations_delete", @@ -44,6 +45,7 @@ async function bootAutomationsPlugin( "auto", "full", ], + routedPermissionModes?: Array<"accept-edits" | "auto" | "full">, ): Promise { const host = createFakePluginHost({ pluginId: "automations", @@ -75,11 +77,16 @@ async function bootAutomationsPlugin( }, }, providers: { - async list() { + async list(routing) { + const permissionModes = + routing?.environmentId === "env_routed" && + routedPermissionModes !== undefined + ? routedPermissionModes + : supportedPermissionModes; return [ { id: "codex", - capabilities: { supportedPermissionModes }, + capabilities: { supportedPermissionModes: permissionModes }, }, ] as never; }, @@ -667,6 +674,14 @@ describe("automations server plugin harness", () => { models: [{ model: "gpt-5.6-codex", displayName: "5.6 Sol" }], permissionModes: ["accept-edits", "auto", "full"], }); + await expect( + harness.callRpc("automations_permission_options", { + projectId: PROJECT_ID, + automationId: created.id, + }), + ).resolves.toEqual({ + permissionModes: ["accept-edits", "auto", "full"], + }); await expect( harness.callRpc("automations_update", { @@ -733,6 +748,39 @@ describe("automations server plugin harness", () => { await harness.dispose(); }); + it("validates permission updates against the automation target environment", async () => { + const { harness } = await bootAutomationsPlugin( + ["accept-edits"], + ["full"], + ); + const created = await createAgentAutomation(harness); + + await expect( + harness.callRpc("automations_update", { + projectId: PROJECT_ID, + automationId: created.id, + agent: { + permissionMode: "full", + target: { + type: "environment", + environment: { + type: "reuse", + environmentId: "env_routed", + }, + }, + }, + }), + ).resolves.toMatchObject({ + execution: { + mode: "agent", + permissionMode: "full", + environment: { type: "reuse", environmentId: "env_routed" }, + }, + }); + + await harness.dispose(); + }); + it("dedupes manual runs through RPC idempotency keys", async () => { const { harness } = await bootAutomationsPlugin(); const automation = await createAgentAutomation(harness); diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts index 50fd75e1fa..539c62676d 100644 --- a/plugins/automations/src/service.ts +++ b/plugins/automations/src/service.ts @@ -20,7 +20,10 @@ import { type Db, } from "./data.js"; import { createAutomationId } from "./ids.js"; -import { resolvePermissionMode } from "./provider-permissions.js"; +import { + providerRoutingForEnvironment, + resolvePermissionMode, +} from "./provider-permissions.js"; import { publishAutomationChange } from "./realtime.js"; import { AUTOMATION_RUNS_LIMIT_MAX, @@ -29,6 +32,7 @@ import { type AgentExecutionUpdate, type AutomationExecution, type AutomationExecutionOptionsResponse, + type AutomationPermissionOptionsResponse, type AutomationRunListResponse, type AutomationRunRpcResponse, type AutomationResponse, @@ -72,6 +76,10 @@ export interface AutomationService { projectId: string; automationId: string; }): Promise; + permissionOptions(input: { + projectId: string; + automationId: string; + }): Promise; create(input: ResolvedCreateAutomationInput): Promise; update(input: UpdateAutomationInput): Promise; delete(input: { @@ -405,13 +413,7 @@ export function createAutomationService(args: { "Execution options are only available for agent automations", ); } - const environment = execution.environment; - const routing = - environment.type === "reuse" - ? { environmentId: environment.environmentId } - : environment.type === "host" && environment.hostId !== undefined - ? { hostId: environment.hostId } - : {}; + const routing = providerRoutingForEnvironment(execution.environment); const loadModels = bb.sdk.providers.models; if (loadModels === undefined) { throw new Error("Provider model discovery is unavailable."); @@ -438,6 +440,33 @@ export function createAutomationService(args: { return { models, permissionModes }; }, + async permissionOptions(input) { + const automation = requireProjectAutomation(db, input); + const execution = parseAutomationExecution(automation.execution); + if (execution.mode !== "agent") { + throw new Error( + "Permission options are only available for agent automations", + ); + } + const environment = execution.environment; + const routing = + environment.type === "reuse" + ? { environmentId: environment.environmentId } + : environment.type === "host" && environment.hostId !== undefined + ? { hostId: environment.hostId } + : {}; + const providers = await bb.sdk.providers.list(routing); + const provider = providers.find( + (candidate) => candidate.id === execution.providerId, + ); + if (provider === undefined || provider.available === false) { + throw new Error(`Provider ${execution.providerId} is not available.`); + } + return { + permissionModes: provider.capabilities.supportedPermissionModes, + }; + }, + async create(payload) { await requireProjectAvailable(bb, payload.projectId); const now = Date.now(); @@ -448,6 +477,7 @@ export function createAutomationService(args: { bb, payload.execution.providerId, payload.execution.permissionMode, + providerRoutingForEnvironment(payload.execution.environment), ); } const automationId = createAutomationId(); @@ -511,6 +541,7 @@ export function createAutomationService(args: { bb, input.execution.providerId, input.execution.permissionMode, + providerRoutingForEnvironment(input.execution.environment), ); } const stored = await resolveStoredExecution({ @@ -522,6 +553,10 @@ export function createAutomationService(args: { stagedScriptFile = stored.writtenScriptFile; } if (input.agent !== undefined) { + const updatedExecution = applyAgentExecutionUpdate( + currentExecution, + input.agent, + ); if (input.agent.permissionMode !== undefined) { if (currentExecution.mode !== "agent") { throw new Error( @@ -530,14 +565,12 @@ export function createAutomationService(args: { } await resolvePermissionMode( bb, - currentExecution.providerId, + updatedExecution.providerId, input.agent.permissionMode, + providerRoutingForEnvironment(updatedExecution.environment), ); } - patch.execution = applyAgentExecutionUpdate( - currentExecution, - input.agent, - ); + patch.execution = updatedExecution; } let updated: AutomationRow | null; try { From e34ace972301b52308f856f215604173d46a1368 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 4 Aug 2026 16:21:15 -0700 Subject: [PATCH 3/4] Address stacked review feedback --- .../plugin/PluginsOverview.test.tsx | 17 +- .../management/BrowsePluginsTab.test.tsx | 8 +- .../SecondaryPanelTabStrip.test.ts | 19 +- .../tools/automation-overview.test.tsx | 67 ++++++- official-plugins/docs/app.test.tsx | 187 ++++++++++++++++-- official-plugins/docs/app.tsx | 125 +++++++++--- .../src/components/ui/dropdown-menu.tsx | 7 +- .../src/components/ui/resource/toolbar.tsx | 32 ++- plugins/automations/app.tsx | 16 +- plugins/automations/overview-view.tsx | 6 +- plugins/automations/package.json | 3 + .../src/automation-execution-options.test.tsx | 75 +++++++ .../src/option-request-gate.test.ts | 23 +++ .../automations/src/option-request-gate.ts | 39 ++++ plugins/automations/tsconfig.json | 2 +- plugins/automations/vitest.config.ts | 2 +- pnpm-lock.yaml | 9 + 17 files changed, 563 insertions(+), 74 deletions(-) create mode 100644 plugins/automations/src/automation-execution-options.test.tsx create mode 100644 plugins/automations/src/option-request-gate.test.ts create mode 100644 plugins/automations/src/option-request-gate.ts 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 a2f500eb9a..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", diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts index 6a27dff279..aab6f57837 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, render } from "@testing-library/react"; +import { act, cleanup, render } from "@testing-library/react"; import { createElement } from "react"; import { afterEach, vi } from "vitest"; import { describe, expect, it } from "vitest"; @@ -21,9 +21,13 @@ describe("secondary panel tab-strip edge fades", () => { 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); } @@ -57,6 +61,7 @@ describe("secondary panel tab-strip edge fades", () => { 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 @@ -69,5 +74,17 @@ describe("secondary panel tab-strip edge fades", () => { 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/tools/automation-overview.test.tsx b/apps/app/src/components/tools/automation-overview.test.tsx index a79c7ad7ac..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"; @@ -147,6 +148,9 @@ describe("AutomationOverviewView", () => { 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" }); @@ -201,19 +205,76 @@ describe("AutomationOverviewView", () => { />, ); - const sortTrigger = screen.getByRole("button", { name: "Sort" }); + const sortTrigger = screen.getByRole("button", { + name: "Sort: Automation name, ascending", + }); expect(sortTrigger.querySelector('[data-icon="ArrowUp"]')).toBeTruthy(); fireEvent.pointerDown(sortTrigger); - const projectOption = screen.getByRole("menuitem", { name: "Project" }); - const nameOption = screen.getByRole("menuitem", { + 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 + .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/official-plugins/docs/app.test.tsx b/official-plugins/docs/app.test.tsx index 4c3bc8b997..3e7d7e69ac 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -28,7 +29,10 @@ beforeEach(() => { }); }); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); interface NoteSummary { path: string; @@ -119,25 +123,31 @@ describe("Docs nav panel", () => { const HeaderContent = panel.headerContent!; const header = render(); const headerSegment = header.getByTestId("notes-sidebar-header"); - expect(headerSegment.classList.contains("bg-sidebar")).toBe(true); - expect(headerSegment.style.width).toBe("288px"); - const navigation = slot.getByRole("navigation", { - name: "Notes sidebar", + 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(navigation).getByRole("button", { name: "Search notes" }), + within(toolbar).getByRole("button", { name: "Search notes" }), ).toBeTruthy(); expect( - within(navigation).getByRole("button", { name: "New note" }), + within(toolbar).getByRole("button", { name: "New note" }), ).toBeTruthy(); expect( - within(navigation).getByRole("button", { name: "New folder" }), + within(toolbar).getByRole("button", { name: "New folder" }), ).toBeTruthy(); expect( - within(navigation).queryByRole("button", { + within(toolbar).queryByRole("button", { name: "Collapse notes sidebar", }), ).toBeNull(); @@ -145,19 +155,40 @@ describe("Docs nav panel", () => { header.getByRole("button", { name: "Collapse notes sidebar" }), ); expect(slot.container.querySelector("aside")?.style.width).toBe("0px"); - expect(headerSegment.style.width).toBe("48px"); + 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(headerSegment.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: "Collapse notes sidebar" }), - ).toBeTruthy(); + 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 () => { @@ -191,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" }, @@ -250,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 62fc198e26..c49dc7f288 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -1241,6 +1241,11 @@ 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; @@ -1257,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; @@ -1298,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; @@ -1324,7 +1366,7 @@ function NotesSidebarToggle({ }) { return (