From 4fa4bff0b5308a4fb6b94c8fff3495f60fb42d96 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:50:50 +0000 Subject: [PATCH 01/79] Polish Browse plugin actions and mobile shelf previews --- .../plugin/management/BrowsePluginsTab.tsx | 45 +++++++++++++++---- .../management/OpenPluginGuideButton.tsx | 22 +++++++++ packages/plugin-registry/r/icon-extended.json | 2 +- packages/plugin-registry/r/icon-registry.json | 2 +- .../src/components/ui/icon-extended.tsx | 2 + .../src/components/ui/icon-registry.ts | 1 + 6 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index cb015e80b5a..240a10202a3 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -9,6 +9,8 @@ import { DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { Icon } from "@bb/shared-ui/icon"; +import bbLogoUrl from "../../../../../../assets/bb-logo.svg"; +import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; import { cn } from "@bb/shared-ui/lib/utils"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { @@ -155,7 +157,7 @@ export function BrowsePluginsTab({ return (
-
+
+
+ shelf.key === "collection:bb-official" ? ( + + ) : shelf.key === "collection:new-and-notable" ? ( + + ) : ( + + ) } browseAction={ - visible.length < shelf.entries.length ? ( - + !expanded && shelf.entries.length > 2 ? ( + ) : undefined } >
-
+
*:nth-child(n+3)]:hidden", + )} + > {visible.map((entry) => ( + + Plugin Guide + + + ); +} diff --git a/packages/plugin-registry/r/icon-extended.json b/packages/plugin-registry/r/icon-extended.json index 2459deee8ca..a07bdd87df3 100644 --- a/packages/plugin-registry/r/icon-extended.json +++ b/packages/plugin-registry/r/icon-extended.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/components/ui/icon-extended.tsx", - "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport {\n AiBrain01Icon,\n AiBrowserIcon,\n AiContentGenerator01Icon,\n ArrowDown02Icon,\n ArrowDownDoubleIcon,\n ArrowMoveDownLeftIcon,\n ArrowMoveDownRightIcon,\n ArrowReloadHorizontalIcon,\n ArrowRight02Icon,\n ArrowTurnBackwardIcon,\n ArrowTurnForwardIcon,\n ArrowUp01Icon,\n ArrowUp02Icon,\n ArrowUpDoubleIcon,\n ArrowUpDownIcon,\n ArrowUpRight01Icon,\n AttachmentIcon,\n BellDotIcon,\n Book02Icon,\n BrainIcon,\n BrowserIcon,\n Calendar03Icon,\n CalendarCheckOut02Icon,\n ChartColumnIcon,\n CircleArrowShrink01Icon,\n CleanIcon,\n Clock01Icon,\n CloudIcon,\n CloudOffIcon,\n Coffee02Icon,\n CollapseIcon,\n DashedLine02Icon,\n DateTimeIcon,\n DiscordIcon,\n DragDropHorizontalIcon,\n DragDropVerticalIcon,\n Edit04Icon,\n ElectricPlugsIcon,\n ExpandIcon,\n File01Icon,\n FileAttachmentIcon,\n FileEmpty02Icon,\n FileQuestionMarkIcon,\n Folder02Icon,\n FolderEditIcon,\n FolderRemoveIcon,\n GitBranchIcon,\n GitForkIcon,\n GithubIcon,\n GitMergeIcon,\n GitPullRequestArrow,\n GitPullRequestClosedIcon,\n GitPullRequestDraftIcon,\n GitPullRequestIcon,\n GridViewIcon,\n InternetIcon,\n LaptopIcon,\n Layers01Icon,\n LayoutTwoColumnIcon,\n LayoutTwoRowIcon,\n LimitationIcon,\n LinkSquare02Icon,\n ListViewIcon,\n LockIcon,\n Mail02Icon,\n MailOpen01Icon,\n Menu02Icon,\n MessageAdd02Icon,\n Mic02Icon,\n MoveToIcon,\n PackageReceiveIcon,\n PauseIcon,\n PinIcon,\n PinOffIcon,\n PlayIcon,\n Plug02Icon,\n PlusMinusSquare01Icon,\n PlusSignIcon,\n PuzzleIcon,\n Refresh01Icon,\n RepeatIcon,\n SecurityCheckIcon,\n SentIcon,\n SidebarBottomIcon,\n SidebarRightIcon,\n SmartPhone01Icon,\n Sorting01Icon,\n SquareIcon,\n SquareUnlock02Icon,\n StarIcon,\n TestTube01Icon,\n TextWrapIcon,\n TimeScheduleIcon,\n Unarchive03Icon,\n UserIcon,\n ViewIcon,\n ViewOffIcon,\n ZoomInAreaIcon,\n ZoomOutAreaIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nconst DiscordLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nconst GithubLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrain01: AiBrain01Icon,\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n BellDot: BellDotIcon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DiscordLogo: DiscordLogoIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n GithubLogo: GithubLogoIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n Limitation: LimitationIcon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minimize2: CollapseIcon,\n MoveTo: MoveToIcon,\n NewTab: DashedLine02Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plug02: Plug02Icon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", + "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport {\n AiBrain01Icon,\n AiBrowserIcon,\n AiContentGenerator01Icon,\n ArrowDown02Icon,\n ArrowDownDoubleIcon,\n ArrowMoveDownLeftIcon,\n ArrowMoveDownRightIcon,\n ArrowReloadHorizontalIcon,\n ArrowRight02Icon,\n ArrowTurnBackwardIcon,\n ArrowTurnForwardIcon,\n ArrowUp01Icon,\n ArrowUp02Icon,\n ArrowUpDoubleIcon,\n ArrowUpDownIcon,\n ArrowUpRight01Icon,\n AttachmentIcon,\n BellDotIcon,\n Book02Icon,\n BrainIcon,\n BrowserIcon,\n Calendar03Icon,\n CalendarCheckOut02Icon,\n ChartColumnIcon,\n CircleArrowShrink01Icon,\n CleanIcon,\n Clock01Icon,\n CloudIcon,\n CloudOffIcon,\n Coffee02Icon,\n CollapseIcon,\n DashedLine02Icon,\n DateTimeIcon,\n DiscordIcon,\n DragDropHorizontalIcon,\n DragDropVerticalIcon,\n Edit04Icon,\n ElectricPlugsIcon,\n ExpandIcon,\n File01Icon,\n FileAttachmentIcon,\n FileEmpty02Icon,\n FileQuestionMarkIcon,\n Folder02Icon,\n FolderEditIcon,\n FolderRemoveIcon,\n GitBranchIcon,\n GitForkIcon,\n GithubIcon,\n GitMergeIcon,\n GitPullRequestArrow,\n GitPullRequestClosedIcon,\n GitPullRequestDraftIcon,\n GitPullRequestIcon,\n GridViewIcon,\n InternetIcon,\n LaptopIcon,\n Layers01Icon,\n LayoutTwoColumnIcon,\n LayoutTwoRowIcon,\n LimitationIcon,\n LinkSquare02Icon,\n ListViewIcon,\n LockIcon,\n Mail02Icon,\n MailOpen01Icon,\n Menu02Icon,\n MessageAdd02Icon,\n Mic02Icon,\n MoveToIcon,\n News01Icon,\n PackageReceiveIcon,\n PauseIcon,\n PinIcon,\n PinOffIcon,\n PlayIcon,\n Plug02Icon,\n PlusMinusSquare01Icon,\n PlusSignIcon,\n PuzzleIcon,\n Refresh01Icon,\n RepeatIcon,\n SecurityCheckIcon,\n SentIcon,\n SidebarBottomIcon,\n SidebarRightIcon,\n SmartPhone01Icon,\n Sorting01Icon,\n SquareIcon,\n SquareUnlock02Icon,\n StarIcon,\n TestTube01Icon,\n TextWrapIcon,\n TimeScheduleIcon,\n Unarchive03Icon,\n UserIcon,\n ViewIcon,\n ViewOffIcon,\n ZoomInAreaIcon,\n ZoomOutAreaIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nconst DiscordLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nconst GithubLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrain01: AiBrain01Icon,\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n BellDot: BellDotIcon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DiscordLogo: DiscordLogoIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n GithubLogo: GithubLogoIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n Limitation: LimitationIcon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minimize2: CollapseIcon,\n MoveTo: MoveToIcon,\n NewTab: DashedLine02Icon,\n News01: News01Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plug02: Plug02Icon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", "type": "registry:ui", "target": "components/ui/icon-extended.tsx" } diff --git a/packages/plugin-registry/r/icon-registry.json b/packages/plugin-registry/r/icon-registry.json index 4332b4e7e49..66d3e3831c1 100644 --- a/packages/plugin-registry/r/icon-registry.json +++ b/packages/plugin-registry/r/icon-registry.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/components/ui/icon-registry.ts", - "content": "import type { ComponentType } from \"react\";\nimport type { IconSvgElement } from \"@hugeicons/react\";\n\nexport const EXTENDED_ICON_NAMES = [\n \"AiBrain01\",\n \"AiBrowser\",\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"BellDot\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DiscordLogo\",\n \"DateTime\",\n \"Github\",\n \"GithubLogo\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"Limitation\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minimize2\",\n \"MoveTo\",\n \"NewTab\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plug02\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\ninterface AppIconDefinition {\n component: ComponentType<{ className?: string }>;\n key: string;\n}\n\nlet appIcons: ReadonlyMap = new Map();\nconst appIconListeners = new Set<() => void>();\n\nexport function setAppIcons(\n next: ReadonlyMap,\n): void {\n appIcons = next;\n for (const listener of appIconListeners) listener();\n}\n\nexport function getAppIcon(name: string): AppIconDefinition | undefined {\n return appIcons.get(name);\n}\n\nexport function subscribeAppIcons(listener: () => void): () => void {\n appIconListeners.add(listener);\n return () => {\n appIconListeners.delete(listener);\n };\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type { IconSvgElement } from \"@hugeicons/react\";\n\nexport const EXTENDED_ICON_NAMES = [\n \"AiBrain01\",\n \"AiBrowser\",\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"BellDot\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DiscordLogo\",\n \"DateTime\",\n \"Github\",\n \"GithubLogo\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"Limitation\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minimize2\",\n \"MoveTo\",\n \"NewTab\",\n \"News01\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plug02\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\ninterface AppIconDefinition {\n component: ComponentType<{ className?: string }>;\n key: string;\n}\n\nlet appIcons: ReadonlyMap = new Map();\nconst appIconListeners = new Set<() => void>();\n\nexport function setAppIcons(\n next: ReadonlyMap,\n): void {\n appIcons = next;\n for (const listener of appIconListeners) listener();\n}\n\nexport function getAppIcon(name: string): AppIconDefinition | undefined {\n return appIcons.get(name);\n}\n\nexport function subscribeAppIcons(listener: () => void): () => void {\n appIconListeners.add(listener);\n return () => {\n appIconListeners.delete(listener);\n };\n}\n", "type": "registry:ui", "target": "components/ui/icon-registry.ts" } diff --git a/packages/shared-ui/src/components/ui/icon-extended.tsx b/packages/shared-ui/src/components/ui/icon-extended.tsx index 0b37c40fa85..e003f273ada 100644 --- a/packages/shared-ui/src/components/ui/icon-extended.tsx +++ b/packages/shared-ui/src/components/ui/icon-extended.tsx @@ -70,6 +70,7 @@ import { MessageAdd02Icon, Mic02Icon, MoveToIcon, + News01Icon, PackageReceiveIcon, PauseIcon, PinIcon, @@ -282,6 +283,7 @@ export const EXTENDED_ICON_MAP: ExtendedIconMap = { Minimize2: CollapseIcon, MoveTo: MoveToIcon, NewTab: DashedLine02Icon, + News01: News01Icon, PackageReceive: PackageReceiveIcon, Palette: PaletteStrokeRoundedIcon, PanelBottom: SidebarBottomIcon, diff --git a/packages/shared-ui/src/components/ui/icon-registry.ts b/packages/shared-ui/src/components/ui/icon-registry.ts index e82eb8c3856..3ba9da2180d 100644 --- a/packages/shared-ui/src/components/ui/icon-registry.ts +++ b/packages/shared-ui/src/components/ui/icon-registry.ts @@ -77,6 +77,7 @@ export const EXTENDED_ICON_NAMES = [ "Minimize2", "MoveTo", "NewTab", + "News01", "PackageReceive", "Palette", "PanelBottom", From 95f92a9911c5e0e5d71048fddf5ca2047e920841 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:03:39 +0000 Subject: [PATCH 02/79] Update shelf expansion assertion for mobile previews --- .../plugin/management/BrowsePluginsTab.test.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index 167df0e47bf..432a9ef42ed 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -394,12 +394,7 @@ describe("BrowsePluginsTab", () => { expect(cardOrder()).toHaveLength(8); }); - it("shows all five cards in a narrow shelf", async () => { - const originalWidth = window.innerWidth; - Object.defineProperty(window, "innerWidth", { - configurable: true, - value: 700, - }); + it("expands a shelf with fewer entries than the desktop limit", async () => { const entries = Array.from({ length: 5 }, (_, index) => ({ ...MEMORY_ENTRY, entryId: `memory-${index}`, @@ -409,12 +404,9 @@ describe("BrowsePluginsTab", () => { renderBrowse({ entries, collections: [] }); await screen.findByTestId("plugin-browse-shelves"); + fireEvent.click(screen.getByRole("button", { name: "See all" })); expect(cardOrder()).toHaveLength(5); expect(screen.queryByRole("button", { name: "See all" })).toBeNull(); - Object.defineProperty(window, "innerWidth", { - configurable: true, - value: originalWidth, - }); }); it("uses the shared error state and retries catalog searches", async () => { From f4f0850eb21fff0caf0c09c6eb6db18af030df4a Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:59:02 +0000 Subject: [PATCH 03/79] Use consistent plugin cards and author identity across collections --- .../management/BrowsePluginsTab.test.tsx | 2 +- .../plugin/management/BrowsePluginsTab.tsx | 35 +---- .../plugin/management/InstalledPluginsTab.tsx | 141 +++++++++++------- .../plugin/management/PluginAuthorAvatar.tsx | 12 +- .../plugin/management/PluginAuthorPage.tsx | 12 +- .../plugin/management/PluginCard.tsx | 88 +++++++++++ .../installed-plugin-catalog.test.ts | 51 +++++++ .../management/installed-plugin-catalog.ts | 26 ++++ .../plugin/management/plugin-ui.tsx | 22 ++- .../hooks/queries/plugin-settings-queries.ts | 6 + 10 files changed, 295 insertions(+), 100 deletions(-) create mode 100644 apps/app/src/components/plugin/management/PluginCard.tsx create mode 100644 apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts create mode 100644 apps/app/src/components/plugin/management/installed-plugin-catalog.ts diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index 432a9ef42ed..6f6dc6b6e47 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -199,7 +199,7 @@ describe("BrowsePluginsTab", () => { onOpenPlugin, ); - fireEvent.click(await screen.findByRole("link", { name: "BB" })); + fireEvent.click(await screen.findByRole("link", { name: "BB Official" })); const params = new URLSearchParams( screen.getByTestId("location-search").textContent ?? "", ); diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 240a10202a3..882a5daf944 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -14,7 +14,6 @@ import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; import { cn } from "@bb/shared-ui/lib/utils"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { - ResourceBrowseCard, ResourceBrowseGrid, ResourceCollectionViewport, ResourceInstallControl, @@ -32,9 +31,7 @@ import { type PluginCatalogSearchEntry, } from "@/hooks/queries/plugin-catalog-queries"; import type { AddPluginInitial } from "./AddPluginDialog"; -import { PluginAuthorAvatar } from "./PluginAuthorAvatar"; -import { PluginAuthorLink } from "./PluginAuthorLink"; -import { pluginAuthorGithub } from "./plugin-marketplace-author"; +import { PluginCard, PluginCardAuthor } from "./PluginCard"; import { PluginBrowseToolbar, pluginBrowseSort, @@ -431,36 +428,12 @@ function PluginCatalogCard({ onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; }) { const count = pluginInstallCountPresentation(entry.installs); - const authorName = entry.author?.name ?? entry.publisherLabel; return ( - } - leadingClassName="size-10" + } title={entry.displayName} description={entry.description || undefined} - byline={ - - - - By{" "} - {entry.author === null ? ( - authorName - ) : ( - - {authorName} - - )} - - - } + byline={} footerMeta={ showCategory && entry.category !== undefined ? ( (null); const updateTarget = updateTargetId === null @@ -46,17 +51,19 @@ export function InstalledPluginsTab({ return ( <> - -
- {plugins.map((plugin) => ( - setUpdateTargetId(plugin.id)} - /> - ))} -
-
+ + {plugins.map((plugin) => ( + setUpdateTargetId(plugin.id)} + /> + ))} + {updateTarget !== null ? ( void; }) { const navigate = useNavigate(); @@ -118,58 +127,79 @@ export function InstalledPluginRow({ ); return (
- - + + {() => } + } title={plugin.name ?? plugin.id} - titleMeta={ - plugin.publisherLabel === null ? undefined : ( - - ) - } - state={ - runtimeStatus === null ? undefined : ( - - {runtimeStatus.label} + byline={ + plugin.source.startsWith("path:") ? ( + + {plugin.sourceDisplay} - ) + ) : catalogEntry !== undefined ? ( + + ) : plugin.publisherLabel !== null ? ( + + {plugin.provenance === "builtin" + ? "BB Official" + : plugin.publisherLabel} + + ) : null + } + footerMeta={ + (catalogEntry?.category ?? plugin.category) !== undefined ? ( + + ) : null } description={ - runtimeStatus === null - ? plugin.description - : (plugin.statusDetail ?? runtimeStatus.condition) + runtimeStatus === null ? ( + plugin.description + ) : ( + + + {runtimeStatus.label} + + {" · "} + {plugin.statusDetail ?? runtimeStatus.condition} + + ) } openLabel={`${plugin.name ?? plugin.id} plugin details`} onOpen={openDetail} - trailingMeta={ - updateSignal !== null ? ( - - - - ) : undefined - } - persistentActions={ - <> + headerAction={ + + {updateSignal !== null ? ( + + + + ) : undefined} {notRunning ? ( not running @@ -182,9 +212,8 @@ export function InstalledPluginRow({ notRunning ? ` (${plugin.status}, not running)` : "" }`} /> - + } - trailingVisual={} />
); diff --git a/apps/app/src/components/plugin/management/PluginAuthorAvatar.tsx b/apps/app/src/components/plugin/management/PluginAuthorAvatar.tsx index aa3182b95e8..15b5f2b2abd 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorAvatar.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorAvatar.tsx @@ -1,5 +1,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@bb/shared-ui/avatar"; import { cn } from "@bb/shared-ui/lib/utils"; +import { BbLogo } from "@/components/ui/bb-logo"; function authorInitials(name: string): string { const initials = name @@ -15,25 +16,28 @@ export function PluginAuthorAvatar({ name, github, size, + official = false, }: { name: string; github: string | null; size: "detail" | "page"; + official?: boolean; }) { + const githubUsername = github ?? (official ? "get-bb" : null); return ( - {github === null ? null : ( + {githubUsername === null ? null : ( - {authorInitials(name)} + {official ? : authorInitials(name)} ); diff --git a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx index 1f019ce4425..2a59a380480 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx @@ -92,6 +92,10 @@ export function PluginAuthorPage({ [authorKey, catalogQuery.data?.entries], ); const author = useMemo(() => authorForEntries(entries), [entries]); + const official = + entries.length > 0 && + entries.every((entry) => entry.marketplace === "bb-official"); + const authorName = official ? "BB Official" : author?.name; const installsKnown = entries.some((entry) => entry.installs !== null); const sort = requestedSort === "most-installed" && !installsKnown ? null : requestedSort; @@ -152,13 +156,14 @@ export function PluginAuthorPage({ {author === null ? null : (

- {author.name} + {authorName} {entries.length.toLocaleString()}{" "} {entries.length === 1 ? "plugin" : "plugins"} @@ -171,6 +176,9 @@ export function PluginAuthorPage({ rel="noreferrer" className="inline-flex items-center gap-1 rounded-sm text-xs text-subtle-foreground underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > + {author.url.startsWith("https://github.com/") ? ( + + ) : null} {formatUrlLabel(author.url)} Opens in a new tab diff --git a/apps/app/src/components/plugin/management/PluginCard.tsx b/apps/app/src/components/plugin/management/PluginCard.tsx new file mode 100644 index 00000000000..878cf724bf4 --- /dev/null +++ b/apps/app/src/components/plugin/management/PluginCard.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from "react"; +import { ResourceBrowseCard } from "@bb/shared-ui/resource-list"; +import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; +import { PluginAuthorAvatar } from "./PluginAuthorAvatar"; +import { PluginAuthorLink } from "./PluginAuthorLink"; +import { pluginAuthorGithub } from "./plugin-marketplace-author"; + +interface PluginCardProps { + title: string; + description: ReactNode; + leading: ReactNode; + byline: ReactNode; + footerMeta: ReactNode; + headerAction: ReactNode; + openLabel: string; + onOpen: (trigger: HTMLButtonElement) => void; +} + +export function PluginCard(props: PluginCardProps) { + return ( + {props.title} + } + /> + ); +} + +interface PluginAuthorBylineProps { + name: string; + github: string | null; + official?: boolean; + children: ReactNode; +} + +export function PluginAuthorByline({ + name, + github, + official, + children, +}: PluginAuthorBylineProps) { + return ( + + + {children} + + ); +} + +interface PluginCardAuthorProps { + entry: Pick< + PluginCatalogSearchEntry, + "author" | "marketplace" | "publisherLabel" + >; +} + +export function PluginCardAuthor({ entry }: PluginCardAuthorProps) { + const name = + entry.marketplace === "bb-official" + ? "BB Official" + : (entry.author?.name ?? entry.publisherLabel); + return ( + + {entry.author === null ? ( + name + ) : ( + + {name} + + )} + + ); +} diff --git a/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts b/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts new file mode 100644 index 00000000000..5522510fe5a --- /dev/null +++ b/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { installedPluginCatalogEntry } from "./installed-plugin-catalog"; + +const community = { + pluginId: "notes", + entryId: "notes", + marketplace: "community", + source: "git:https://github.com/alice/notes.git@v2", +}; +const official = { + ...community, + marketplace: "bb-official", + source: "builtin:notes", +}; + +describe("installed plugin catalog identity", () => { + it("uses the recorded marketplace when another listing shares its IDs", () => { + expect( + installedPluginCatalogEntry( + { + id: "notes", + catalogEntryId: "notes", + catalogMarketplaceName: "community", + source: "git:https://github.com/alice/notes.git@v1", + }, + [official, community], + ), + ).toBe(community); + }); + it("does not give a direct or local plugin another author's catalog identity", () => { + for (const source of [ + "git:https://github.com/bob/notes.git", + "path:/workspace/notes", + ]) { + expect( + installedPluginCatalogEntry( + { id: "notes", catalogEntryId: null, source }, + [official, community], + ), + ).toBeUndefined(); + } + }); + it("matches a bundled plugin by its source without marketplace installation metadata", () => { + expect( + installedPluginCatalogEntry( + { id: "notes", catalogEntryId: null, source: "builtin:notes" }, + [community, official], + ), + ).toBe(official); + }); +}); diff --git a/apps/app/src/components/plugin/management/installed-plugin-catalog.ts b/apps/app/src/components/plugin/management/installed-plugin-catalog.ts new file mode 100644 index 00000000000..966a4094cf2 --- /dev/null +++ b/apps/app/src/components/plugin/management/installed-plugin-catalog.ts @@ -0,0 +1,26 @@ +import type { PluginListItem } from "@/hooks/queries/plugin-settings-queries"; +import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; + +export function installedPluginCatalogEntry< + Entry extends Pick< + PluginCatalogSearchEntry, + "pluginId" | "entryId" | "marketplace" | "source" + >, +>( + plugin: Pick< + PluginListItem, + "id" | "source" | "catalogEntryId" | "catalogMarketplaceName" + >, + entries: readonly Entry[], +): Entry | undefined { + if (plugin.source.startsWith("path:")) return undefined; + return entries.find( + (entry) => + entry.pluginId === plugin.id && + (plugin.catalogEntryId === null || + entry.entryId === plugin.catalogEntryId) && + (plugin.catalogMarketplaceName === undefined + ? entry.source === plugin.source + : entry.marketplace === plugin.catalogMarketplaceName), + ); +} diff --git a/apps/app/src/components/plugin/management/plugin-ui.tsx b/apps/app/src/components/plugin/management/plugin-ui.tsx index 8e8ccb5f7a0..d6594350c3e 100644 --- a/apps/app/src/components/plugin/management/plugin-ui.tsx +++ b/apps/app/src/components/plugin/management/plugin-ui.tsx @@ -91,9 +91,9 @@ export function pluginCatalogCategoryPillStyle( color: neutral(55), } : { - background: accentTint(accentToken, 16), - borderColor: accentTint(accentToken, 24), - color: accentInk(accentToken, 52), + background: accentTint(accentToken, 10), + borderColor: accentTint(accentToken, 18), + color: accentInk(accentToken, 50), }; } @@ -194,7 +194,7 @@ export function PluginCategoryLabel({ }) { return ( {label} @@ -205,6 +205,7 @@ export function PluginCategoryLabel({ export function CatalogEntryIconChip({ entry, className, + compact = false, }: { entry: { displayName: string; @@ -213,17 +214,26 @@ export function CatalogEntryIconChip({ iconTinted: boolean; }; className?: string; + compact?: boolean; }) { return ( - {() => } + {() => ( + + )} ); } diff --git a/apps/app/src/hooks/queries/plugin-settings-queries.ts b/apps/app/src/hooks/queries/plugin-settings-queries.ts index 5e4e5d75f98..dbc570254bd 100644 --- a/apps/app/src/hooks/queries/plugin-settings-queries.ts +++ b/apps/app/src/hooks/queries/plugin-settings-queries.ts @@ -52,6 +52,9 @@ export interface PluginListItem { source: string; isOrphanedBuiltin: boolean; catalogEntryId: string | null; + catalogMarketplaceName?: string; + categoryId?: InstalledPlugin["categoryId"]; + category?: string; publisherLabel: string | null; sourceDisplay: string; updateState: PluginUpdateState; @@ -108,6 +111,9 @@ export function toPluginListItem(plugin: InstalledPlugin): PluginListItem { source: plugin.source, isOrphanedBuiltin: plugin.isOrphanedBuiltin, catalogEntryId: plugin.catalogEntryId ?? null, + catalogMarketplaceName: plugin.catalogMarketplaceName, + categoryId: plugin.categoryId, + category: plugin.category, publisherLabel: plugin.publisherLabel, sourceDisplay: plugin.sourceDisplay, updateState: { From c2fcf47fa224891998d7d3f2a14e43ba37314e29 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:05:00 +0000 Subject: [PATCH 04/79] Constrain author collection cards to the mobile viewport --- .../src/components/plugin/management/PluginAuthorPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx index 2a59a380480..41c430e5a4a 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx @@ -140,7 +140,10 @@ export function PluginAuthorPage({ }; return ( - +
Date: Mon, 14 Sep 2026 06:10:55 +0000 Subject: [PATCH 05/79] Use grid pagination and preserve status detail assertions --- .../plugin/PluginsOverview.test.tsx | 86 ++----------------- .../src/components/plugin/PluginsOverview.tsx | 10 +-- .../plugin/management/InstalledPluginsTab.tsx | 2 +- .../views/ToolsView.plugin-detail.test.tsx | 12 +-- 4 files changed, 11 insertions(+), 99 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 06167771184..978f92a1f9f 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -453,7 +453,7 @@ describe("PluginsOverview", () => { }); it("loads more installed plugins as the scroll sentinel is reached", async () => { - const plugins = Array.from({ length: 12 }, (_, index) => { + const plugins = Array.from({ length: 14 }, (_, index) => { const ordinal = String(index + 1).padStart(2, "0"); return { ...AUTOMATIONS_PLUGIN, @@ -496,8 +496,8 @@ describe("PluginsOverview", () => { ); expect(await screen.findByText("Plugin 01")).toBeTruthy(); - expect(screen.getByText("Plugin 10")).toBeTruthy(); - expect(screen.queryByText("Plugin 11")).toBeNull(); + expect(screen.getByText("Plugin 12")).toBeTruthy(); + expect(screen.queryByText("Plugin 13")).toBeNull(); expect( document.querySelector("[data-resource-infinite-sentinel]"), ).not.toBeNull(); @@ -505,7 +505,7 @@ describe("PluginsOverview", () => { reachSentinel(); expect(screen.getByText("Plugin 01")).toBeTruthy(); - expect(screen.getByText("Plugin 12")).toBeTruthy(); + expect(screen.getByText("Plugin 14")).toBeTruthy(); expect( document.querySelector("[data-resource-infinite-sentinel]"), ).toBeNull(); @@ -515,85 +515,9 @@ describe("PluginsOverview", () => { { target: { value: "Plugin 01" } }, ); expect(screen.getByText("Plugin 01")).toBeTruthy(); - expect(screen.queryByText("Plugin 12")).toBeNull(); + expect(screen.queryByText("Plugin 14")).toBeNull(); }); - it("fits the first chunk to the viewport and keeps the list panel unstretched", async () => { - const plugins = Array.from({ length: 30 }, (_, index) => { - const ordinal = String(index + 1).padStart(2, "0"); - return { - ...AUTOMATIONS_PLUGIN, - id: `plugin-${ordinal}`, - source: `builtin:plugin-${ordinal}`, - name: `Plugin ${ordinal}`, - }; - }); - const viewportHeight = 760; - const clientHeightDescriptor = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - "clientHeight", - ); - - Object.defineProperty(HTMLElement.prototype, "clientHeight", { - configurable: true, - get() { - return this.id === "plugins-installed-results" ? viewportHeight : 0; - }, - }); - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( - function getBoundingClientRect(this: HTMLElement) { - const height = this.hasAttribute("data-resource-list-panel") - ? viewportHeight - : this.hasAttribute("data-resource-row") - ? 50 - : 0; - return new DOMRect(0, 0, 800, height); - }, - ); - vi.stubGlobal( - "ResizeObserver", - class ResizeObserverMock { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - - try { - installFetch(plugins); - const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByText("Plugin 15")).toBeTruthy(); - }); - expect(screen.queryByText("Plugin 16")).toBeNull(); - expect( - document.querySelector("[data-resource-infinite-sentinel]"), - ).not.toBeNull(); - const listPanel = document.querySelector("[data-resource-list-panel]"); - expect( - document.querySelector("[data-resource-collection-content]"), - ).toBeNull(); - expect(listPanel?.classList.contains("flex-1")).toBe(false); - } finally { - if (clientHeightDescriptor === undefined) { - Reflect.deleteProperty(HTMLElement.prototype, "clientHeight"); - } else { - Object.defineProperty( - HTMLElement.prototype, - "clientHeight", - clientHeightDescriptor, - ); - } - } - }); it("sorts enabled plugins before inactive plugins and published plugins first within enabled", async () => { installFetch([ diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index fbde84a0ace..f40c6e2a697 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { ResourceInfiniteScrollSentinel, useResourceInfiniteItems, - useResourceViewportPageSize, + RESOURCE_GRID_PAGE_SIZE, } from "@bb/shared-ui/resource-pagination"; import { ResourceCollectionPage, @@ -54,8 +54,6 @@ export function PluginsOverview({ mode ?? (searchParams.get("view") === "installed" ? "installed" : "browse"); const authorKey = searchParams.get("author"); const [installedQuery, setInstalledQuery] = useState(""); - const [installedViewport, setInstalledViewport] = - useState(null); const [installedSortDirection, setInstalledSortDirection] = useState< "asc" | "desc" >("asc"); @@ -74,9 +72,6 @@ export function PluginsOverview({ installedSortDirection, [...activeTypeFilters].sort().join(","), ].join("\u0000"); - const installedPageSize = useResourceViewportPageSize(installedViewport, { - resetKey: installedResetKey, - }); const [addDialog, setAddDialog] = useState<{ open: boolean; initial: AddPluginInitial | null; @@ -130,7 +125,7 @@ export function PluginsOverview({ ], ); const installedList = useResourceInfiniteItems(visiblePlugins, { - pageSize: installedPageSize, + pageSize: RESOURCE_GRID_PAGE_SIZE, resetKey: installedResetKey, }); @@ -186,7 +181,6 @@ export function PluginsOverview({ content = ( {" · "} - {plugin.statusDetail ?? runtimeStatus.condition} + {plugin.statusDetail ?? runtimeStatus.condition} ) } diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index 2b9674a6c8f..c5e5272379f 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -836,13 +836,7 @@ describe("BB Official plugin detail routing", () => { const pluginButton = await screen.findByRole("button", { name: "GitHub plugin details", }); - expect( - vi - .mocked(fetch) - .mock.calls.some(([input]) => - String(input).startsWith("/api/v1/plugin-catalog/search"), - ), - ).toBe(false); + fireEvent.click(pluginButton); } @@ -924,7 +918,7 @@ describe("BB Official plugin detail routing", () => { { wrapper: QueryClientWrapper }, ); - fireEvent.click((await screen.findAllByRole("link", { name: "BB" }))[0]!); + fireEvent.click((await screen.findAllByRole("link", { name: "BB Official" }))[0]!); expect(await screen.findByRole("heading", { name: /^BB/u })).toBeTruthy(); let params = new URLSearchParams( screen.getByTestId("route-search").textContent ?? "", @@ -944,7 +938,7 @@ describe("BB Official plugin detail routing", () => { expect(params.getAll("category")).toEqual(["code-and-reviews"]); expect(params.get("sort")).toBe("recently-added"); - fireEvent.click((await screen.findAllByRole("link", { name: "BB" }))[0]!); + fireEvent.click((await screen.findAllByRole("link", { name: "BB Official" }))[0]!); const card = await screen.findByRole("button", { name: "Open GitHub details", }); From 3a3d1737ea9029b2f91663d80cc93a152ccb5510 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:11:15 +0000 Subject: [PATCH 06/79] Format updated plugin collection assertions --- apps/app/src/components/plugin/PluginsOverview.test.tsx | 1 - apps/app/src/views/ToolsView.plugin-detail.test.tsx | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 978f92a1f9f..5873332b9f5 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -518,7 +518,6 @@ describe("PluginsOverview", () => { expect(screen.queryByText("Plugin 14")).toBeNull(); }); - it("sorts enabled plugins before inactive plugins and published plugins first within enabled", async () => { installFetch([ { diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index c5e5272379f..b52f81f9994 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -918,7 +918,9 @@ describe("BB Official plugin detail routing", () => { { wrapper: QueryClientWrapper }, ); - fireEvent.click((await screen.findAllByRole("link", { name: "BB Official" }))[0]!); + fireEvent.click( + (await screen.findAllByRole("link", { name: "BB Official" }))[0]!, + ); expect(await screen.findByRole("heading", { name: /^BB/u })).toBeTruthy(); let params = new URLSearchParams( screen.getByTestId("route-search").textContent ?? "", @@ -938,7 +940,9 @@ describe("BB Official plugin detail routing", () => { expect(params.getAll("category")).toEqual(["code-and-reviews"]); expect(params.get("sort")).toBe("recently-added"); - fireEvent.click((await screen.findAllByRole("link", { name: "BB Official" }))[0]!); + fireEvent.click( + (await screen.findAllByRole("link", { name: "BB Official" }))[0]!, + ); const card = await screen.findByRole("button", { name: "Open GitHub details", }); From c60f06e7588f3a2094a8294bfef95b50f702760f Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:32:10 +0000 Subject: [PATCH 07/79] Keep marketplace identity fixture consistent during catalog loading --- apps/app/src/components/plugin/PluginsOverview.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 5873332b9f5..231f5c0c5ad 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -816,7 +816,7 @@ describe("PluginsOverview", () => { expect(screen.getByText("Inactive Local Plugin")).toBeTruthy(); }); - it("badges a built-in plugin BB Official and a catalog install by its marketplace", async () => { + it("keeps the recorded publisher when the catalog entry belongs to another marketplace", async () => { installFetch([ AUTOMATIONS_PLUGIN, { @@ -828,6 +828,7 @@ describe("PluginsOverview", () => { publisherKey: "bb-community", publisherLabel: "BB Community", catalogEntryId: GITHUB_CATALOG_ENTRY.entryId, + catalogMarketplaceName: "bb-community", sourceDisplay: "BB Official · GitHub", }, ]); From ef4728596a6d2d3783ddd1d6cd0e81ae50708359 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:59:18 +0000 Subject: [PATCH 08/79] Preserve prototype category hues when mixing card colors --- apps/app/src/components/plugin/management/plugin-ui.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/plugin/management/plugin-ui.tsx b/apps/app/src/components/plugin/management/plugin-ui.tsx index d6594350c3e..16f4860af31 100644 --- a/apps/app/src/components/plugin/management/plugin-ui.tsx +++ b/apps/app/src/components/plugin/management/plugin-ui.tsx @@ -65,11 +65,11 @@ function neutral(percent: number): string { } function accentTint(token: string, percent: number): string { - return `color-mix(in oklch, var(${token}) ${percent}%, var(--canvas))`; + return `color-mix(in oklab, var(${token}) ${percent}%, var(--canvas))`; } function accentInk(token: string, percent: number): string { - return `color-mix(in oklch, var(${token}) ${percent}%, var(--ink))`; + return `color-mix(in oklab, var(${token}) ${percent}%, var(--ink))`; } function pluginCatalogCategoryAccentToken( From 1416d8151bb07c02281acc52e712e31b04de25b7 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:12:40 +0000 Subject: [PATCH 09/79] Unify plugin collection filters and responsive sort controls --- .../components/create-via-prompt-examples.tsx | 3 + .../plugin/PluginsOverview.test.tsx | 199 +++++----------- .../src/components/plugin/PluginsOverview.tsx | 215 ++++++++++-------- .../management/BrowsePluginsTab.test.tsx | 4 +- .../plugin/management/BrowsePluginsTab.tsx | 9 +- .../plugin/management/PluginAuthorPage.tsx | 7 +- .../management/PluginBrowseControls.tsx | 136 ++++++----- .../management/plugin-browse-discovery.ts | 52 +++-- .../src/components/ui/resource/toolbar.tsx | 76 ++++++- 9 files changed, 365 insertions(+), 336 deletions(-) diff --git a/apps/app/src/components/create-via-prompt-examples.tsx b/apps/app/src/components/create-via-prompt-examples.tsx index 340cbaaa7a4..befe8ac61c6 100644 --- a/apps/app/src/components/create-via-prompt-examples.tsx +++ b/apps/app/src/components/create-via-prompt-examples.tsx @@ -85,6 +85,7 @@ interface CreateWithTemplatesButtonProps { kind: CreateViaPromptKind; label: string; menuActions?: readonly ResourceCreateMenuAction[]; + compactWhenNarrow?: boolean; onCreate: (prompt?: string) => void; } @@ -92,6 +93,7 @@ export function CreateWithTemplatesButton({ kind, label, menuActions, + compactWhenNarrow, onCreate, }: CreateWithTemplatesButtonProps) { const { examples } = getCreateExamples(kind); @@ -116,6 +118,7 @@ export function CreateWithTemplatesButton({ templates={examples} templateGroups={templateGroups} menuActions={menuActions} + compactWhenNarrow={compactWhenNarrow} onCreate={onCreate} /> ); diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 231f5c0c5ad..f0446f9f7a9 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -282,7 +282,7 @@ describe("PluginsOverview", () => { ); }); - it("shows category filters only in Browse", async () => { + it("filters Browse by category", async () => { installFetch([ AUTOMATIONS_PLUGIN, { @@ -339,7 +339,7 @@ describe("PluginsOverview", () => { expect(screen.getByTestId("location-path").textContent).toBe("/"); }); - it("shows the Type filter on Installed instead of Category", async () => { + it("shows the same category control on Installed", async () => { installFetch([AUTOMATIONS_PLUGIN]); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( @@ -354,7 +354,11 @@ describe("PluginsOverview", () => { expect(await screen.findByText("Automations")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Category" })).toBeNull(); - expect(screen.getByRole("button", { name: "Type" })).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "Filter plugins by category: All categories", + }), + ).toBeTruthy(); expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy(); }); @@ -582,62 +586,65 @@ describe("PluginsOverview", () => { expect(officialPills).toHaveLength(2); expect(screen.getAllByText("BB Community")).toHaveLength(1); - const sortTrigger = screen.getByRole("button", { - name: "Sort: Plugin name, ascending", - }); - expect(sortTrigger.querySelector('[data-icon="ArrowUpDown"]')).toBeTruthy(); + const sortTrigger = screen.getByRole("button", { name: "Sort: Default" }); fireEvent.pointerDown(sortTrigger); - fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); expect( + screen.getByRole("menuitemradio", { name: "Recently added" }), + ).toBeTruthy(); + expect( + screen + .getByRole("menuitemradio", { name: "Most installed" }) + .getAttribute("aria-disabled"), + ).toBe("true"); + const rowIds = () => [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( (row) => row.getAttribute("data-testid"), - ), - ).toEqual([ - "plugin-row-enabled-official-zulu", - "plugin-row-enabled-official-alpha", + ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); + expect(rowIds()).toEqual([ "plugin-row-enabled-local-alpha", - "plugin-row-inactive-official", + "plugin-row-enabled-official-alpha", + "plugin-row-enabled-official-zulu", "plugin-row-inactive-local", + "plugin-row-inactive-official", ]); - - fireEvent.keyDown( - screen.getByRole("menu", { - name: "Sort: Plugin name, descending", - }), - { key: "Escape" }, - ); - fireEvent.click(screen.getByText("switch-to-browse")); - await screen.findByText("GitHub"); - fireEvent.click(screen.getByText("switch-to-installed")); - expect( - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ), - ).toEqual([ + fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); + expect(rowIds()).toEqual([ + "plugin-row-inactive-official", + "plugin-row-inactive-local", "plugin-row-enabled-official-zulu", "plugin-row-enabled-official-alpha", "plugin-row-enabled-local-alpha", - "plugin-row-inactive-official", + ]); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); + expect(rowIds()).toEqual([ + "plugin-row-enabled-official-alpha", + "plugin-row-enabled-official-zulu", + "plugin-row-enabled-local-alpha", "plugin-row-inactive-local", + "plugin-row-inactive-official", ]); + expect(screen.queryByRole("menuitem", { name: "Clear sort" })).toBeNull(); }); - it("gives each publisher its own Type facet, separate from User", async () => { + it("filters Installed by catalog and local categories and clears the selection", async () => { installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, + AUTOMATIONS_PLUGIN, { ...AUTOMATIONS_PLUGIN, - id: "catalog-one", - name: "Catalog One", - provenance: "catalog", - publisherKey: "bb-community", - publisherLabel: "BB Community", - catalogEntryId: "catalog-one", + id: "local-notes", + source: "path:/workspace/notes", + name: "Local Notes", + provenance: "direct", + publisherLabel: null, + categoryId: "memory-and-context", + category: "Memory & Context", }, { ...AUTOMATIONS_PLUGIN, - id: "direct-one", - name: "Direct One", + id: "uncategorized", + source: "path:/workspace/other", + name: "Other Plugin", provenance: "direct", publisherLabel: null, }, @@ -647,117 +654,27 @@ describe("PluginsOverview", () => { - - , ); - - await screen.findByText("Direct One"); - const rowIds = () => - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ); - - const typeTrigger = screen.getByRole("button", { name: "Type" }); - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - "plugin-row-direct-one", - ]); - fireEvent.pointerDown(typeTrigger); - expect(screen.queryByRole("menuitemcheckbox", { name: "All" })).toBeNull(); - - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Official" }), - ); - await waitFor(() => { - expect(rowIds()).toEqual(["plugin-row-builtin-one"]); - }); - - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Community" }), - ); - await waitFor(() => { - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - ]); - }); - - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); + await screen.findByText("Local Notes"); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Official" }), + screen.getByRole("button", { + name: "Filter plugins by category: All categories", + }), ); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Community" }), - ); - await waitFor(() => { - expect(rowIds()).toEqual(["plugin-row-direct-one"]); - }); - - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); - await waitFor(() => { - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - "plugin-row-direct-one", - ]); - }); - expect(screen.queryByText("No plugins match these filters.")).toBeNull(); - }); - - it("drops a Type selection whose facet no longer has any plugin", async () => { - installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, - { - ...AUTOMATIONS_PLUGIN, - id: "acme-one", - name: "Acme One", - provenance: "catalog", - publisherKey: "acme-plugins", - publisherLabel: "Acme Plugins", - catalogEntryId: "acme-one", - }, - ]); - const { wrapper: QueryClientWrapper, queryClient } = - createQueryClientTestHarness(); - render( - - - - - , + await screen.findByRole("option", { name: /Memory & Context, 1 plugin/ }), ); - - await screen.findByText("Acme One"); - fireEvent.pointerDown(screen.getByRole("button", { name: "Type" })); + expect(screen.getByText("Local Notes")).toBeTruthy(); + expect(screen.queryByText("Automations")).toBeNull(); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Acme Plugins" }), + screen.getByRole("option", { name: /Workflow management, 1 plugin/ }), ); - await waitFor(() => { - expect( - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ), - ).toEqual(["plugin-row-acme-one"]); - }); - - installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, - ]); - await act(async () => { - await queryClient.invalidateQueries(); - }); - - await waitFor(() => { - expect( - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ), - ).toEqual(["plugin-row-builtin-one"]); - }); - expect(screen.queryByText("No plugins match these filters.")).toBeNull(); + expect(screen.getByText("Automations")).toBeTruthy(); + expect(screen.queryByText("Other Plugin")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + expect(screen.getByText("Other Plugin")).toBeTruthy(); }); it("keeps disabled plugins installed regardless of provenance", async () => { diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index f40c6e2a697..6b246804161 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -9,9 +9,6 @@ import { ResourceCollectionPage, ResourceCollectionViewport, ResourceListState, - ResourceMultiSelectMenu, - ResourceSortMenu, - ResourceToolbar, } from "@bb/shared-ui/resource-list"; import { cn } from "@bb/shared-ui/lib/utils"; import { CreateWithTemplatesButton } from "@/components/create-via-prompt-examples"; @@ -21,14 +18,24 @@ import { AddPluginDialog, type AddPluginInitial, } from "@/components/plugin/management/AddPluginDialog"; -import { BrowsePluginsTab } from "@/components/plugin/management/BrowsePluginsTab"; +import { + BrowsePluginsTab, + pluginCategoryFilterOptions, +} from "@/components/plugin/management/BrowsePluginsTab"; import { CheckPluginUpdatesButton } from "@/components/plugin/management/CheckPluginUpdatesButton"; import { InstalledPluginsTab } from "@/components/plugin/management/InstalledPluginsTab"; import { PluginAuthorPage } from "@/components/plugin/management/PluginAuthorPage"; +import { usePluginCatalogSearch } from "@/hooks/queries/plugin-catalog-queries"; +import { installedPluginCatalogEntry } from "./management/installed-plugin-catalog"; +import { + PluginCollectionToolbar, + pluginBrowseSort, + pluginBrowseSortDirection, +} from "./management/PluginBrowseControls"; import { - pluginPublisherFilterId, - pluginPublisherFilterOptions, -} from "@/components/plugin/plugin-provenance"; + pluginCategoryFilterId, + sortPluginEntries, +} from "./management/plugin-browse-discovery"; import { PLUGINS_INSTALLED_DESCRIPTION } from "@/components/plugin/plugins-collection-copy"; import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; import { @@ -44,7 +51,7 @@ export function PluginsOverview({ onOpenPlugin?: (pluginId: string, trigger: HTMLButtonElement) => void; } = {}) { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const listQuery = usePluginList({ enabled: true }); const plugins = useMemo( () => listQuery.data?.plugins ?? [], @@ -53,77 +60,109 @@ export function PluginsOverview({ const activeMode = mode ?? (searchParams.get("view") === "installed" ? "installed" : "browse"); const authorKey = searchParams.get("author"); - const [installedQuery, setInstalledQuery] = useState(""); - const [installedSortDirection, setInstalledSortDirection] = useState< - "asc" | "desc" - >("asc"); - const [typeFilters, setTypeFilters] = useState([]); - const typeFilterOptions = useMemo( - () => pluginPublisherFilterOptions(plugins), - [plugins], + const installedQuery = searchParams.get("query") ?? ""; + const catalogQuery = usePluginCatalogSearch("", { + enabled: activeMode === "installed", + }); + const installedEntries = useMemo( + () => + plugins.map((plugin) => { + const entry = installedPluginCatalogEntry( + plugin, + catalogQuery.data?.entries ?? [], + ); + return { + plugin, + entryId: plugin.id, + displayName: plugin.name ?? plugin.id, + categoryId: entry?.categoryId ?? plugin.categoryId, + category: entry?.category ?? plugin.category, + publishedAt: entry?.publishedAt, + installs: entry?.installs ?? null, + }; + }), + [plugins, catalogQuery.data?.entries], ); - const activeTypeFilters = useMemo(() => { - const offered = new Set(typeFilterOptions.map((option) => option.id)); - return typeFilters.filter((value) => offered.has(value)); - }, [typeFilterOptions, typeFilters]); + const selectedCategories = searchParams.getAll("category"); + const categoryOptions = useMemo( + () => pluginCategoryFilterOptions(installedEntries, selectedCategories), + [installedEntries, selectedCategories], + ); + const installsKnown = installedEntries.some( + (entry) => entry.installs !== null, + ); + const requestedSort = pluginBrowseSort(searchParams.get("sort")); + const installedSort = + requestedSort === "most-installed" && !installsKnown ? null : requestedSort; + const installedSortDirection = + pluginBrowseSortDirection(searchParams.get("direction")) ?? + (installedSort === "name" ? "asc" : "desc"); + const changeSearchParams = (change: (next: URLSearchParams) => void) => { + const next = new URLSearchParams(searchParams); + change(next); + setSearchParams(next, { replace: true }); + }; const normalizedInstalledQuery = installedQuery.trim().toLowerCase(); const installedResetKey = [ normalizedInstalledQuery, + installedSort, installedSortDirection, - [...activeTypeFilters].sort().join(","), + [...selectedCategories].sort().join(","), ].join("\u0000"); const [addDialog, setAddDialog] = useState<{ open: boolean; initial: AddPluginInitial | null; }>({ open: false, initial: null }); - const visiblePlugins = useMemo( - () => - plugins - .filter((plugin) => { - if ( - activeTypeFilters.length > 0 && - !activeTypeFilters.includes(pluginPublisherFilterId(plugin)) - ) { - return false; - } - if (normalizedInstalledQuery.length === 0) return true; - return [ - plugin.id, - plugin.name ?? "", - plugin.description ?? "", - plugin.version, - plugin.sourceDisplay, - ] - .join(" ") - .toLowerCase() - .includes(normalizedInstalledQuery); - }) - .sort((left, right) => { - const enabledResult = Number(!left.enabled) - Number(!right.enabled); - if (enabledResult !== 0) return enabledResult; - if (left.enabled) { - const leftPublisher = left.publisherLabel; - const rightPublisher = right.publisherLabel; - const publisherResult = - Number(leftPublisher === null) - Number(rightPublisher === null); - if (publisherResult !== 0) return publisherResult; - } - const result = (left.name ?? left.id).localeCompare( - right.name ?? right.id, - ); - if (result !== 0) { - return installedSortDirection === "asc" ? result : -result; - } - return left.id.localeCompare(right.id); - }), - [ - activeTypeFilters, - installedSortDirection, - normalizedInstalledQuery, - plugins, - ], - ); + const visiblePlugins = useMemo(() => { + const filtered = installedEntries.filter((entry) => { + if ( + selectedCategories.length > 0 && + !selectedCategories.includes(pluginCategoryFilterId(entry)) + ) + return false; + if (normalizedInstalledQuery.length === 0) return true; + const plugin = entry.plugin; + return [ + plugin.id, + plugin.name ?? "", + plugin.description ?? "", + plugin.version, + plugin.sourceDisplay, + ] + .join(" ") + .toLowerCase() + .includes(normalizedInstalledQuery); + }); + if (installedSort !== null) + return sortPluginEntries( + filtered, + installedSort, + installedSortDirection, + ).map((entry) => entry.plugin); + return filtered + .map((entry) => entry.plugin) + .sort((left, right) => { + const enabledResult = Number(!left.enabled) - Number(!right.enabled); + if (enabledResult !== 0) return enabledResult; + if (left.enabled) { + const publisherResult = + Number(left.publisherLabel === null) - + Number(right.publisherLabel === null); + if (publisherResult !== 0) return publisherResult; + } + return ( + (left.name ?? left.id).localeCompare(right.name ?? right.id) || + left.id.localeCompare(right.id) + ); + }); + }, [ + installedEntries, + selectedCategories, + normalizedInstalledQuery, + installedSort, + installedSortDirection, + ]); const installedList = useResourceInfiniteItems(visiblePlugins, { pageSize: RESOURCE_GRID_PAGE_SIZE, resetKey: installedResetKey, @@ -143,6 +182,7 @@ export function PluginsOverview({ <> - - - setInstalledSortDirection((current) => - current === "asc" ? "desc" : "asc", - ) - } - /> - {plugins.length > 0 ? : null} - + additionalControls={ + plugins.length > 0 ? : null } /> } @@ -230,7 +255,7 @@ export function PluginsOverview({ message={ normalizedInstalledQuery === "" ? "No plugins match these filters." - : activeTypeFilters.length > 0 + : selectedCategories.length > 0 ? `No plugins match "${installedQuery}" with these filters.` : `No plugins match "${installedQuery}"` } diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index 6f6dc6b6e47..9a192bea3b4 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -296,7 +296,7 @@ describe("BrowsePluginsTab", () => { }); const sortTrigger = await screen.findByRole("button", { - name: "Sort: Featured", + name: "Sort: Default", }); fireEvent.pointerDown(sortTrigger); expect( @@ -370,7 +370,7 @@ describe("BrowsePluginsTab", () => { }); expect(screen.queryByTestId("plugin-browse-shelves")).toBeNull(); fireEvent.pointerDown(trigger); - fireEvent.click(screen.getByRole("menuitemradio", { name: "Featured" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); expect(await screen.findByTestId("plugin-browse-shelves")).toBeTruthy(); const params = new URLSearchParams( screen.getByTestId("location-search").textContent ?? "", diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 882a5daf944..b62d03f5866 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -33,7 +33,7 @@ import { import type { AddPluginInitial } from "./AddPluginDialog"; import { PluginCard, PluginCardAuthor } from "./PluginCard"; import { - PluginBrowseToolbar, + PluginCollectionToolbar, pluginBrowseSort, pluginBrowseSortDirection, type PluginBrowseCategoryOption, @@ -69,7 +69,8 @@ export function BrowsePluginsTab({ const selectedCategories = searchParams.getAll("category"); const requestedSort = pluginBrowseSort(searchParams.get("sort")); const sortDirection = - pluginBrowseSortDirection(searchParams.get("direction")) ?? "desc"; + pluginBrowseSortDirection(searchParams.get("direction")) ?? + (requestedSort === "name" ? "asc" : "desc"); const [heroRequest, setHeroRequest] = useState<{ nonce: number; seed?: string; @@ -195,7 +196,7 @@ export function BrowsePluginsTab({ ) : (
- [], selected: readonly string[], ): PluginBrowseCategoryOption[] { const labels = new Map(); diff --git a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx index 41c430e5a4a..f4029e14c06 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx @@ -20,7 +20,7 @@ import { } from "./BrowsePluginsTab"; import { PluginAuthorAvatar } from "./PluginAuthorAvatar"; import { - PluginBrowseToolbar, + PluginCollectionToolbar, pluginBrowseSort, pluginBrowseSortDirection, } from "./PluginBrowseControls"; @@ -78,7 +78,8 @@ export function PluginAuthorPage({ const selectedCategories = searchParams.getAll("category"); const requestedSort = pluginBrowseSort(searchParams.get("sort")); const sortDirection = - pluginBrowseSortDirection(searchParams.get("direction")) ?? "desc"; + pluginBrowseSortDirection(searchParams.get("direction")) ?? + (requestedSort === "name" ? "asc" : "desc"); const catalogQuery = usePluginCatalogSearch("", { enabled: true }); const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: debouncedQuery !== "", @@ -204,7 +205,7 @@ export function PluginAuthorPage({ ) : (
- = { + name: "Plugin name", "recently-added": "Recently added", "most-installed": "Most installed", }; const PLUGIN_BROWSE_SORT_ICONS: Record = { + name: "Sort", "recently-added": "Clock", "most-installed": "Download", }; @@ -53,7 +64,7 @@ export interface PluginBrowseCategoryOption { count: number; } -export function PluginBrowseToolbar({ +export function PluginCollectionToolbar({ query, selectedCategories, categoryOptions, @@ -61,7 +72,15 @@ export function PluginBrowseToolbar({ sortDirection, installsKnown, changeSearchParams, + searchPlaceholder = "Search plugins", + action, + additionalControls, + className, }: { + searchPlaceholder?: string; + action?: ReactNode; + additionalControls?: ReactNode; + className?: string; query: string; selectedCategories: readonly string[]; categoryOptions: readonly PluginBrowseCategoryOption[]; @@ -71,10 +90,12 @@ export function PluginBrowseToolbar({ changeSearchParams: (change: (next: URLSearchParams) => void) => void; }) { return ( -
+
changeSearchParams((next) => { if (value === "") next.delete("query"); @@ -83,23 +104,12 @@ export function PluginBrowseToolbar({ } controls={ <> - - changeSearchParams((next) => { - next.delete("category"); - for (const value of values) { - next.append("category", value); - } - }) - } - /> changeSearchParams((next) => { @@ -110,7 +120,7 @@ export function PluginBrowseToolbar({ ); } else { next.set("sort", value); - next.set("direction", "desc"); + next.set("direction", value === "name" ? "asc" : "desc"); } }) } @@ -121,6 +131,19 @@ export function PluginBrowseToolbar({ }) } /> + + changeSearchParams((next) => { + next.delete("category"); + for (const value of values) { + next.append("category", value); + } + }) + } + /> + {additionalControls} } /> @@ -172,12 +195,6 @@ export function PluginBrowseCategoryFilter({ const option = options.find((candidate) => candidate.id === selectedId); return option === undefined ? [] : [option]; }); - const selectionLabel = - selectedOptions.length === 0 - ? "All categories" - : selectedOptions.length === 1 - ? (selectedOptions[0]?.label ?? "Category") - : `${selectedOptions.length} categories`; const accessibleSelectionLabel = selectedOptions.length === 0 ? "All categories" @@ -256,40 +273,43 @@ export function PluginBrowseCategoryFilter({ if (!nextOpen) setSearch(""); }} > - - - + + + + + + + {`Category: ${accessibleSelectionLabel}`} + , +): boolean { return entry.categoryId !== undefined && entry.category !== undefined; } @@ -115,7 +117,7 @@ export function pluginBrowseShelves({ } export function pluginCategoryFilterId( - entry: PluginCatalogSearchEntry, + entry: Pick, ): string { return isCategorized(entry) ? (entry.categoryId ?? UNCATEGORIZED_PLUGIN_CATEGORY_ID) @@ -133,28 +135,36 @@ function compareOptionalNumbers( return direction === "asc" ? result : -result; } -export function sortPluginEntries( - entries: readonly PluginCatalogSearchEntry[], +export function sortPluginEntries< + Entry extends Pick< + PluginCatalogSearchEntry, + "displayName" | "entryId" | "publishedAt" | "installs" + >, +>( + entries: readonly Entry[], sort: PluginBrowseSort, direction: PluginBrowseSortDirection = "desc", -): PluginCatalogSearchEntry[] { +): Entry[] { return [...entries].sort((left, right) => { const sortResult = - sort === "recently-added" - ? compareOptionalNumbers( - left.publishedAt === undefined - ? undefined - : Date.parse(left.publishedAt), - right.publishedAt === undefined - ? undefined - : Date.parse(right.publishedAt), - direction, - ) - : compareOptionalNumbers( - left.installs ?? undefined, - right.installs ?? undefined, - direction, - ); + sort === "name" + ? left.displayName.localeCompare(right.displayName) * + (direction === "asc" ? 1 : -1) + : sort === "recently-added" + ? compareOptionalNumbers( + left.publishedAt === undefined + ? undefined + : Date.parse(left.publishedAt), + right.publishedAt === undefined + ? undefined + : Date.parse(right.publishedAt), + direction, + ) + : compareOptionalNumbers( + left.installs ?? undefined, + right.installs ?? undefined, + direction, + ); if (sortResult !== 0) return sortResult; const nameResult = left.displayName.localeCompare(right.displayName); return nameResult || left.entryId.localeCompare(right.entryId); diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 6a9ad57420b..de3ef64db3a 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -27,6 +27,7 @@ export function ResourceToolbar({ onSearchChange, controls, action, + compact = false, }: { searchValue: string; searchPlaceholder: string; @@ -34,10 +35,21 @@ export function ResourceToolbar({ onSearchChange: (value: string) => void; controls?: ReactNode; action?: ReactNode; + compact?: boolean; }) { return ( -
-
+
+
void; placeholderLabel?: string; compact?: boolean; + clearInFooter?: boolean; }) { const [open, setOpen] = useState(false); const selectedOption = options.find((option) => option.id === value); @@ -383,7 +397,7 @@ export function ResourceSortMenu({ > Sort by - {onClear === undefined ? null : ( + {onClear === undefined || clearInFooter ? null : ( ); })} + {clearInFooter && onClear !== undefined && value !== null ? ( + <> + + { + event.preventDefault(); + onClear(); + }} + className={cn( + "text-xs text-muted-foreground", + compact && "md:px-1.5 md:py-1", + )} + > + Clear sort + + + ) : null} ); @@ -470,27 +501,48 @@ export function ResourceCreateButton({ templateGroups, menuActions = [], onCreate, + compactWhenNarrow = false, }: { label: string; templates: readonly ResourceCreateTemplate[]; templateGroups?: readonly ResourceCreateTemplateGroup[]; menuActions?: readonly ResourceCreateMenuAction[]; onCreate: (prompt?: string) => void; + compactWhenNarrow?: boolean; }) { const groups: readonly ResourceCreateTemplateGroup[] = templateGroups ?? [ { label: "Examples", templates }, ]; return (
- + + + + + {compactWhenNarrow ? ( + + {label} + + ) : null} + - - - {`Category: ${accessibleSelectionLabel}`} - + + + + + + + + {`Category: ${accessibleSelectionLabel}`} + + onCreate()} + > + + + {label} + + + ); return (
- - - - - {compactWhenNarrow ? ( - - {label} - - ) : null} - + {compactWhenNarrow ? ( + + + {createButton} + {label} + + + ) : ( + createButton + )} - - + + Browse plugins + + {selectedShelf === undefined ? null : ( +

+ {selectedShelf.label} + + {selectedShelf.entries.length.toLocaleString()}{" "} + {selectedShelf.entries.length === 1 ? "plugin" : "plugins"} + +

+ )} +
+ ) : ( + <> +
+
- - - - - Install from source - - - -
- -
+ + + + + + + + Install from source + + + +
+ +
- + + + )} - {composing ? ( + {composing && shelfKey === null ? ( ) : (
@@ -208,46 +273,47 @@ export function BrowsePluginsTab({ changeSearchParams={changeSearchParams} /> - {searchQuery.isError && entries.length > 0 ? ( + {(searchQuery.isError || activeQuery.isError) && + entries.length > 0 ? (

The latest search failed. The page shows saved catalog results.

) : null} - {searchQuery.isPending ? ( + {activeQuery.isPending || + (shelfKey !== null && + debouncedQuery !== "" && + searchQuery.isPending) ? ( + ) : activeQuery.isError && entries.length === 0 ? ( + void activeQuery.refetch()} + /> + ) : shelfKey !== null && selectedShelf === undefined ? ( + ) : entries.length === 0 ? ( { - void searchQuery.refetch(); - } - : undefined - } + state="empty" + message="No plugins match this search." + /> + ) : searchQuery.isError && searchQuery.data === undefined ? ( + void searchQuery.refetch()} /> ) : filteredEntries.length === 0 ? ( - ) : sort === null ? ( + ) : sort === null && shelfKey === null ? (
{shelves.map((shelf) => ( - setExpandedShelves((current) => - new Set(current).add(shelf.key), - ) - } onInstall={onInstall} onOpenPlugin={onOpenPlugin} /> @@ -323,24 +389,22 @@ export function pluginCategoryFilterOptions( function BrowseShelf({ shelf, - expanded, - onExpand, onInstall, onOpenPlugin, }: { shelf: PluginBrowseShelf; - expanded: boolean; - onExpand: () => void; onInstall: (initial: AddPluginInitial) => void; onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; }) { - const visible = expanded - ? shelf.entries - : shelf.entries.slice(0, SHELF_ENTRY_LIMIT); + const [searchParams] = useSearchParams(); + const shelfParams = new URLSearchParams(searchParams); + shelfParams.set("shelf", shelf.key); + const visible = shelf.entries.slice(0, SHELF_ENTRY_LIMIT); return ( 2 ? ( - + shelf.entries.length > 2 ? ( + + + See all + + ) : undefined } >
*:nth-child(n+3)]:hidden", - )} + className="grid gap-2 max-sm:[&>*:nth-child(n+3)]:hidden" > {visible.map((entry) => ( Plugin Guide - - + ); } diff --git a/packages/shared-ui/src/components/ui/resource/collection.tsx b/packages/shared-ui/src/components/ui/resource/collection.tsx index aa39fcb18f0..ac7b3b5ee2d 100644 --- a/packages/shared-ui/src/components/ui/resource/collection.tsx +++ b/packages/shared-ui/src/components/ui/resource/collection.tsx @@ -212,12 +212,14 @@ export function ResourceBrowseGrid({ export function ResourceSourceShelf({ label, description, + hideDescriptionOnMobile = false, leading, browseAction, children, }: { label: ReactNode; description?: ReactNode; + hideDescriptionOnMobile?: boolean; leading?: ReactNode; browseAction?: ReactNode; children: ReactNode; @@ -231,14 +233,25 @@ export function ResourceSourceShelf({ {label}
- {browseAction && description === undefined ? ( -
+ {browseAction && + (description === undefined || hideDescriptionOnMobile) ? ( +
{browseAction}
) : null}
{description === undefined ? null : ( -
+

{description}

From 6a467a3526e83d2a66e28ccf2bccda5360adf639 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:50:52 +0000 Subject: [PATCH 14/79] Export the shared shelf navigation action --- packages/shared-ui/src/components/ui/resource-list.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared-ui/src/components/ui/resource-list.tsx b/packages/shared-ui/src/components/ui/resource-list.tsx index 26383639042..bd76b2384d4 100644 --- a/packages/shared-ui/src/components/ui/resource-list.tsx +++ b/packages/shared-ui/src/components/ui/resource-list.tsx @@ -50,6 +50,7 @@ export { ResourceCollectionPage, ResourceCollectionViewport, ResourceShelfSeeAllAction, + ResourceShelfAction, ResourceSourceShelf, ResourceTemplateBrowseCard, } from "./resource/collection"; From e04f7c7e75d566b75fc794a6b6342345b2134d00 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:56:38 +0000 Subject: [PATCH 15/79] Stabilize shelf entries and separate the heading count --- .../src/components/plugin/management/BrowsePluginsTab.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 6bd7350f60b..b8dece3f39b 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -108,8 +108,10 @@ export function BrowsePluginsTab({ [catalog.collections, entries, shelfKey], ); useResourceRouteLabel(selectedShelf?.label ?? null); - const shelfEntries = - shelfKey === null ? entries : (selectedShelf?.entries ?? []); + const shelfEntries = useMemo( + () => (shelfKey === null ? entries : (selectedShelf?.entries ?? [])), + [entries, selectedShelf, shelfKey], + ); const installsKnown = shelfEntries.some((entry) => entry.installs !== null); const sort = requestedSort === "most-installed" && !installsKnown ? null : requestedSort; @@ -207,7 +209,7 @@ export function BrowsePluginsTab({ {selectedShelf === undefined ? null : (

- {selectedShelf.label} + {selectedShelf.label}{" "} {selectedShelf.entries.length.toLocaleString()}{" "} {selectedShelf.entries.length === 1 ? "plugin" : "plugins"} From 93fc6393ee0d8e4de57cdd98ec475e85f9ef9005 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:58:56 +0000 Subject: [PATCH 16/79] Constrain shelf pages to the mobile viewport --- apps/app/src/components/plugin/management/BrowsePluginsTab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index b8dece3f39b..bcfc1669bae 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -196,6 +196,7 @@ export function BrowsePluginsTab({
{shelfKey !== null ? ( From f26c36b5452e1fa406c0e81ca44abc531b92a165 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:15:41 +0000 Subject: [PATCH 17/79] Show category identity once on plugin category pages --- .../plugin/management/BrowsePluginsTab.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index bcfc1669bae..d4be725a81a 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -210,7 +210,18 @@ export function BrowsePluginsTab({ {selectedShelf === undefined ? null : (

- {selectedShelf.label}{" "} + + {selectedShelf.key.startsWith("category:") ? ( + + ) : null} + {selectedShelf.label} + {" "} {selectedShelf.entries.length.toLocaleString()}{" "} {selectedShelf.entries.length === 1 ? "plugin" : "plugins"} @@ -325,6 +336,7 @@ export function BrowsePluginsTab({ ) : ( @@ -469,10 +481,12 @@ function BrowseShelf({ export function PluginCatalogGrid({ entries, + showCategory = true, onInstall, onOpenPlugin, }: { entries: readonly PluginCatalogSearchEntry[]; + showCategory?: boolean; onInstall: (initial: AddPluginInitial) => void; onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; }) { @@ -482,7 +496,7 @@ export function PluginCatalogGrid({ From dc1163b49d0c16ba7f062c84cbb99e1685f079dd Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:25:35 +0000 Subject: [PATCH 18/79] Enable Plugin Guide when opening it from Browse --- .../management/BrowsePluginsTab.test.tsx | 9 - .../management/OpenPluginGuideButton.test.tsx | 168 ++++++++++++++++++ .../management/OpenPluginGuideButton.tsx | 65 +++++-- 3 files changed, 223 insertions(+), 19 deletions(-) create mode 100644 apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index f2a7a9ef723..ae7efefca1e 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -466,15 +466,6 @@ describe("BrowsePluginsTab", () => { ).toBe("/plugins"); }); - it("opens Plugin Guide through the app router", async () => { - renderBrowse({ entries: [MEMORY_ENTRY], collections: [] }); - const guide = await screen.findByRole("link", { name: "Plugin Guide" }); - expect(guide.getAttribute("target")).toBeNull(); - expect(guide.getAttribute("href")).toBe( - "/plugins/plugin-api-docs/plugin-api", - ); - }); - it("uses the shared error state and retries catalog searches", async () => { let searchAttempts = 0; vi.stubGlobal( diff --git a/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx b/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx new file mode 100644 index 00000000000..3779fece29e --- /dev/null +++ b/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { InstalledPlugin } from "@bb/server-contract"; +import { appToast } from "@/components/ui/app-toast"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; + +const GUIDE: InstalledPlugin = { + id: "plugin-api-docs", + source: "builtin:plugin-api-docs", + rootDir: "/plugins/plugin-api-docs", + version: "1.0.0", + provenance: "builtin", + publisherLabel: "BB Official", + isOrphanedBuiltin: false, + sourceDisplay: "Included with BB", + updateState: {}, + enabled: false, + description: "Explore the plugin API", + name: "Plugin Guide", + screenshots: [], + collections: [], + icon: null, + iconUrl: null, + status: "disabled", + statusDetail: null, + handlerStats: { count: 0, totalMs: 0, maxMs: 0, errorCount: 0 }, + services: [], + schedules: [], + cliCommand: null, + capabilities: [], + hasSettings: false, + app: { hasApp: false, bundle: null }, + logoUrl: null, + logoDarkUrl: null, + providerIds: [], + icons: {}, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function LocationProbe() { + return {useLocation().pathname}; +} + +function renderGuide() { + const { wrapper } = createQueryClientTestHarness(); + render( + + + + , + { wrapper }, + ); + return screen.getByRole("button", { name: "Plugin Guide" }); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("OpenPluginGuideButton", () => { + it("enables a disabled Guide before navigating and prevents duplicate requests", async () => { + const enabled = { ...GUIDE, enabled: true, status: "running" }; + let finishEnable: (response: Response) => void = () => undefined; + const enableResponse = new Promise((resolve) => { + finishEnable = resolve; + }); + const fetchMock = vi.fn((input: RequestInfo | URL) => + String(input).endsWith("/enable") + ? enableResponse + : Promise.resolve(jsonResponse({ plugins: [GUIDE] })), + ); + vi.stubGlobal("fetch", fetchMock); + const button = renderGuide(); + fireEvent.click(button); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "/api/v1/plugins/plugin-api-docs/enable", + ); + expect(button.hasAttribute("disabled")).toBe(true); + expect(screen.getByTestId("location").textContent).toBe("/plugins"); + fireEvent.click(button); + expect(fetchMock).toHaveBeenCalledTimes(2); + + finishEnable(jsonResponse({ ok: true, plugin: enabled })); + await vi.waitFor(() => + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/plugin-api-docs/plugin-api", + ), + ); + }); + + it("opens an enabled Guide without enabling or reloading it", async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + plugins: [{ ...GUIDE, enabled: true, status: "running" }], + }), + ); + vi.stubGlobal("fetch", fetchMock); + fireEvent.click(renderGuide()); + await vi.waitFor(() => + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/plugin-api-docs/plugin-api", + ), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("stays on Browse after an enable failure and allows retry", async () => { + const toast = vi.spyOn(appToast, "error").mockReturnValue("toast"); + let attempts = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (!String(input).endsWith("/enable")) { + return jsonResponse({ plugins: [GUIDE] }); + } + attempts += 1; + return attempts === 1 + ? jsonResponse({ error: "Service unavailable" }, 503) + : jsonResponse({ + ok: true, + plugin: { ...GUIDE, enabled: true, status: "running" }, + }); + }), + ); + const button = renderGuide(); + fireEvent.click(button); + await vi.waitFor(() => + expect(toast).toHaveBeenCalledWith("Could not open Plugin Guide", { + description: "Service unavailable", + }), + ); + expect(screen.getByTestId("location").textContent).toBe("/plugins"); + await vi.waitFor(() => expect(button.hasAttribute("disabled")).toBe(false)); + fireEvent.click(button); + await vi.waitFor(() => + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/plugin-api-docs/plugin-api", + ), + ); + expect(attempts).toBe(2); + }); + + it("opens the listing when Guide is absent without attempting to enable it", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ plugins: [] })); + vi.stubGlobal("fetch", fetchMock); + fireEvent.click(renderGuide()); + await vi.waitFor(() => + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/plugin-api-docs", + ), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx b/apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx index 477ce554a3a..a5524bda86f 100644 --- a/apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx +++ b/apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx @@ -1,19 +1,64 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Icon } from "@bb/shared-ui/icon"; -import { buttonVariants } from "@bb/shared-ui/button"; +import { Button } from "@bb/shared-ui/button"; import { cn } from "@bb/shared-ui/lib/utils"; -import { Link } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { appToast } from "@/components/ui/app-toast"; +import { invalidatePluginList } from "@/hooks/cache-owners/plugin-cache-owner"; +import { + pluginListQueryOptions, + setPluginEnabled, +} from "@/hooks/queries/plugin-settings-queries"; +import { pluginAdminErrorMessage } from "@/lib/plugin-admin-error"; +import { getPluginDetailRoutePath } from "@/lib/route-paths"; + +const PLUGIN_GUIDE_ID = "plugin-api-docs"; export function OpenPluginGuideButton() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const open = useMutation({ + meta: { showErrorToast: false }, + mutationFn: async () => { + const plugins = await queryClient.fetchQuery({ + ...pluginListQueryOptions({ enabled: true }), + staleTime: 0, + }); + const guide = plugins.find((plugin) => plugin.id === PLUGIN_GUIDE_ID); + if (guide === undefined) { + return getPluginDetailRoutePath({ pluginId: PLUGIN_GUIDE_ID }); + } + if (!guide.enabled) { + await setPluginEnabled(fetch, PLUGIN_GUIDE_ID, true); + await invalidatePluginList({ queryClient }); + } + return `/plugins/${PLUGIN_GUIDE_ID}/plugin-api`; + }, + onError: (error) => { + appToast.error("Could not open Plugin Guide", { + description: pluginAdminErrorMessage(error), + }); + }, + }); + return ( - + open.mutate(undefined, { onSuccess: (path) => navigate(path) }) + } > - + Plugin Guide - + ); } From 37b7b7c0d11d68062ef281613ae5b4038632f76e Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:32:40 +0000 Subject: [PATCH 19/79] Open the install dialog when Plugin Guide is missing --- .../management/OpenPluginGuideButton.test.tsx | 83 ++++++++++++++++++- .../management/OpenPluginGuideButton.tsx | 71 +++++++++++----- 2 files changed, 130 insertions(+), 24 deletions(-) diff --git a/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx b/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx index 3779fece29e..035c689df58 100644 --- a/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx +++ b/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { InstalledPlugin } from "@bb/server-contract"; import { appToast } from "@/components/ui/app-toast"; +import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; @@ -40,6 +41,30 @@ const GUIDE: InstalledPlugin = { icons: {}, }; +const GUIDE_ENTRY: PluginCatalogSearchEntry = { + entryId: GUIDE.id, + pluginId: GUIDE.id, + displayName: "Plugin Guide", + description: "Explore the plugin API", + source: GUIDE.source, + marketplace: "bb-official", + marketplaceDisplayName: "BB Official", + publisherKey: "bb-official", + publisherLabel: "BB Official", + official: true, + author: { name: "BB", github: "get-bb", url: "https://github.com/get-bb" }, + icon: null, + iconUrl: null, + iconTinted: false, + screenshots: [], + collections: [], + repositoryUrl: null, + installed: false, + installs: null, + compatible: true, + incompatibleReason: null, +}; + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -154,15 +179,65 @@ describe("OpenPluginGuideButton", () => { expect(attempts).toBe(2); }); - it("opens the listing when Guide is absent without attempting to enable it", async () => { - const fetchMock = vi.fn(async () => jsonResponse({ plugins: [] })); + it("offers the existing install dialog when Guide is absent and cancels without a write", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => + String(input).startsWith("/api/v1/plugin-catalog/search") + ? jsonResponse({ results: [GUIDE_ENTRY], collections: [] }) + : jsonResponse({ plugins: [] }), + ); + vi.stubGlobal("fetch", fetchMock); + fireEvent.click(renderGuide()); + expect( + await screen.findByRole("dialog", { name: "Install Plugin Guide?" }), + ).toBeTruthy(); + expect(screen.getByTestId("location").textContent).toBe("/plugins"); + expect(fetchMock).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByTestId("location").textContent).toBe("/plugins"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("installs the official listing after confirmation and opens Guide after success", async () => { + let finishInstall: (response: Response) => void = () => undefined; + const installResponse = new Promise((resolve) => { + finishInstall = resolve; + }); + const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input); + if (url === "/api/v1/plugin-catalog/install") return installResponse; + return Promise.resolve( + url.startsWith("/api/v1/plugin-catalog/search") + ? jsonResponse({ results: [GUIDE_ENTRY], collections: [] }) + : jsonResponse({ plugins: [] }), + ); + }); vi.stubGlobal("fetch", fetchMock); fireEvent.click(renderGuide()); + const install = await screen.findByRole("button", { + name: "Install Plugin Guide", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + fireEvent.click(install); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + expect(fetchMock.mock.calls[2]?.[0]).toBe("/api/v1/plugin-catalog/install"); + expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); + expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ + entryId: "plugin-api-docs", + marketplace: "bb-official", + }); + expect(screen.getByTestId("location").textContent).toBe("/plugins"); + finishInstall( + jsonResponse({ + ok: true, + plugin: { ...GUIDE, enabled: true, status: "running" }, + }), + ); await vi.waitFor(() => expect(screen.getByTestId("location").textContent).toBe( - "/plugins/plugin-api-docs", + "/plugins/plugin-api-docs/plugin-api", ), ); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).toBeNull(); }); }); diff --git a/apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx b/apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx index a5524bda86f..12fb1355915 100644 --- a/apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx +++ b/apps/app/src/components/plugin/management/OpenPluginGuideButton.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Icon } from "@bb/shared-ui/icon"; import { Button } from "@bb/shared-ui/button"; @@ -5,18 +6,23 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { useNavigate } from "react-router-dom"; import { appToast } from "@/components/ui/app-toast"; import { invalidatePluginList } from "@/hooks/cache-owners/plugin-cache-owner"; +import { searchPluginCatalog } from "@/hooks/queries/plugin-catalog-queries"; import { pluginListQueryOptions, setPluginEnabled, } from "@/hooks/queries/plugin-settings-queries"; import { pluginAdminErrorMessage } from "@/lib/plugin-admin-error"; -import { getPluginDetailRoutePath } from "@/lib/route-paths"; +import { AddPluginDialog, type AddPluginInitial } from "./AddPluginDialog"; const PLUGIN_GUIDE_ID = "plugin-api-docs"; +const PLUGIN_GUIDE_PATH = `/plugins/${PLUGIN_GUIDE_ID}/plugin-api`; export function OpenPluginGuideButton() { const queryClient = useQueryClient(); const navigate = useNavigate(); + const [installTarget, setInstallTarget] = useState( + null, + ); const open = useMutation({ meta: { showErrorToast: false }, mutationFn: async () => { @@ -26,13 +32,23 @@ export function OpenPluginGuideButton() { }); const guide = plugins.find((plugin) => plugin.id === PLUGIN_GUIDE_ID); if (guide === undefined) { - return getPluginDetailRoutePath({ pluginId: PLUGIN_GUIDE_ID }); + const catalog = await searchPluginCatalog(fetch, PLUGIN_GUIDE_ID); + const entry = catalog.entries.find( + (candidate) => + candidate.pluginId === PLUGIN_GUIDE_ID && + candidate.source === `builtin:${PLUGIN_GUIDE_ID}` && + candidate.official, + ); + if (entry === undefined) { + throw new Error("Plugin Guide is unavailable in the catalog."); + } + return entry; } if (!guide.enabled) { await setPluginEnabled(fetch, PLUGIN_GUIDE_ID, true); await invalidatePluginList({ queryClient }); } - return `/plugins/${PLUGIN_GUIDE_ID}/plugin-api`; + return null; }, onError: (error) => { appToast.error("Could not open Plugin Guide", { @@ -42,23 +58,38 @@ export function OpenPluginGuideButton() { }); return ( - + { + if (!isOpen) setInstallTarget(null); + }} + onInstalled={() => navigate(PLUGIN_GUIDE_PATH)} /> - Plugin Guide - + ); } From 7bfaedaedb478577d1f2e74c7bb945af10e3d857 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:37:58 +0000 Subject: [PATCH 20/79] Allow the catalog installer to restore included plugins --- .../src/services/plugins/plugin-service.ts | 4 +-- .../services/plugins/official-plugins.test.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index bf8c863dd7a..734c077d1d9 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -1395,9 +1395,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { async installOfficialPlugin(name) { return withPluginOperationLock(REGISTRATION_MUTATION_KEY, async () => { - const bundled = bundledPlugins.find( - (plugin) => plugin.name === name && !plugin.autoInstall, - ); + const bundled = bundledPlugins.find((plugin) => plugin.name === name); if (bundled === undefined) { throw new Error(`unknown official plugin "${name}"`); } diff --git a/apps/server/test/services/plugins/official-plugins.test.ts b/apps/server/test/services/plugins/official-plugins.test.ts index 4330758b7b3..a7c24dacff1 100644 --- a/apps/server/test/services/plugins/official-plugins.test.ts +++ b/apps/server/test/services/plugins/official-plugins.test.ts @@ -294,6 +294,34 @@ describe("store-installed official plugins", () => { ]); }); + it("explicitly installs a missing included plugin with its builtin provenance", async () => { + service = createService({ + db, + dataDir: join(workDir, "data"), + bundled: [officialEntry({ autoInstall: true, defaultEnabled: false })], + }); + expect(service.list()).toEqual([]); + + await expect( + service.installOfficialPlugin("fixture"), + ).resolves.toMatchObject({ + id: "builtin-fixture", + source: "builtin:fixture", + provenance: "builtin", + enabled: true, + status: "running", + }); + await service.start(); + expect(service.list()).toMatchObject([ + { + id: "builtin-fixture", + provenance: "builtin", + enabled: true, + status: "running", + }, + ]); + }); + it("rejects unknown official plugin names", async () => { service = createService({ db, From c820311231f425bd83e97d3c4c52bdbf2b9a4719 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:02:56 +0000 Subject: [PATCH 21/79] Align Browse Guide and Create actions in one row --- .../src/components/plugin/management/BrowsePluginsTab.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index d4be725a81a..7ef9ea16143 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -231,8 +231,9 @@ export function BrowsePluginsTab({

) : ( <> -
-
+
+ +
@@ -263,7 +264,6 @@ export function BrowsePluginsTab({
-
Date: Tue, 15 Sep 2026 21:04:13 +0000 Subject: [PATCH 22/79] Keep responsive Create label spacing consistent --- .../app/src/components/plugin/management/BrowsePluginsTab.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 7ef9ea16143..36d5b96722d 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -245,7 +245,9 @@ export function BrowsePluginsTab({ }} > - Create a plugin + + Create a plugin + From 3fcbaa077ef2963b36d20bdd86cd6f16e491eac9 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:09:21 +0000 Subject: [PATCH 23/79] Preserve word boundaries in the responsive Create label --- apps/app/src/components/plugin/management/BrowsePluginsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 36d5b96722d..388375981b6 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -246,7 +246,7 @@ export function BrowsePluginsTab({ > - Create a plugin + Create a plugin From 5aa14580f0d13b6348cd5c1812c151258d47c38e Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:27:50 +0000 Subject: [PATCH 24/79] Align card identity fixtures with current marketplace metadata --- .../management/installed-plugin-catalog.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts b/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts index 5522510fe5a..9f4921bc0c7 100644 --- a/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts +++ b/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts @@ -34,7 +34,12 @@ describe("installed plugin catalog identity", () => { ]) { expect( installedPluginCatalogEntry( - { id: "notes", catalogEntryId: null, source }, + { + id: "notes", + catalogEntryId: null, + catalogMarketplaceName: null, + source, + }, [official, community], ), ).toBeUndefined(); @@ -43,7 +48,12 @@ describe("installed plugin catalog identity", () => { it("matches a bundled plugin by its source without marketplace installation metadata", () => { expect( installedPluginCatalogEntry( - { id: "notes", catalogEntryId: null, source: "builtin:notes" }, + { + id: "notes", + catalogEntryId: null, + catalogMarketplaceName: null, + source: "builtin:notes", + }, [community, official], ), ).toBe(official); From 4860e84ac03dee5e93d230abcb81ca635595b4d7 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:31:32 +0000 Subject: [PATCH 25/79] Preserve bundled catalog matching with nullable marketplace metadata --- .../components/plugin/management/installed-plugin-catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/installed-plugin-catalog.ts b/apps/app/src/components/plugin/management/installed-plugin-catalog.ts index 966a4094cf2..4d4be632074 100644 --- a/apps/app/src/components/plugin/management/installed-plugin-catalog.ts +++ b/apps/app/src/components/plugin/management/installed-plugin-catalog.ts @@ -19,7 +19,7 @@ export function installedPluginCatalogEntry< entry.pluginId === plugin.id && (plugin.catalogEntryId === null || entry.entryId === plugin.catalogEntryId) && - (plugin.catalogMarketplaceName === undefined + (plugin.catalogMarketplaceName === null ? entry.source === plugin.source : entry.marketplace === plugin.catalogMarketplaceName), ); From 1b26d27a60999539db0bd5ccb8f4577a77552f9a Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:16:33 +0000 Subject: [PATCH 26/79] Group Installed plugins into category shelves --- .../plugin/PluginsOverview.test.tsx | 244 ++++++------ .../src/components/plugin/PluginsOverview.tsx | 171 +-------- .../plugin/management/BrowsePluginsTab.tsx | 96 ++--- .../management/InstalledPluginsCollection.tsx | 356 ++++++++++++++++++ .../plugin/management/InstalledPluginsTab.tsx | 43 ++- .../plugin/management/PluginShelf.tsx | 80 ++++ .../management/plugin-browse-discovery.ts | 23 +- 7 files changed, 647 insertions(+), 366 deletions(-) create mode 100644 apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx create mode 100644 apps/app/src/components/plugin/management/PluginShelf.tsx diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 231f5c0c5ad..e8e22338225 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -7,6 +7,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { MemoryRouter, @@ -189,6 +190,10 @@ function LocationPath() { return {useLocation().pathname}; } +function LocationSearch() { + return {useLocation().search}; +} + afterEach(() => { focusManager.setFocused(undefined); cleanup(); @@ -339,7 +344,7 @@ describe("PluginsOverview", () => { expect(screen.getByTestId("location-path").textContent).toBe("/"); }); - it("shows the Type filter on Installed instead of Category", async () => { + it("offers category filtering on Installed using the same control as Browse", async () => { installFetch([AUTOMATIONS_PLUGIN]); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( @@ -353,8 +358,12 @@ describe("PluginsOverview", () => { ); expect(await screen.findByText("Automations")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Category" })).toBeNull(); - expect(screen.getByRole("button", { name: "Type" })).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "Filter plugins by category: All categories", + }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Type" })).toBeNull(); expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy(); }); @@ -488,7 +497,11 @@ describe("PluginsOverview", () => { installFetch(plugins); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( - + @@ -614,150 +627,153 @@ describe("PluginsOverview", () => { (row) => row.getAttribute("data-testid"), ), ).toEqual([ - "plugin-row-enabled-official-zulu", "plugin-row-enabled-official-alpha", + "plugin-row-enabled-official-zulu", "plugin-row-enabled-local-alpha", - "plugin-row-inactive-official", "plugin-row-inactive-local", + "plugin-row-inactive-official", ]); }); - it("gives each publisher its own Type facet, separate from User", async () => { + it("groups only installed plugins and keeps unmatched local sources uncategorized", async () => { installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, + AUTOMATIONS_PLUGIN, { ...AUTOMATIONS_PLUGIN, - id: "catalog-one", - name: "Catalog One", - provenance: "catalog", - publisherKey: "bb-community", - publisherLabel: "BB Community", - catalogEntryId: "catalog-one", + id: "simple-notes", + source: "builtin:docs", + name: "Docs", }, { ...AUTOMATIONS_PLUGIN, - id: "direct-one", - name: "Direct One", + id: "github", + source: "path:/qa/github", + name: "Local GitHub", provenance: "direct", publisherLabel: null, }, ]); - const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + const { wrapper } = createQueryClientTestHarness(); render( - - - - - + , + { wrapper }, ); - await screen.findByText("Direct One"); - const rowIds = () => - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ); - - const typeTrigger = screen.getByRole("button", { name: "Type" }); - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - "plugin-row-direct-one", - ]); - fireEvent.pointerDown(typeTrigger); - expect(screen.queryByRole("menuitemcheckbox", { name: "All" })).toBeNull(); - - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Official" }), + const shelves = await screen.findByTestId("plugin-installed-shelves"); + await waitFor(() => + expect( + within(shelves) + .getAllByRole("heading") + .map((heading) => heading.textContent), + ).toEqual(["Memory & Context", "Tasks & Workflows", "More plugins"]), ); - await waitFor(() => { - expect(rowIds()).toEqual(["plugin-row-builtin-one"]); - }); + expect( + screen.queryByRole("heading", { name: "Code & Reviews" }), + ).toBeNull(); + expect(screen.getAllByTestId("plugin-row-github")).toHaveLength(1); + expect(screen.queryByText("Workflow management")).toBeNull(); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Community" }), + screen.getByRole("button", { + name: "Filter plugins by category: All categories", + }), ); - await waitFor(() => { - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - ]); - }); + expect( + await screen.findByRole("option", { name: /Tasks & Workflows.*1/u }), + ).toBeTruthy(); + expect( + screen.queryByRole("option", { name: /Code & Reviews/u }), + ).toBeNull(); + fireEvent.click(screen.getByRole("option", { name: /Tasks & Workflows/u })); + expect(screen.queryByText("Local GitHub")).toBeNull(); + expect(screen.queryByText("Docs")).toBeNull(); + expect(screen.getByText("Automations")).toBeTruthy(); + }); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Official" }), - ); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Community" }), + it("opens the full installed shelf and restores the original query and scroll after shelf edits", async () => { + const plugins = Array.from({ length: 14 }, (_, index) => ({ + ...AUTOMATIONS_PLUGIN, + id: `local-${index + 1}`, + source: `path:/qa/local-${index + 1}`, + name: `Local ${String(index + 1).padStart(2, "0")}`, + })); + installFetch(plugins); + const { wrapper } = createQueryClientTestHarness(); + render( + + + + , + { wrapper }, ); - await waitFor(() => { - expect(rowIds()).toEqual(["plugin-row-direct-one"]); - }); - - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); - await waitFor(() => { - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - "plugin-row-direct-one", - ]); + await screen.findByText("Local 01"); + expect(screen.getByText("Local 06")).toBeTruthy(); + expect(screen.queryByText("Local 07")).toBeNull(); + const viewport = document.getElementById("plugins-installed-results"); + if (viewport === null) throw new Error("Missing Installed viewport"); + viewport.scrollTop = 240; + fireEvent.click(screen.getByRole("link", { name: "See all More plugins" })); + expect( + await screen.findByRole("heading", { name: "More plugins 14 plugins" }), + ).toBeTruthy(); + expect(screen.getByText("Local 12")).toBeTruthy(); + expect( + new URLSearchParams( + screen.getByTestId("location-search").textContent ?? "", + ).get("view"), + ).toBe("installed"); + const search = screen.getByRole("textbox", { + name: "Search installed plugins", }); - expect(screen.queryByText("No plugins match these filters.")).toBeNull(); + fireEvent.change(search, { target: { value: "Local 14" } }); + expect(screen.getByText("Local 14")).toBeTruthy(); + expect(screen.queryByText("Local 01")).toBeNull(); + fireEvent.change(search, { target: { value: "zz-no-match" } }); + expect(screen.getByText('No plugins match "zz-no-match"')).toBeTruthy(); + fireEvent.click(screen.getByRole("link", { name: "Installed plugins" })); + expect( + ( + screen.getByRole("textbox", { + name: "Search installed plugins", + }) as HTMLInputElement + ).value, + ).toBe("Local"); + expect(screen.getByTestId("location-search").textContent).toBe( + "?view=installed&query=Local&category=uncategorized", + ); + expect( + document.getElementById("plugins-installed-results")?.scrollTop, + ).toBe(240); + expect(screen.getByText("Local 06")).toBeTruthy(); + expect(screen.queryByText("Local 07")).toBeNull(); }); - it("drops a Type selection whose facet no longer has any plugin", async () => { - installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, - { - ...AUTOMATIONS_PLUGIN, - id: "acme-one", - name: "Acme One", - provenance: "catalog", - publisherKey: "acme-plugins", - publisherLabel: "Acme Plugins", - catalogEntryId: "acme-one", - }, - ]); - const { wrapper: QueryClientWrapper, queryClient } = - createQueryClientTestHarness(); + it("keeps a directly opened empty category in Installed with a way back", async () => { + installFetch(); + const { wrapper } = createQueryClientTestHarness(); render( - - - - + + + , + { wrapper }, ); - - await screen.findByText("Acme One"); - fireEvent.pointerDown(screen.getByRole("button", { name: "Type" })); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Acme Plugins" }), + expect( + await screen.findByText("No installed plugins in this category."), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("link", { name: "Installed plugins" })); + expect(screen.getByTestId("location-search").textContent).toBe( + "?view=installed", ); - await waitFor(() => { - expect( - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ), - ).toEqual(["plugin-row-acme-one"]); - }); - - installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, - ]); - await act(async () => { - await queryClient.invalidateQueries(); - }); - - await waitFor(() => { - expect( - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ), - ).toEqual(["plugin-row-builtin-one"]); - }); - expect(screen.queryByText("No plugins match these filters.")).toBeNull(); + expect(await screen.findByText("Automations")).toBeTruthy(); }); it("keeps disabled plugins installed regardless of provenance", async () => { diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index f40c6e2a697..2cdfff0de39 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -1,19 +1,6 @@ -import { useMemo, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { - ResourceInfiniteScrollSentinel, - useResourceInfiniteItems, - RESOURCE_GRID_PAGE_SIZE, -} from "@bb/shared-ui/resource-pagination"; -import { - ResourceCollectionPage, - ResourceCollectionViewport, - ResourceListState, - ResourceMultiSelectMenu, - ResourceSortMenu, - ResourceToolbar, -} from "@bb/shared-ui/resource-list"; -import { cn } from "@bb/shared-ui/lib/utils"; +import { ResourceCollectionPage } from "@bb/shared-ui/resource-list"; import { CreateWithTemplatesButton } from "@/components/create-via-prompt-examples"; import { CREATE_PLUGIN_PROMPT } from "@bb/client-core"; import { TOOLS_PAGE_BAND_CLASSES } from "@/components/tools/tools-navigation"; @@ -22,15 +9,9 @@ import { type AddPluginInitial, } from "@/components/plugin/management/AddPluginDialog"; import { BrowsePluginsTab } from "@/components/plugin/management/BrowsePluginsTab"; -import { CheckPluginUpdatesButton } from "@/components/plugin/management/CheckPluginUpdatesButton"; -import { InstalledPluginsTab } from "@/components/plugin/management/InstalledPluginsTab"; +import { InstalledPluginsCollection } from "@/components/plugin/management/InstalledPluginsCollection"; import { PluginAuthorPage } from "@/components/plugin/management/PluginAuthorPage"; -import { - pluginPublisherFilterId, - pluginPublisherFilterOptions, -} from "@/components/plugin/plugin-provenance"; import { PLUGINS_INSTALLED_DESCRIPTION } from "@/components/plugin/plugins-collection-copy"; -import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; import { getPluginDetailRoutePath, getRootComposeRoutePath, @@ -45,90 +26,14 @@ export function PluginsOverview({ } = {}) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const listQuery = usePluginList({ enabled: true }); - const plugins = useMemo( - () => listQuery.data?.plugins ?? [], - [listQuery.data?.plugins], - ); const activeMode = mode ?? (searchParams.get("view") === "installed" ? "installed" : "browse"); const authorKey = searchParams.get("author"); - const [installedQuery, setInstalledQuery] = useState(""); - const [installedSortDirection, setInstalledSortDirection] = useState< - "asc" | "desc" - >("asc"); - const [typeFilters, setTypeFilters] = useState([]); - const typeFilterOptions = useMemo( - () => pluginPublisherFilterOptions(plugins), - [plugins], - ); - const activeTypeFilters = useMemo(() => { - const offered = new Set(typeFilterOptions.map((option) => option.id)); - return typeFilters.filter((value) => offered.has(value)); - }, [typeFilterOptions, typeFilters]); - const normalizedInstalledQuery = installedQuery.trim().toLowerCase(); - const installedResetKey = [ - normalizedInstalledQuery, - installedSortDirection, - [...activeTypeFilters].sort().join(","), - ].join("\u0000"); const [addDialog, setAddDialog] = useState<{ open: boolean; initial: AddPluginInitial | null; }>({ open: false, initial: null }); - const visiblePlugins = useMemo( - () => - plugins - .filter((plugin) => { - if ( - activeTypeFilters.length > 0 && - !activeTypeFilters.includes(pluginPublisherFilterId(plugin)) - ) { - return false; - } - if (normalizedInstalledQuery.length === 0) return true; - return [ - plugin.id, - plugin.name ?? "", - plugin.description ?? "", - plugin.version, - plugin.sourceDisplay, - ] - .join(" ") - .toLowerCase() - .includes(normalizedInstalledQuery); - }) - .sort((left, right) => { - const enabledResult = Number(!left.enabled) - Number(!right.enabled); - if (enabledResult !== 0) return enabledResult; - if (left.enabled) { - const leftPublisher = left.publisherLabel; - const rightPublisher = right.publisherLabel; - const publisherResult = - Number(leftPublisher === null) - Number(rightPublisher === null); - if (publisherResult !== 0) return publisherResult; - } - const result = (left.name ?? left.id).localeCompare( - right.name ?? right.id, - ); - if (result !== 0) { - return installedSortDirection === "asc" ? result : -result; - } - return left.id.localeCompare(right.id); - }), - [ - activeTypeFilters, - installedSortDirection, - normalizedInstalledQuery, - plugins, - ], - ); - const installedList = useResourceInfiniteItems(visiblePlugins, { - pageSize: RESOURCE_GRID_PAGE_SIZE, - resetKey: installedResetKey, - }); - const startCreatePlugin = (prompt?: string) => { navigate(getRootComposeRoutePath(), { state: { @@ -178,75 +83,7 @@ export function PluginsOverview({ /> ); } else { - content = ( - - - - setInstalledSortDirection((current) => - current === "asc" ? "desc" : "asc", - ) - } - /> - {plugins.length > 0 ? : null} - - } - /> - } - > -
- {listQuery.isError ? ( - void listQuery.refetch()} - /> - ) : listQuery.isFetching && listQuery.data === undefined ? ( - - ) : plugins.length > 0 && visiblePlugins.length === 0 ? ( - 0 - ? `No plugins match "${installedQuery}" with these filters.` - : `No plugins match "${installedQuery}"` - } - /> - ) : ( - <> - - - - )} -
-
- ); + content = ; } return ( diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 614840e57e3..7bf17d18d6a 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -9,7 +9,6 @@ import { DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { Icon } from "@bb/shared-ui/icon"; -import bbLogoUrl from "../../../../../../assets/bb-logo.svg"; import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; import { cn } from "@bb/shared-ui/lib/utils"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; @@ -19,8 +18,6 @@ import { ResourceInstallControl, ResourceInstalledControl, ResourceListState, - ResourceShelfAction, - ResourceSourceShelf, useResourceRouteLabel, } from "@bb/shared-ui/resource-list"; import { BrowseArchetypeCards } from "@/components/plugin/browse-hero/BrowseArchetypeCards"; @@ -54,7 +51,11 @@ import { pluginInstallCountPresentation, } from "./plugin-ui"; -const SHELF_ENTRY_LIMIT = 6; +import { + PluginShelf, + PluginShelfGrid, + PLUGIN_SHELF_ENTRY_LIMIT, +} from "./PluginShelf"; export function BrowsePluginsTab({ onInstall, @@ -355,7 +356,7 @@ export function BrowsePluginsTab({ } export function pluginCategoryFilterOptions( - entries: readonly PluginCatalogSearchEntry[], + entries: readonly Pick[], selected: readonly string[], ): PluginBrowseCategoryOption[] { const labels = new Map(); @@ -420,68 +421,35 @@ function BrowseShelf({ const [searchParams] = useSearchParams(); const shelfParams = new URLSearchParams(searchParams); shelfParams.set("shelf", shelf.key); - const visible = shelf.entries.slice(0, SHELF_ENTRY_LIMIT); + const visible = shelf.entries.slice(0, PLUGIN_SHELF_ENTRY_LIMIT); return ( - - ) : shelf.key === "collection:new-and-notable" ? ( - - ) : ( - - ) - } - browseAction={ - shelf.entries.length > 2 ? ( - - - See all - - - ) : undefined + + See all + } > -
-
- {visible.map((entry) => ( - - ))} -
-
-
+ + {visible.map((entry) => ( + + ))} + + ); } diff --git a/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx b/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx new file mode 100644 index 00000000000..ebd0ccc1559 --- /dev/null +++ b/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx @@ -0,0 +1,356 @@ +import { useLayoutEffect, useMemo, useRef, type ReactNode } from "react"; +import { + Link, + useLocation, + useNavigate, + useSearchParams, +} from "react-router-dom"; +import { z } from "zod"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + ResourceCollectionViewport, + ResourceListState, + ResourceSortMenu, + ResourceToolbar, + useResourceRouteLabel, +} from "@bb/shared-ui/resource-list"; +import { + ResourceInfiniteScrollSentinel, + useResourceInfiniteItems, + RESOURCE_GRID_PAGE_SIZE, +} from "@bb/shared-ui/resource-pagination"; +import { TOOLS_PAGE_BAND_CLASSES } from "@/components/tools/tools-navigation"; +import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; +import { usePluginCatalogSearch } from "@/hooks/queries/plugin-catalog-queries"; +import { pluginCategoryFilterOptions } from "./BrowsePluginsTab"; +import { CheckPluginUpdatesButton } from "./CheckPluginUpdatesButton"; +import { InstalledPluginsTab } from "./InstalledPluginsTab"; +import { installedPluginCatalogEntry } from "./installed-plugin-catalog"; +import { PluginBrowseCategoryFilter } from "./PluginBrowseControls"; +import { PluginShelf, PLUGIN_SHELF_ENTRY_LIMIT } from "./PluginShelf"; +import { + pluginCategoryFilterId, + pluginCategoryShelves, +} from "./plugin-browse-discovery"; +import { pluginCatalogCategoryMutedAccentStyle } from "./plugin-ui"; + +const installedNavigationSchema = z.object({ + installedReturn: z + .object({ search: z.string(), scrollTop: z.number().nonnegative() }) + .optional(), + installedRestoreScroll: z.number().nonnegative().optional(), +}); + +export function InstalledPluginsCollection({ + actions, +}: { + actions: ReactNode; +}) { + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const listQuery = usePluginList({ enabled: true }); + const catalogQuery = usePluginCatalogSearch("", { enabled: true }); + const plugins = useMemo( + () => + (listQuery.data?.plugins ?? []).map((plugin) => { + const entry = installedPluginCatalogEntry( + plugin, + catalogQuery.data?.entries ?? [], + ); + return { + ...plugin, + categoryId: entry?.categoryId ?? plugin.categoryId, + category: entry?.category ?? plugin.category, + }; + }), + [catalogQuery.data?.entries, listQuery.data?.plugins], + ); + const shelfKey = searchParams.get("shelf"); + const query = searchParams.get("query") ?? ""; + const selectedCategories = useMemo( + () => (shelfKey === null ? searchParams.getAll("category") : []), + [searchParams, shelfKey], + ); + const sortDirection = + searchParams.get("direction") === "desc" ? "desc" : "asc"; + const sorted = searchParams.get("sort") === "alpha"; + const navigation = installedNavigationSchema.safeParse(location.state).data; + const allShelves = useMemo(() => pluginCategoryShelves(plugins), [plugins]); + const selectedShelf = allShelves.find((shelf) => shelf.key === shelfKey); + useResourceRouteLabel(selectedShelf?.label ?? null); + const scopedPlugins = + shelfKey === null ? plugins : (selectedShelf?.entries ?? []); + const categoryOptions = useMemo( + () => pluginCategoryFilterOptions(plugins, selectedCategories), + [plugins, selectedCategories], + ); + const normalizedQuery = query.trim().toLowerCase(); + const visiblePlugins = useMemo( + () => + scopedPlugins + .filter( + (plugin) => + (selectedCategories.length === 0 || + selectedCategories.includes(pluginCategoryFilterId(plugin))) && + [ + plugin.id, + plugin.name ?? "", + plugin.description ?? "", + plugin.version, + plugin.sourceDisplay, + ] + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ) + .sort((left, right) => { + const enabledResult = Number(!left.enabled) - Number(!right.enabled); + if (enabledResult !== 0) return enabledResult; + if (left.enabled) { + const publisherResult = + Number(left.publisherLabel === null) - + Number(right.publisherLabel === null); + if (publisherResult !== 0) return publisherResult; + } + const result = (left.name ?? left.id).localeCompare( + right.name ?? right.id, + ); + if (result !== 0) return sortDirection === "asc" ? result : -result; + return left.id.localeCompare(right.id); + }), + [normalizedQuery, scopedPlugins, selectedCategories, sortDirection], + ); + const shelves = useMemo( + () => pluginCategoryShelves(visiblePlugins), + [visiblePlugins], + ); + const installedList = useResourceInfiniteItems(visiblePlugins, { + pageSize: RESOURCE_GRID_PAGE_SIZE, + resetKey: location.search, + }); + const viewportRef = useRef(null); + const scrollPositions = useRef(new Map()); + const ready = !listQuery.isPending && !catalogQuery.isPending; + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (viewport === null || !ready) return; + viewport.scrollTop = + navigation?.installedRestoreScroll ?? + scrollPositions.current.get(location.search) ?? + 0; + return () => { + scrollPositions.current.set(location.search, viewport.scrollTop); + }; + }, [ + location.key, + location.search, + navigation?.installedRestoreScroll, + ready, + ]); + + const changeSearchParams = (change: (next: URLSearchParams) => void) => { + const next = new URLSearchParams(searchParams); + change(next); + setSearchParams(next, { + replace: true, + state: { installedReturn: navigation?.installedReturn }, + }); + }; + const returnParams = new URLSearchParams( + navigation?.installedReturn?.search ?? searchParams, + ); + returnParams.delete("shelf"); + + return ( + +
+ {shelfKey !== null ? ( +
+ + + Installed plugins + + {selectedShelf === undefined ? null : ( +

+ + + {selectedShelf.label} + + + {selectedShelf.entries.length.toLocaleString()}{" "} + {selectedShelf.entries.length === 1 ? "plugin" : "plugins"} + +

+ )} +
+ ) : null} + + changeSearchParams((next) => { + if (value === "") next.delete("query"); + else next.set("query", value); + }) + } + action={actions} + controls={ + <> + {shelfKey === null ? ( + + changeSearchParams((next) => { + next.delete("category"); + for (const value of values) + next.append("category", value); + }) + } + /> + ) : null} + + changeSearchParams((next) => { + next.set("sort", "alpha"); + next.set( + "direction", + sortDirection === "asc" ? "desc" : "asc", + ); + }) + } + onClear={ + sorted + ? () => + changeSearchParams((next) => { + next.delete("sort"); + next.delete("direction"); + }) + : undefined + } + /> + {plugins.length > 0 ? : null} + + } + /> + {listQuery.isError ? ( + void listQuery.refetch()} + /> + ) : !ready ? ( + + ) : shelfKey !== null && selectedShelf === undefined ? ( + + ) : plugins.length > 0 && visiblePlugins.length === 0 ? ( + + ) : shelfKey === null && !sorted && plugins.length > 0 ? ( +
+ {shelves.map((shelf) => { + const next = new URLSearchParams(searchParams); + next.set("shelf", shelf.key); + return ( + { + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + navigate( + { + pathname: location.pathname, + search: next.toString(), + }, + { + state: { + installedReturn: { + search: location.search, + scrollTop: viewportRef.current?.scrollTop ?? 0, + }, + }, + }, + ); + }} + > + See all + + } + > + + + ); + })} +
+ ) : ( +
+ + +
+ )} +
+
+ ); +} diff --git a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx index 5cbc939f514..ac58cdc62d6 100644 --- a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx +++ b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx @@ -28,13 +28,18 @@ import { usePluginCatalogSearch, type PluginCatalogSearchEntry, } from "@/hooks/queries/plugin-catalog-queries"; +import { PluginShelfGrid } from "./PluginShelf"; import { UpdatePluginDialog } from "./UpdatePluginDialog"; import { PluginLogo, PluginCategoryLabel } from "./plugin-ui"; export function InstalledPluginsTab({ plugins, + showCategory = true, + preview = false, }: { plugins: readonly PluginListItem[]; + showCategory?: boolean; + preview?: boolean; }) { const catalogQuery = usePluginCatalogSearch("", { enabled: true }); const [updateTargetId, setUpdateTargetId] = useState(null); @@ -49,21 +54,28 @@ export function InstalledPluginsTab({ ); } + const cards = plugins.map((plugin) => ( + setUpdateTargetId(plugin.id)} + /> + )); + return ( <> - - {plugins.map((plugin) => ( - setUpdateTargetId(plugin.id)} - /> - ))} - + {preview ? ( + {cards} + ) : ( + + {cards} + + )} {updateTarget !== null ? ( void; }) { const navigate = useNavigate(); @@ -122,7 +136,7 @@ export function InstalledPluginRow({ const openDetail = () => navigate( isPluginsRoutePath(location.pathname) - ? `${getPluginDetailRoutePath({ pluginId: plugin.id })}?view=installed` + ? `${getPluginDetailRoutePath({ pluginId: plugin.id })}${location.search || "?view=installed"}` : getPluginDetailRoutePath({ pluginId: plugin.id, view: "installed" }), ); return ( @@ -160,6 +174,7 @@ export function InstalledPluginRow({ ) : null } footerMeta={ + showCategory && (catalogEntry?.category ?? plugin.category) !== undefined ? ( ; + entryCount: number; + seeAllLink: ReactNode; + children: ReactNode; +}) { + return ( + + ) : shelf.key === "collection:new-and-notable" ? ( + + ) : ( + + ) + } + browseAction={ + entryCount > 2 ? ( + + {seeAllLink} + + ) : undefined + } + > + {children} + + ); +} + +export function PluginShelfGrid({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts index fd2cea10270..4f67834c4e9 100644 --- a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts +++ b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts @@ -10,16 +10,20 @@ export const UNCATEGORIZED_PLUGIN_CATEGORY_ID = "uncategorized"; export type PluginBrowseSort = "recently-added" | "most-installed"; export type PluginBrowseSortDirection = "asc" | "desc"; -export interface PluginBrowseShelf { +export interface PluginCollectionShelf { key: string; categoryId?: string; label: string; description?: string; - entries: PluginCatalogSearchEntry[]; + entries: Entry[]; kind: "collection" | "category" | "uncategorized"; } -function isCategorized(entry: PluginCatalogSearchEntry): boolean { +export type PluginBrowseShelf = PluginCollectionShelf; + +type PluginCategory = Pick; + +function isCategorized(entry: PluginCategory): boolean { return entry.categoryId !== undefined && entry.category !== undefined; } @@ -60,7 +64,14 @@ export function pluginBrowseShelves({ }, ]; }); - const entriesByCategory = new Map(); + return [...shelves, ...pluginCategoryShelves(entries)]; +} + +export function pluginCategoryShelves( + entries: readonly Entry[], +): PluginCollectionShelf[] { + const shelves: PluginCollectionShelf[] = []; + const entriesByCategory = new Map(); const categoryLabels = new Map(); const unknownCategoryOrder: string[] = []; for (const entry of entries) { @@ -114,9 +125,7 @@ export function pluginBrowseShelves({ return shelves; } -export function pluginCategoryFilterId( - entry: PluginCatalogSearchEntry, -): string { +export function pluginCategoryFilterId(entry: PluginCategory): string { return isCategorized(entry) ? (entry.categoryId ?? UNCATEGORIZED_PLUGIN_CATEGORY_ID) : UNCATEGORIZED_PLUGIN_CATEGORY_ID; From 1f51010ad3a404bb586b1af87bee9e975890c52c Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:21:53 +0000 Subject: [PATCH 27/79] Keep Installed search responsive during route updates --- .../plugin/PluginsOverview.test.tsx | 16 ++++---- .../management/InstalledPluginsCollection.tsx | 37 ++++++++++++------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index e8e22338225..e9e3b40d2c6 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -87,7 +87,7 @@ const GITHUB_CATALOG_ENTRY = { icon: "Github", iconUrl: null, categoryId: "code-and-reviews", - category: "Developer tools", + category: "Code & Reviews", source: "builtin:github", marketplace: "bb-official", marketplaceDisplayName: "BB Official", @@ -108,7 +108,7 @@ const AUTOMATIONS_CATALOG_ENTRY = { description: AUTOMATIONS_PLUGIN.description, icon: AUTOMATIONS_PLUGIN.icon, categoryId: "tasks-and-workflows", - category: "Workflow management", + category: "Tasks & Workflows", source: AUTOMATIONS_PLUGIN.source, installed: true, }; @@ -121,7 +121,7 @@ const DOCS_CATALOG_ENTRY = { description: "Create and edit Markdown documents.", icon: "NotebookText", categoryId: "memory-and-context", - category: "Context & knowledge", + category: "Memory & Context", source: "builtin:docs", installed: true, }; @@ -318,9 +318,7 @@ describe("PluginsOverview", () => { }); expect(screen.queryByRole("button", { name: "Type" })).toBeNull(); fireEvent.click(categoryTrigger); - fireEvent.click( - screen.getByRole("option", { name: /Context & knowledge/u }), - ); + fireEvent.click(screen.getByRole("option", { name: /Memory & Context/u })); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.getByText("Docs")).toBeTruthy(); expect(screen.queryByText("GitHub")).toBeNull(); @@ -673,7 +671,11 @@ describe("PluginsOverview", () => { screen.queryByRole("heading", { name: "Code & Reviews" }), ).toBeNull(); expect(screen.getAllByTestId("plugin-row-github")).toHaveLength(1); - expect(screen.queryByText("Workflow management")).toBeNull(); + expect( + within(screen.getByTestId("plugin-row-automations")).queryByText( + "Tasks & Workflows", + ), + ).toBeNull(); fireEvent.click( screen.getByRole("button", { diff --git a/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx b/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx index ebd0ccc1559..9a74482675f 100644 --- a/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx +++ b/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx @@ -1,4 +1,11 @@ -import { useLayoutEffect, useMemo, useRef, type ReactNode } from "react"; +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { Link, useLocation, @@ -69,6 +76,8 @@ export function InstalledPluginsCollection({ ); const shelfKey = searchParams.get("shelf"); const query = searchParams.get("query") ?? ""; + const [searchInput, setSearchInput] = useState(query); + useEffect(() => setSearchInput(query), [query]); const selectedCategories = useMemo( () => (shelfKey === null ? searchParams.getAll("category") : []), [searchParams, shelfKey], @@ -80,13 +89,15 @@ export function InstalledPluginsCollection({ const allShelves = useMemo(() => pluginCategoryShelves(plugins), [plugins]); const selectedShelf = allShelves.find((shelf) => shelf.key === shelfKey); useResourceRouteLabel(selectedShelf?.label ?? null); - const scopedPlugins = - shelfKey === null ? plugins : (selectedShelf?.entries ?? []); + const scopedPlugins = useMemo( + () => (shelfKey === null ? plugins : (selectedShelf?.entries ?? [])), + [plugins, selectedShelf, shelfKey], + ); const categoryOptions = useMemo( () => pluginCategoryFilterOptions(plugins, selectedCategories), [plugins, selectedCategories], ); - const normalizedQuery = query.trim().toLowerCase(); + const normalizedQuery = searchInput.trim().toLowerCase(); const visiblePlugins = useMemo( () => scopedPlugins @@ -136,12 +147,11 @@ export function InstalledPluginsCollection({ useLayoutEffect(() => { const viewport = viewportRef.current; if (viewport === null || !ready) return; + const positions = scrollPositions.current; viewport.scrollTop = - navigation?.installedRestoreScroll ?? - scrollPositions.current.get(location.search) ?? - 0; + navigation?.installedRestoreScroll ?? positions.get(location.search) ?? 0; return () => { - scrollPositions.current.set(location.search, viewport.scrollTop); + positions.set(location.search, viewport.scrollTop); }; }, [ location.key, @@ -197,7 +207,7 @@ export function InstalledPluginsCollection({ aria-hidden /> {selectedShelf.label} - + {" "} {selectedShelf.entries.length.toLocaleString()}{" "} {selectedShelf.entries.length === 1 ? "plugin" : "plugins"} @@ -207,14 +217,15 @@ export function InstalledPluginsCollection({
) : null} + onSearchChange={(value) => { + setSearchInput(value); changeSearchParams((next) => { if (value === "") next.delete("query"); else next.set("query", value); - }) - } + }); + }} action={actions} controls={ <> From a87c3b981f2e24c9b21bd87c628165863c87cf69 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:37:26 +0000 Subject: [PATCH 28/79] Use a Local label on installed plugin cards --- .../src/components/plugin/management/InstalledPluginsTab.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx index ac58cdc62d6..1ce2d87d27b 100644 --- a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx +++ b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx @@ -152,9 +152,7 @@ export function InstalledPluginRow({ title={plugin.name ?? plugin.id} byline={ plugin.source.startsWith("path:") ? ( - - {plugin.sourceDisplay} - + "Local" ) : catalogEntry !== undefined ? ( ) : plugin.publisherLabel !== null ? ( From ca0933eb13a7577caa5493ea52578f62a140dc6d Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:03:16 +0000 Subject: [PATCH 29/79] Defer Installed category shelves --- .../plugin/PluginsOverview.test.tsx | 258 ++++++------ .../src/components/plugin/PluginsOverview.tsx | 171 +++++++- .../plugin/management/BrowsePluginsTab.tsx | 96 +++-- .../management/InstalledPluginsCollection.tsx | 367 ------------------ .../plugin/management/InstalledPluginsTab.tsx | 43 +- .../plugin/management/PluginShelf.tsx | 80 ---- .../management/plugin-browse-discovery.ts | 23 +- 7 files changed, 372 insertions(+), 666 deletions(-) delete mode 100644 apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx delete mode 100644 apps/app/src/components/plugin/management/PluginShelf.tsx diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index e9e3b40d2c6..231f5c0c5ad 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -7,7 +7,6 @@ import { render, screen, waitFor, - within, } from "@testing-library/react"; import { MemoryRouter, @@ -87,7 +86,7 @@ const GITHUB_CATALOG_ENTRY = { icon: "Github", iconUrl: null, categoryId: "code-and-reviews", - category: "Code & Reviews", + category: "Developer tools", source: "builtin:github", marketplace: "bb-official", marketplaceDisplayName: "BB Official", @@ -108,7 +107,7 @@ const AUTOMATIONS_CATALOG_ENTRY = { description: AUTOMATIONS_PLUGIN.description, icon: AUTOMATIONS_PLUGIN.icon, categoryId: "tasks-and-workflows", - category: "Tasks & Workflows", + category: "Workflow management", source: AUTOMATIONS_PLUGIN.source, installed: true, }; @@ -121,7 +120,7 @@ const DOCS_CATALOG_ENTRY = { description: "Create and edit Markdown documents.", icon: "NotebookText", categoryId: "memory-and-context", - category: "Memory & Context", + category: "Context & knowledge", source: "builtin:docs", installed: true, }; @@ -190,10 +189,6 @@ function LocationPath() { return {useLocation().pathname}; } -function LocationSearch() { - return {useLocation().search}; -} - afterEach(() => { focusManager.setFocused(undefined); cleanup(); @@ -318,7 +313,9 @@ describe("PluginsOverview", () => { }); expect(screen.queryByRole("button", { name: "Type" })).toBeNull(); fireEvent.click(categoryTrigger); - fireEvent.click(screen.getByRole("option", { name: /Memory & Context/u })); + fireEvent.click( + screen.getByRole("option", { name: /Context & knowledge/u }), + ); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.getByText("Docs")).toBeTruthy(); expect(screen.queryByText("GitHub")).toBeNull(); @@ -342,7 +339,7 @@ describe("PluginsOverview", () => { expect(screen.getByTestId("location-path").textContent).toBe("/"); }); - it("offers category filtering on Installed using the same control as Browse", async () => { + it("shows the Type filter on Installed instead of Category", async () => { installFetch([AUTOMATIONS_PLUGIN]); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( @@ -356,12 +353,8 @@ describe("PluginsOverview", () => { ); expect(await screen.findByText("Automations")).toBeTruthy(); - expect( - screen.getByRole("button", { - name: "Filter plugins by category: All categories", - }), - ).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Type" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Category" })).toBeNull(); + expect(screen.getByRole("button", { name: "Type" })).toBeTruthy(); expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy(); }); @@ -495,11 +488,7 @@ describe("PluginsOverview", () => { installFetch(plugins); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( - + @@ -625,157 +614,150 @@ describe("PluginsOverview", () => { (row) => row.getAttribute("data-testid"), ), ).toEqual([ - "plugin-row-enabled-official-alpha", "plugin-row-enabled-official-zulu", + "plugin-row-enabled-official-alpha", "plugin-row-enabled-local-alpha", - "plugin-row-inactive-local", "plugin-row-inactive-official", + "plugin-row-inactive-local", ]); }); - it("groups only installed plugins and keeps unmatched local sources uncategorized", async () => { + it("gives each publisher its own Type facet, separate from User", async () => { installFetch([ - AUTOMATIONS_PLUGIN, + { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, { ...AUTOMATIONS_PLUGIN, - id: "simple-notes", - source: "builtin:docs", - name: "Docs", + id: "catalog-one", + name: "Catalog One", + provenance: "catalog", + publisherKey: "bb-community", + publisherLabel: "BB Community", + catalogEntryId: "catalog-one", }, { ...AUTOMATIONS_PLUGIN, - id: "github", - source: "path:/qa/github", - name: "Local GitHub", + id: "direct-one", + name: "Direct One", provenance: "direct", publisherLabel: null, }, ]); - const { wrapper } = createQueryClientTestHarness(); + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( - + + + + + , - { wrapper }, ); - const shelves = await screen.findByTestId("plugin-installed-shelves"); - await waitFor(() => - expect( - within(shelves) - .getAllByRole("heading") - .map((heading) => heading.textContent), - ).toEqual(["Memory & Context", "Tasks & Workflows", "More plugins"]), - ); - expect( - screen.queryByRole("heading", { name: "Code & Reviews" }), - ).toBeNull(); - expect(screen.getAllByTestId("plugin-row-github")).toHaveLength(1); - expect( - within(screen.getByTestId("plugin-row-automations")).queryByText( - "Tasks & Workflows", - ), - ).toBeNull(); + await screen.findByText("Direct One"); + const rowIds = () => + [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( + (row) => row.getAttribute("data-testid"), + ); + + const typeTrigger = screen.getByRole("button", { name: "Type" }); + expect(rowIds()).toEqual([ + "plugin-row-builtin-one", + "plugin-row-catalog-one", + "plugin-row-direct-one", + ]); + fireEvent.pointerDown(typeTrigger); + expect(screen.queryByRole("menuitemcheckbox", { name: "All" })).toBeNull(); fireEvent.click( - screen.getByRole("button", { - name: "Filter plugins by category: All categories", - }), + screen.getByRole("menuitemcheckbox", { name: "BB Official" }), ); - expect( - await screen.findByRole("option", { name: /Tasks & Workflows.*1/u }), - ).toBeTruthy(); - expect( - screen.queryByRole("option", { name: /Code & Reviews/u }), - ).toBeNull(); - fireEvent.click(screen.getByRole("option", { name: /Tasks & Workflows/u })); - expect(screen.queryByText("Local GitHub")).toBeNull(); - expect(screen.queryByText("Docs")).toBeNull(); - expect(screen.getByText("Automations")).toBeTruthy(); - }); + await waitFor(() => { + expect(rowIds()).toEqual(["plugin-row-builtin-one"]); + }); - it("opens the full installed shelf and restores the original query and scroll after shelf edits", async () => { - const plugins = Array.from({ length: 14 }, (_, index) => ({ - ...AUTOMATIONS_PLUGIN, - id: `local-${index + 1}`, - source: `path:/qa/local-${index + 1}`, - name: `Local ${String(index + 1).padStart(2, "0")}`, - })); - installFetch(plugins); - const { wrapper } = createQueryClientTestHarness(); - render( - - - - , - { wrapper }, + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "BB Community" }), ); - await screen.findByText("Local 01"); - expect(screen.getByText("Local 06")).toBeTruthy(); - expect(screen.queryByText("Local 07")).toBeNull(); - const viewport = document.getElementById("plugins-installed-results"); - if (viewport === null) throw new Error("Missing Installed viewport"); - viewport.scrollTop = 240; - fireEvent.click(screen.getByRole("link", { name: "See all More plugins" })); - expect( - await screen.findByRole("heading", { name: "More plugins 14 plugins" }), - ).toBeTruthy(); - expect(screen.getByText("Local 12")).toBeTruthy(); - expect( - new URLSearchParams( - screen.getByTestId("location-search").textContent ?? "", - ).get("view"), - ).toBe("installed"); - const search = screen.getByRole("textbox", { - name: "Search installed plugins", + await waitFor(() => { + expect(rowIds()).toEqual([ + "plugin-row-builtin-one", + "plugin-row-catalog-one", + ]); }); - fireEvent.change(search, { target: { value: "Local 14" } }); - expect(screen.getByText("Local 14")).toBeTruthy(); - expect(screen.queryByText("Local 01")).toBeNull(); - fireEvent.change(search, { target: { value: "zz-no-match" } }); - expect(screen.getByText('No plugins match "zz-no-match"')).toBeTruthy(); - fireEvent.click(screen.getByRole("link", { name: "Installed plugins" })); - expect( - ( - screen.getByRole("textbox", { - name: "Search installed plugins", - }) as HTMLInputElement - ).value, - ).toBe("Local"); - expect(screen.getByTestId("location-search").textContent).toBe( - "?view=installed&query=Local&category=uncategorized", + + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "BB Official" }), ); - expect( - document.getElementById("plugins-installed-results")?.scrollTop, - ).toBe(240); - expect(screen.getByText("Local 06")).toBeTruthy(); - expect(screen.queryByText("Local 07")).toBeNull(); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "BB Community" }), + ); + await waitFor(() => { + expect(rowIds()).toEqual(["plugin-row-direct-one"]); + }); + + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); + await waitFor(() => { + expect(rowIds()).toEqual([ + "plugin-row-builtin-one", + "plugin-row-catalog-one", + "plugin-row-direct-one", + ]); + }); + expect(screen.queryByText("No plugins match these filters.")).toBeNull(); }); - it("keeps a directly opened empty category in Installed with a way back", async () => { - installFetch(); - const { wrapper } = createQueryClientTestHarness(); + it("drops a Type selection whose facet no longer has any plugin", async () => { + installFetch([ + { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, + { + ...AUTOMATIONS_PLUGIN, + id: "acme-one", + name: "Acme One", + provenance: "catalog", + publisherKey: "acme-plugins", + publisherLabel: "Acme Plugins", + catalogEntryId: "acme-one", + }, + ]); + const { wrapper: QueryClientWrapper, queryClient } = + createQueryClientTestHarness(); render( - - - + + + + , - { wrapper }, ); - expect( - await screen.findByText("No installed plugins in this category."), - ).toBeTruthy(); - fireEvent.click(screen.getByRole("link", { name: "Installed plugins" })); - expect(screen.getByTestId("location-search").textContent).toBe( - "?view=installed", + + await screen.findByText("Acme One"); + fireEvent.pointerDown(screen.getByRole("button", { name: "Type" })); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Acme Plugins" }), ); - expect(await screen.findByText("Automations")).toBeTruthy(); + await waitFor(() => { + expect( + [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( + (row) => row.getAttribute("data-testid"), + ), + ).toEqual(["plugin-row-acme-one"]); + }); + + installFetch([ + { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, + ]); + await act(async () => { + await queryClient.invalidateQueries(); + }); + + await waitFor(() => { + expect( + [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( + (row) => row.getAttribute("data-testid"), + ), + ).toEqual(["plugin-row-builtin-one"]); + }); + expect(screen.queryByText("No plugins match these filters.")).toBeNull(); }); it("keeps disabled plugins installed regardless of provenance", async () => { diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 2cdfff0de39..f40c6e2a697 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -1,6 +1,19 @@ -import { useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { ResourceCollectionPage } from "@bb/shared-ui/resource-list"; +import { + ResourceInfiniteScrollSentinel, + useResourceInfiniteItems, + RESOURCE_GRID_PAGE_SIZE, +} from "@bb/shared-ui/resource-pagination"; +import { + ResourceCollectionPage, + ResourceCollectionViewport, + ResourceListState, + ResourceMultiSelectMenu, + ResourceSortMenu, + ResourceToolbar, +} from "@bb/shared-ui/resource-list"; +import { cn } from "@bb/shared-ui/lib/utils"; import { CreateWithTemplatesButton } from "@/components/create-via-prompt-examples"; import { CREATE_PLUGIN_PROMPT } from "@bb/client-core"; import { TOOLS_PAGE_BAND_CLASSES } from "@/components/tools/tools-navigation"; @@ -9,9 +22,15 @@ import { type AddPluginInitial, } from "@/components/plugin/management/AddPluginDialog"; import { BrowsePluginsTab } from "@/components/plugin/management/BrowsePluginsTab"; -import { InstalledPluginsCollection } from "@/components/plugin/management/InstalledPluginsCollection"; +import { CheckPluginUpdatesButton } from "@/components/plugin/management/CheckPluginUpdatesButton"; +import { InstalledPluginsTab } from "@/components/plugin/management/InstalledPluginsTab"; import { PluginAuthorPage } from "@/components/plugin/management/PluginAuthorPage"; +import { + pluginPublisherFilterId, + pluginPublisherFilterOptions, +} from "@/components/plugin/plugin-provenance"; import { PLUGINS_INSTALLED_DESCRIPTION } from "@/components/plugin/plugins-collection-copy"; +import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; import { getPluginDetailRoutePath, getRootComposeRoutePath, @@ -26,14 +45,90 @@ export function PluginsOverview({ } = {}) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const listQuery = usePluginList({ enabled: true }); + const plugins = useMemo( + () => listQuery.data?.plugins ?? [], + [listQuery.data?.plugins], + ); const activeMode = mode ?? (searchParams.get("view") === "installed" ? "installed" : "browse"); const authorKey = searchParams.get("author"); + const [installedQuery, setInstalledQuery] = useState(""); + const [installedSortDirection, setInstalledSortDirection] = useState< + "asc" | "desc" + >("asc"); + const [typeFilters, setTypeFilters] = useState([]); + const typeFilterOptions = useMemo( + () => pluginPublisherFilterOptions(plugins), + [plugins], + ); + const activeTypeFilters = useMemo(() => { + const offered = new Set(typeFilterOptions.map((option) => option.id)); + return typeFilters.filter((value) => offered.has(value)); + }, [typeFilterOptions, typeFilters]); + const normalizedInstalledQuery = installedQuery.trim().toLowerCase(); + const installedResetKey = [ + normalizedInstalledQuery, + installedSortDirection, + [...activeTypeFilters].sort().join(","), + ].join("\u0000"); const [addDialog, setAddDialog] = useState<{ open: boolean; initial: AddPluginInitial | null; }>({ open: false, initial: null }); + const visiblePlugins = useMemo( + () => + plugins + .filter((plugin) => { + if ( + activeTypeFilters.length > 0 && + !activeTypeFilters.includes(pluginPublisherFilterId(plugin)) + ) { + return false; + } + if (normalizedInstalledQuery.length === 0) return true; + return [ + plugin.id, + plugin.name ?? "", + plugin.description ?? "", + plugin.version, + plugin.sourceDisplay, + ] + .join(" ") + .toLowerCase() + .includes(normalizedInstalledQuery); + }) + .sort((left, right) => { + const enabledResult = Number(!left.enabled) - Number(!right.enabled); + if (enabledResult !== 0) return enabledResult; + if (left.enabled) { + const leftPublisher = left.publisherLabel; + const rightPublisher = right.publisherLabel; + const publisherResult = + Number(leftPublisher === null) - Number(rightPublisher === null); + if (publisherResult !== 0) return publisherResult; + } + const result = (left.name ?? left.id).localeCompare( + right.name ?? right.id, + ); + if (result !== 0) { + return installedSortDirection === "asc" ? result : -result; + } + return left.id.localeCompare(right.id); + }), + [ + activeTypeFilters, + installedSortDirection, + normalizedInstalledQuery, + plugins, + ], + ); + const installedList = useResourceInfiniteItems(visiblePlugins, { + pageSize: RESOURCE_GRID_PAGE_SIZE, + resetKey: installedResetKey, + }); + const startCreatePlugin = (prompt?: string) => { navigate(getRootComposeRoutePath(), { state: { @@ -83,7 +178,75 @@ export function PluginsOverview({ /> ); } else { - content = ; + content = ( + + + + setInstalledSortDirection((current) => + current === "asc" ? "desc" : "asc", + ) + } + /> + {plugins.length > 0 ? : null} + + } + /> + } + > +
+ {listQuery.isError ? ( + void listQuery.refetch()} + /> + ) : listQuery.isFetching && listQuery.data === undefined ? ( + + ) : plugins.length > 0 && visiblePlugins.length === 0 ? ( + 0 + ? `No plugins match "${installedQuery}" with these filters.` + : `No plugins match "${installedQuery}"` + } + /> + ) : ( + <> + + + + )} +
+
+ ); } return ( diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 7bf17d18d6a..614840e57e3 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { Icon } from "@bb/shared-ui/icon"; +import bbLogoUrl from "../../../../../../assets/bb-logo.svg"; import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; import { cn } from "@bb/shared-ui/lib/utils"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; @@ -18,6 +19,8 @@ import { ResourceInstallControl, ResourceInstalledControl, ResourceListState, + ResourceShelfAction, + ResourceSourceShelf, useResourceRouteLabel, } from "@bb/shared-ui/resource-list"; import { BrowseArchetypeCards } from "@/components/plugin/browse-hero/BrowseArchetypeCards"; @@ -51,11 +54,7 @@ import { pluginInstallCountPresentation, } from "./plugin-ui"; -import { - PluginShelf, - PluginShelfGrid, - PLUGIN_SHELF_ENTRY_LIMIT, -} from "./PluginShelf"; +const SHELF_ENTRY_LIMIT = 6; export function BrowsePluginsTab({ onInstall, @@ -356,7 +355,7 @@ export function BrowsePluginsTab({ } export function pluginCategoryFilterOptions( - entries: readonly Pick[], + entries: readonly PluginCatalogSearchEntry[], selected: readonly string[], ): PluginBrowseCategoryOption[] { const labels = new Map(); @@ -421,35 +420,68 @@ function BrowseShelf({ const [searchParams] = useSearchParams(); const shelfParams = new URLSearchParams(searchParams); shelfParams.set("shelf", shelf.key); - const visible = shelf.entries.slice(0, PLUGIN_SHELF_ENTRY_LIMIT); + const visible = shelf.entries.slice(0, SHELF_ENTRY_LIMIT); return ( - - See all - + + ) : shelf.key === "collection:new-and-notable" ? ( + + ) : ( + + ) + } + browseAction={ + shelf.entries.length > 2 ? ( + + + See all + + + ) : undefined } > - - {visible.map((entry) => ( - - ))} - - +
+
+ {visible.map((entry) => ( + + ))} +
+
+ ); } diff --git a/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx b/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx deleted file mode 100644 index 9a74482675f..00000000000 --- a/apps/app/src/components/plugin/management/InstalledPluginsCollection.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import { - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import { - Link, - useLocation, - useNavigate, - useSearchParams, -} from "react-router-dom"; -import { z } from "zod"; -import { Icon } from "@bb/shared-ui/icon"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { - ResourceCollectionViewport, - ResourceListState, - ResourceSortMenu, - ResourceToolbar, - useResourceRouteLabel, -} from "@bb/shared-ui/resource-list"; -import { - ResourceInfiniteScrollSentinel, - useResourceInfiniteItems, - RESOURCE_GRID_PAGE_SIZE, -} from "@bb/shared-ui/resource-pagination"; -import { TOOLS_PAGE_BAND_CLASSES } from "@/components/tools/tools-navigation"; -import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; -import { usePluginCatalogSearch } from "@/hooks/queries/plugin-catalog-queries"; -import { pluginCategoryFilterOptions } from "./BrowsePluginsTab"; -import { CheckPluginUpdatesButton } from "./CheckPluginUpdatesButton"; -import { InstalledPluginsTab } from "./InstalledPluginsTab"; -import { installedPluginCatalogEntry } from "./installed-plugin-catalog"; -import { PluginBrowseCategoryFilter } from "./PluginBrowseControls"; -import { PluginShelf, PLUGIN_SHELF_ENTRY_LIMIT } from "./PluginShelf"; -import { - pluginCategoryFilterId, - pluginCategoryShelves, -} from "./plugin-browse-discovery"; -import { pluginCatalogCategoryMutedAccentStyle } from "./plugin-ui"; - -const installedNavigationSchema = z.object({ - installedReturn: z - .object({ search: z.string(), scrollTop: z.number().nonnegative() }) - .optional(), - installedRestoreScroll: z.number().nonnegative().optional(), -}); - -export function InstalledPluginsCollection({ - actions, -}: { - actions: ReactNode; -}) { - const location = useLocation(); - const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - const listQuery = usePluginList({ enabled: true }); - const catalogQuery = usePluginCatalogSearch("", { enabled: true }); - const plugins = useMemo( - () => - (listQuery.data?.plugins ?? []).map((plugin) => { - const entry = installedPluginCatalogEntry( - plugin, - catalogQuery.data?.entries ?? [], - ); - return { - ...plugin, - categoryId: entry?.categoryId ?? plugin.categoryId, - category: entry?.category ?? plugin.category, - }; - }), - [catalogQuery.data?.entries, listQuery.data?.plugins], - ); - const shelfKey = searchParams.get("shelf"); - const query = searchParams.get("query") ?? ""; - const [searchInput, setSearchInput] = useState(query); - useEffect(() => setSearchInput(query), [query]); - const selectedCategories = useMemo( - () => (shelfKey === null ? searchParams.getAll("category") : []), - [searchParams, shelfKey], - ); - const sortDirection = - searchParams.get("direction") === "desc" ? "desc" : "asc"; - const sorted = searchParams.get("sort") === "alpha"; - const navigation = installedNavigationSchema.safeParse(location.state).data; - const allShelves = useMemo(() => pluginCategoryShelves(plugins), [plugins]); - const selectedShelf = allShelves.find((shelf) => shelf.key === shelfKey); - useResourceRouteLabel(selectedShelf?.label ?? null); - const scopedPlugins = useMemo( - () => (shelfKey === null ? plugins : (selectedShelf?.entries ?? [])), - [plugins, selectedShelf, shelfKey], - ); - const categoryOptions = useMemo( - () => pluginCategoryFilterOptions(plugins, selectedCategories), - [plugins, selectedCategories], - ); - const normalizedQuery = searchInput.trim().toLowerCase(); - const visiblePlugins = useMemo( - () => - scopedPlugins - .filter( - (plugin) => - (selectedCategories.length === 0 || - selectedCategories.includes(pluginCategoryFilterId(plugin))) && - [ - plugin.id, - plugin.name ?? "", - plugin.description ?? "", - plugin.version, - plugin.sourceDisplay, - ] - .join(" ") - .toLowerCase() - .includes(normalizedQuery), - ) - .sort((left, right) => { - const enabledResult = Number(!left.enabled) - Number(!right.enabled); - if (enabledResult !== 0) return enabledResult; - if (left.enabled) { - const publisherResult = - Number(left.publisherLabel === null) - - Number(right.publisherLabel === null); - if (publisherResult !== 0) return publisherResult; - } - const result = (left.name ?? left.id).localeCompare( - right.name ?? right.id, - ); - if (result !== 0) return sortDirection === "asc" ? result : -result; - return left.id.localeCompare(right.id); - }), - [normalizedQuery, scopedPlugins, selectedCategories, sortDirection], - ); - const shelves = useMemo( - () => pluginCategoryShelves(visiblePlugins), - [visiblePlugins], - ); - const installedList = useResourceInfiniteItems(visiblePlugins, { - pageSize: RESOURCE_GRID_PAGE_SIZE, - resetKey: location.search, - }); - const viewportRef = useRef(null); - const scrollPositions = useRef(new Map()); - const ready = !listQuery.isPending && !catalogQuery.isPending; - useLayoutEffect(() => { - const viewport = viewportRef.current; - if (viewport === null || !ready) return; - const positions = scrollPositions.current; - viewport.scrollTop = - navigation?.installedRestoreScroll ?? positions.get(location.search) ?? 0; - return () => { - positions.set(location.search, viewport.scrollTop); - }; - }, [ - location.key, - location.search, - navigation?.installedRestoreScroll, - ready, - ]); - - const changeSearchParams = (change: (next: URLSearchParams) => void) => { - const next = new URLSearchParams(searchParams); - change(next); - setSearchParams(next, { - replace: true, - state: { installedReturn: navigation?.installedReturn }, - }); - }; - const returnParams = new URLSearchParams( - navigation?.installedReturn?.search ?? searchParams, - ); - returnParams.delete("shelf"); - - return ( - -
- {shelfKey !== null ? ( -
- - - Installed plugins - - {selectedShelf === undefined ? null : ( -

- - - {selectedShelf.label} - {" "} - - {selectedShelf.entries.length.toLocaleString()}{" "} - {selectedShelf.entries.length === 1 ? "plugin" : "plugins"} - -

- )} -
- ) : null} - { - setSearchInput(value); - changeSearchParams((next) => { - if (value === "") next.delete("query"); - else next.set("query", value); - }); - }} - action={actions} - controls={ - <> - {shelfKey === null ? ( - - changeSearchParams((next) => { - next.delete("category"); - for (const value of values) - next.append("category", value); - }) - } - /> - ) : null} - - changeSearchParams((next) => { - next.set("sort", "alpha"); - next.set( - "direction", - sortDirection === "asc" ? "desc" : "asc", - ); - }) - } - onClear={ - sorted - ? () => - changeSearchParams((next) => { - next.delete("sort"); - next.delete("direction"); - }) - : undefined - } - /> - {plugins.length > 0 ? : null} - - } - /> - {listQuery.isError ? ( - void listQuery.refetch()} - /> - ) : !ready ? ( - - ) : shelfKey !== null && selectedShelf === undefined ? ( - - ) : plugins.length > 0 && visiblePlugins.length === 0 ? ( - - ) : shelfKey === null && !sorted && plugins.length > 0 ? ( -
- {shelves.map((shelf) => { - const next = new URLSearchParams(searchParams); - next.set("shelf", shelf.key); - return ( - { - if ( - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) - return; - event.preventDefault(); - navigate( - { - pathname: location.pathname, - search: next.toString(), - }, - { - state: { - installedReturn: { - search: location.search, - scrollTop: viewportRef.current?.scrollTop ?? 0, - }, - }, - }, - ); - }} - > - See all - - } - > - - - ); - })} -
- ) : ( -
- - -
- )} -
-
- ); -} diff --git a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx index 1ce2d87d27b..96688bf8295 100644 --- a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx +++ b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx @@ -28,18 +28,13 @@ import { usePluginCatalogSearch, type PluginCatalogSearchEntry, } from "@/hooks/queries/plugin-catalog-queries"; -import { PluginShelfGrid } from "./PluginShelf"; import { UpdatePluginDialog } from "./UpdatePluginDialog"; import { PluginLogo, PluginCategoryLabel } from "./plugin-ui"; export function InstalledPluginsTab({ plugins, - showCategory = true, - preview = false, }: { plugins: readonly PluginListItem[]; - showCategory?: boolean; - preview?: boolean; }) { const catalogQuery = usePluginCatalogSearch("", { enabled: true }); const [updateTargetId, setUpdateTargetId] = useState(null); @@ -54,28 +49,21 @@ export function InstalledPluginsTab({ ); } - const cards = plugins.map((plugin) => ( - setUpdateTargetId(plugin.id)} - /> - )); - return ( <> - {preview ? ( - {cards} - ) : ( - - {cards} - - )} + + {plugins.map((plugin) => ( + setUpdateTargetId(plugin.id)} + /> + ))} + {updateTarget !== null ? ( void; }) { const navigate = useNavigate(); @@ -136,7 +122,7 @@ export function InstalledPluginRow({ const openDetail = () => navigate( isPluginsRoutePath(location.pathname) - ? `${getPluginDetailRoutePath({ pluginId: plugin.id })}${location.search || "?view=installed"}` + ? `${getPluginDetailRoutePath({ pluginId: plugin.id })}?view=installed` : getPluginDetailRoutePath({ pluginId: plugin.id, view: "installed" }), ); return ( @@ -172,7 +158,6 @@ export function InstalledPluginRow({ ) : null } footerMeta={ - showCategory && (catalogEntry?.category ?? plugin.category) !== undefined ? ( ; - entryCount: number; - seeAllLink: ReactNode; - children: ReactNode; -}) { - return ( - - ) : shelf.key === "collection:new-and-notable" ? ( - - ) : ( - - ) - } - browseAction={ - entryCount > 2 ? ( - - {seeAllLink} - - ) : undefined - } - > - {children} - - ); -} - -export function PluginShelfGrid({ children }: { children: ReactNode }) { - return ( -
-
- {children} -
-
- ); -} diff --git a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts index 4f67834c4e9..fd2cea10270 100644 --- a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts +++ b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts @@ -10,20 +10,16 @@ export const UNCATEGORIZED_PLUGIN_CATEGORY_ID = "uncategorized"; export type PluginBrowseSort = "recently-added" | "most-installed"; export type PluginBrowseSortDirection = "asc" | "desc"; -export interface PluginCollectionShelf { +export interface PluginBrowseShelf { key: string; categoryId?: string; label: string; description?: string; - entries: Entry[]; + entries: PluginCatalogSearchEntry[]; kind: "collection" | "category" | "uncategorized"; } -export type PluginBrowseShelf = PluginCollectionShelf; - -type PluginCategory = Pick; - -function isCategorized(entry: PluginCategory): boolean { +function isCategorized(entry: PluginCatalogSearchEntry): boolean { return entry.categoryId !== undefined && entry.category !== undefined; } @@ -64,14 +60,7 @@ export function pluginBrowseShelves({ }, ]; }); - return [...shelves, ...pluginCategoryShelves(entries)]; -} - -export function pluginCategoryShelves( - entries: readonly Entry[], -): PluginCollectionShelf[] { - const shelves: PluginCollectionShelf[] = []; - const entriesByCategory = new Map(); + const entriesByCategory = new Map(); const categoryLabels = new Map(); const unknownCategoryOrder: string[] = []; for (const entry of entries) { @@ -125,7 +114,9 @@ export function pluginCategoryShelves( return shelves; } -export function pluginCategoryFilterId(entry: PluginCategory): string { +export function pluginCategoryFilterId( + entry: PluginCatalogSearchEntry, +): string { return isCategorized(entry) ? (entry.categoryId ?? UNCATEGORIZED_PLUGIN_CATEGORY_ID) : UNCATEGORIZED_PLUGIN_CATEGORY_ID; From b594af4ea1f7c2522c95f63556b3d3306610f3a6 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:10:35 +0000 Subject: [PATCH 30/79] Use a neutral Local badge on the shared plugin card --- .../plugin/management/BrowsePluginsTab.tsx | 16 ++++++------ .../plugin/management/InstalledPluginsTab.tsx | 25 +++++++++++-------- .../plugin/management/PluginCard.tsx | 18 +++++++++++-- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 614840e57e3..b5d069ca492 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -49,7 +49,6 @@ import { } from "./plugin-browse-discovery"; import { CatalogEntryIconChip, - PluginCategoryLabel, pluginCatalogCategoryMutedAccentStyle, pluginInstallCountPresentation, } from "./plugin-ui"; @@ -529,13 +528,14 @@ function PluginCatalogCard({ title={entry.displayName} description={entry.description || undefined} byline={} - footerMeta={ - showCategory && entry.category !== undefined ? ( - - ) : undefined + badge={ + showCategory && entry.category !== undefined + ? { + kind: "category", + categoryId: entry.categoryId, + label: entry.category, + } + : null } headerAction={ entry.installed ? ( diff --git a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx index 96688bf8295..27c3b2a19d1 100644 --- a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx +++ b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx @@ -29,7 +29,7 @@ import { type PluginCatalogSearchEntry, } from "@/hooks/queries/plugin-catalog-queries"; import { UpdatePluginDialog } from "./UpdatePluginDialog"; -import { PluginLogo, PluginCategoryLabel } from "./plugin-ui"; +import { PluginLogo } from "./plugin-ui"; export function InstalledPluginsTab({ plugins, @@ -104,6 +104,8 @@ export function InstalledPluginRow({ onSettled: () => invalidatePluginList({ queryClient }), }); const enabled = toggle.isPending ? toggle.variables : plugin.enabled; + const isLocal = plugin.source.startsWith("path:"); + const category = catalogEntry?.category ?? plugin.category; const signal = pluginRowSignal(plugin); const statusSignal = signal?.kind === "status" ? signal : null; const updateSignal = signal?.kind === "update" ? signal : null; @@ -137,9 +139,7 @@ export function InstalledPluginRow({ } title={plugin.name ?? plugin.id} byline={ - plugin.source.startsWith("path:") ? ( - "Local" - ) : catalogEntry !== undefined ? ( + isLocal ? null : catalogEntry !== undefined ? ( ) : plugin.publisherLabel !== null ? ( ) : null } - footerMeta={ - (catalogEntry?.category ?? plugin.category) !== undefined ? ( - - ) : null + badge={ + isLocal + ? { kind: "local" } + : category === undefined + ? null + : { + kind: "category", + categoryId: catalogEntry?.categoryId ?? plugin.categoryId, + label: category, + } } description={ runtimeStatus === null ? ( diff --git a/apps/app/src/components/plugin/management/PluginCard.tsx b/apps/app/src/components/plugin/management/PluginCard.tsx index 878cf724bf4..e139645dd44 100644 --- a/apps/app/src/components/plugin/management/PluginCard.tsx +++ b/apps/app/src/components/plugin/management/PluginCard.tsx @@ -4,19 +4,23 @@ import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-qu import { PluginAuthorAvatar } from "./PluginAuthorAvatar"; import { PluginAuthorLink } from "./PluginAuthorLink"; import { pluginAuthorGithub } from "./plugin-marketplace-author"; +import { PluginCategoryLabel } from "./plugin-ui"; interface PluginCardProps { title: string; description: ReactNode; leading: ReactNode; byline: ReactNode; - footerMeta: ReactNode; + badge: + | { kind: "category"; categoryId: string | undefined; label: string } + | { kind: "local" } + | null; headerAction: ReactNode; openLabel: string; onOpen: (trigger: HTMLButtonElement) => void; } -export function PluginCard(props: PluginCardProps) { +export function PluginCard({ badge, ...props }: PluginCardProps) { return ( {props.title} } + footerMeta={ + badge === null ? null : ( + + ) + } /> ); } From 9c970dcb0ac759b90d0c880dadcde8aafcc2f724 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:01:06 +0000 Subject: [PATCH 31/79] Open Installed plugins through the shared detail panel --- .../src/components/plugin/PluginsOverview.tsx | 17 +- .../management/InstalledPluginsTab.test.tsx | 6 +- .../plugin/management/InstalledPluginsTab.tsx | 20 +- .../plugin/management/PluginRowSignal.tsx | 6 +- .../views/ToolsView.plugin-detail.test.tsx | 198 ++++++++++-------- apps/app/src/views/ToolsView.tsx | 21 +- 6 files changed, 149 insertions(+), 119 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index f40c6e2a697..60caedae761 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -156,11 +156,17 @@ export function PluginsOverview({ ); + const openPlugin = + onOpenPlugin ?? + ((pluginId: string) => + navigate( + getPluginDetailRoutePath({ + pluginId, + view: activeMode === "installed" ? "installed" : undefined, + }), + )); let content: ReactNode; if (activeMode === "browse") { - const openPlugin = - onOpenPlugin ?? - ((pluginId: string) => navigate(getPluginDetailRoutePath({ pluginId }))); content = authorKey === null ? ( ) : ( <> - + - +
, ); diff --git a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx index 27c3b2a19d1..861745ff74b 100644 --- a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx +++ b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx @@ -1,7 +1,6 @@ import { useSetPluginEnabled } from "@/components/plugin/useSetPluginEnabled"; import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useLocation, useNavigate } from "react-router-dom"; import { EmptyState } from "@bb/shared-ui/empty-state"; import { Switch } from "@bb/shared-ui/switch"; import { @@ -13,10 +12,6 @@ import { invalidatePluginList } from "@/hooks/cache-owners/plugin-cache-owner"; import type { PluginListItem } from "@/hooks/queries/plugin-settings-queries"; import { pluginNeedsAttention } from "@/hooks/usePluginAttention"; import { cn } from "@bb/shared-ui/lib/utils"; -import { - getPluginDetailRoutePath, - isPluginsRoutePath, -} from "@/lib/route-paths"; import { pluginRowSignal, pluginRuntimeStatusPresentation, @@ -33,8 +28,10 @@ import { PluginLogo } from "./plugin-ui"; export function InstalledPluginsTab({ plugins, + onOpenPlugin, }: { plugins: readonly PluginListItem[]; + onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; }) { const catalogQuery = usePluginCatalogSearch("", { enabled: true }); const [updateTargetId, setUpdateTargetId] = useState(null); @@ -61,6 +58,7 @@ export function InstalledPluginsTab({ catalogQuery.data?.entries ?? [], )} onUpdateClick={() => setUpdateTargetId(plugin.id)} + onOpenPlugin={onOpenPlugin} /> ))} @@ -81,13 +79,13 @@ export function InstalledPluginRow({ plugin, onUpdateClick, catalogEntry, + onOpenPlugin, }: { plugin: PluginListItem; catalogEntry?: PluginCatalogSearchEntry; onUpdateClick: () => void; + onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; }) { - const navigate = useNavigate(); - const location = useLocation(); const queryClient = useQueryClient(); const setEnabled = useSetPluginEnabled(); const toggle = useMutation({ @@ -121,12 +119,8 @@ export function InstalledPluginRow({ ? "text-warning-text" : "text-muted-foreground"; - const openDetail = () => - navigate( - isPluginsRoutePath(location.pathname) - ? `${getPluginDetailRoutePath({ pluginId: plugin.id })}?view=installed` - : getPluginDetailRoutePath({ pluginId: plugin.id, view: "installed" }), - ); + const openDetail = (trigger: HTMLButtonElement) => + onOpenPlugin(plugin.id, trigger); return (
| null; - onStatusClick: () => void; + onStatusClick: (trigger: HTMLButtonElement) => void; }) { return ( @@ -46,7 +46,7 @@ export function PluginRowSignalView({ }: { signal: PluginRowSignal; onUpdateClick: () => void; - onStatusClick: () => void; + onStatusClick: (trigger: HTMLButtonElement) => void; statusPresentation?: "standalone" | "badge"; }) { if (signal.kind === "update") { @@ -102,7 +102,7 @@ export function PluginRowSignalView({ : "text-muted-foreground hover:text-muted-foreground", )} aria-label={statusDescription} - onClick={onStatusClick} + onClick={(event) => onStatusClick(event.currentTarget)} > { expect(screen.getByTestId("full-trust-warning")).toBeTruthy(); }); - it("preserves Browse and restores card focus across the real app routes", async () => { - const catalogEntry = { - ...GITHUB_CATALOG_ENTRY, - categoryId: "code-and-reviews", - category: "Code & Reviews", - author: { - name: "BB", - github: "get-bb", - url: "https://github.com/get-bb", - }, - }; - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/v1/plugins") { - return new Response(JSON.stringify({ enabled: true, plugins: [] }), { + it.each(["browse", "installed"] as const)( + "preserves %s and restores card focus across the real app routes", + async (mode) => { + const installed = mode === "installed"; + const pluginName = installed ? "Local GitHub" : "GitHub"; + const searchLabel = installed + ? "Search installed plugins" + : "Search plugins"; + const catalogEntry = { + ...GITHUB_CATALOG_ENTRY, + installed, + categoryId: "code-and-reviews", + category: "Code & Reviews", + author: { + name: "BB", + github: "get-bb", + url: "https://github.com/get-bb", + }, + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/v1/plugins") { + return new Response( + JSON.stringify({ + enabled: true, + plugins: installed + ? [makeInstalledPlugin({ id: "github", name: pluginName })] + : [], + }), + { + headers: { "content-type": "application/json" }, + }, + ); + } + if (url.startsWith("/api/v1/plugin-catalog/search")) { + return new Response( + JSON.stringify({ results: [catalogEntry], collections: [] }), + { headers: { "content-type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, headers: { "content-type": "application/json" }, }); - } - if (url.startsWith("/api/v1/plugin-catalog/search")) { - return new Response( - JSON.stringify({ results: [catalogEntry], collections: [] }), - { headers: { "content-type": "application/json" } }, - ); - } - return new Response(JSON.stringify({ error: "not found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - }), - ); - - const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); - render( - - - - - - - , - { wrapper: QueryClientWrapper }, - ); + }), + ); - const card = await screen.findByRole("button", { - name: "Open GitHub details", - }); - const panels = Array.from(document.querySelectorAll("[data-panel]")); - expect(panels).toHaveLength(2); - expect(panels[0]?.getAttribute("data-panel-size")).toBe("100.0"); - expect(panels[1]?.getAttribute("data-panel-size")).toBe("0.0"); - const search = screen.getByRole("textbox", { name: "Search plugins" }); - card.focus(); - fireEvent.click(card); - expect( - await screen.findByRole("button", { name: "Close GitHub" }), - ).toBeTruthy(); - expect( - screen.getByRole("textbox", { name: "Search plugins" }), - ).toBeTruthy(); - expect(screen.getAllByRole("button", { name: /^Close /u })).toHaveLength(1); - expect(Array.from(document.querySelectorAll("[data-panel]"))).toEqual( - panels, - ); - expect(screen.getByRole("textbox", { name: "Search plugins" })).toBe( - search, - ); + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + + + , + { wrapper: QueryClientWrapper }, + ); - fireEvent.click(screen.getByRole("button", { name: "Close GitHub" })); - await waitFor(() => { - expect(screen.getByTestId("route-path").textContent).toBe("/plugins"); - expect(document.activeElement).toBe(card); - }); - expect(Array.from(document.querySelectorAll("[data-panel]"))).toEqual( - panels, - ); - expect(panels[0]?.getAttribute("data-panel-size")).toBe("100.0"); - expect(panels[1]?.getAttribute("data-panel-size")).toBe("0.0"); - expect(screen.getByRole("textbox", { name: "Search plugins" })).toBe( - search, - ); + const card = await screen.findByRole("button", { + name: installed + ? `${pluginName} plugin details` + : "Open GitHub details", + }); + const panels = Array.from(document.querySelectorAll("[data-panel]")); + expect(panels).toHaveLength(2); + expect(panels[0]?.getAttribute("data-panel-size")).toBe("100.0"); + expect(panels[1]?.getAttribute("data-panel-size")).toBe("0.0"); + const search = screen.getByRole("textbox", { name: searchLabel }); + if (installed) { + fireEvent.change(search, { target: { value: "Local GitHub" } }); + } + card.focus(); + fireEvent.click(card); + expect( + await screen.findByRole("button", { name: `Close ${pluginName}` }), + ).toBeTruthy(); + expect(screen.getByRole("textbox", { name: searchLabel })).toBeTruthy(); + expect(screen.getAllByRole("button", { name: /^Close /u })).toHaveLength( + 1, + ); + expect(Array.from(document.querySelectorAll("[data-panel]"))).toEqual( + panels, + ); + expect(screen.getByRole("textbox", { name: searchLabel })).toBe(search); - fireEvent.click(screen.getByRole("button", { name: "Browser back" })); - await waitFor(() => { - expect(screen.getByTestId("route-path").textContent).toBe( - "/plugins/github", + fireEvent.click( + screen.getByRole("button", { name: `Close ${pluginName}` }), ); - }); - }); + await waitFor(() => { + expect(screen.getByTestId("route-path").textContent).toBe("/plugins"); + expect(screen.getByTestId("route-search").textContent).toBe( + installed ? "?view=installed" : "", + ); + expect(document.activeElement).toBe(card); + if (installed) expect(search).toHaveProperty("value", "Local GitHub"); + }); + expect(Array.from(document.querySelectorAll("[data-panel]"))).toEqual( + panels, + ); + expect(panels[0]?.getAttribute("data-panel-size")).toBe("100.0"); + expect(panels[1]?.getAttribute("data-panel-size")).toBe("0.0"); + expect(screen.getByRole("textbox", { name: searchLabel })).toBe(search); + + fireEvent.click(screen.getByRole("button", { name: "Browser back" })); + await waitFor(() => { + expect(screen.getByTestId("route-path").textContent).toBe( + "/plugins/github", + ); + }); + }, + ); it.each([ "/settings/plugins/github?view=installed", @@ -1017,8 +1043,8 @@ describe("BB Official plugin detail routing", () => { "?view=installed", ); expect( - screen.queryByRole("button", { name: "Close Automations" }), - ).toBeNull(); + await screen.findByRole("button", { name: "Close Automations" }), + ).toBeTruthy(); }); it("opens an author from a card and returns to the prior Browse filters", async () => { diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx index 269989f1939..cd2ca1447c7 100644 --- a/apps/app/src/views/ToolsView.tsx +++ b/apps/app/src/views/ToolsView.tsx @@ -19,6 +19,7 @@ import { ConfirmDeleteDialogContent, } from "@/components/dialogs/ConfirmDeleteDialog"; import { AddPluginDialog } from "@/components/plugin/management/AddPluginDialog"; +import { installedPluginCatalogEntry } from "@/components/plugin/management/installed-plugin-catalog"; import { ResourceListState, useResourceRouteLabel, @@ -424,10 +425,7 @@ export function PluginsView({ pluginId }: { pluginId?: string } = {}) { const navigate = useNavigate(); const focusReturnRef = useRef(null); const [isPluginDetailFullPage, setIsPluginDetailFullPage] = useState(false); - const isInstalledDetail = - pluginId !== undefined && - new URLSearchParams(location.search).get("view") === "installed"; - const isPanelOpen = pluginId !== undefined && !isInstalledDetail; + const isPanelOpen = pluginId !== undefined; const catalogQuery = usePluginCatalogSearch("", { enabled: isPanelOpen }); const listQuery = usePluginList({ enabled: true }); @@ -452,12 +450,15 @@ export function PluginsView({ pluginId }: { pluginId?: string } = {}) { if (focusTarget?.isConnected) focusTarget.focus({ preventScroll: true }); }); }, [location.search, navigate]); - const catalogEntry = catalogQuery.data?.entries.find( - (entry) => entry.pluginId === pluginId, - ); const installedPlugin = listQuery.data?.plugins.find( (entry) => entry.id === pluginId, ); + const catalogEntry = installedPlugin + ? installedPluginCatalogEntry( + installedPlugin, + catalogQuery.data?.entries ?? [], + ) + : catalogQuery.data?.entries.find((entry) => entry.pluginId === pluginId); const panelLabel = catalogEntry?.displayName ?? installedPlugin?.name ?? @@ -502,11 +503,7 @@ export function PluginsView({ pluginId }: { pluginId?: string } = {}) { const mainContent = (
}> - {isInstalledDetail && pluginId !== undefined ? ( - - ) : ( - - )} +
); From 7de539d663fa2cb3af087c89fe9454501385b8f5 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:10:34 +0000 Subject: [PATCH 32/79] Share plugin catalog matching and card grid ownership --- .../plugin/management/BrowsePluginsTab.tsx | 157 +----------------- .../plugin/management/InstalledPluginsTab.tsx | 16 +- .../plugin/management/PluginAuthorPage.tsx | 6 +- .../management/PluginBrowseControls.test.tsx | 6 +- .../management/PluginBrowseControls.tsx | 7 +- .../plugin/management/PluginCard.tsx | 25 ++- .../plugin/management/PluginCatalogCard.tsx | 99 +++++++++++ .../installed-plugin-catalog.test.ts | 16 ++ .../management/installed-plugin-catalog.ts | 7 + .../management/plugin-browse-discovery.ts | 60 +++++++ apps/app/src/views/ToolsView.tsx | 17 +- 11 files changed, 228 insertions(+), 188 deletions(-) create mode 100644 apps/app/src/components/plugin/management/PluginCatalogCard.tsx diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index b5d069ca492..1af81a789ad 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { Button } from "@bb/shared-ui/button"; -import { PLUGIN_CATALOG_CATEGORIES, pluginCatalogCategory } from "@bb/domain"; import { DropdownMenu, DropdownMenuContent, @@ -14,10 +13,7 @@ import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; import { cn } from "@bb/shared-ui/lib/utils"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { - ResourceBrowseGrid, ResourceCollectionViewport, - ResourceInstallControl, - ResourceInstalledControl, ResourceListState, ResourceShelfAction, ResourceSourceShelf, @@ -33,25 +29,20 @@ import { type PluginCatalogSearchEntry, } from "@/hooks/queries/plugin-catalog-queries"; import type { AddPluginInitial } from "./AddPluginDialog"; -import { PluginCard, PluginCardAuthor } from "./PluginCard"; +import { PluginCatalogCard, PluginCatalogGrid } from "./PluginCatalogCard"; import { PluginBrowseToolbar, pluginBrowseSort, pluginBrowseSortDirection, - type PluginBrowseCategoryOption, } from "./PluginBrowseControls"; import { - UNCATEGORIZED_PLUGIN_CATEGORY_ID, pluginBrowseShelves, pluginCategoryFilterId, + pluginCategoryFilterOptions, sortPluginEntries, type PluginBrowseShelf, } from "./plugin-browse-discovery"; -import { - CatalogEntryIconChip, - pluginCatalogCategoryMutedAccentStyle, - pluginInstallCountPresentation, -} from "./plugin-ui"; +import { pluginCatalogCategoryMutedAccentStyle } from "./plugin-ui"; const SHELF_ENTRY_LIMIT = 6; @@ -353,60 +344,6 @@ export function BrowsePluginsTab({ ); } -export function pluginCategoryFilterOptions( - entries: readonly PluginCatalogSearchEntry[], - selected: readonly string[], -): PluginBrowseCategoryOption[] { - const labels = new Map(); - const counts = new Map(); - const unknownIds: string[] = []; - for (const entry of entries) { - const id = pluginCategoryFilterId(entry); - if (!labels.has(id)) { - labels.set( - id, - id === UNCATEGORIZED_PLUGIN_CATEGORY_ID - ? "Uncategorized" - : (entry.category ?? id), - ); - if ( - id !== UNCATEGORIZED_PLUGIN_CATEGORY_ID && - pluginCatalogCategory(id) === undefined - ) { - unknownIds.push(id); - } - } - counts.set(id, (counts.get(id) ?? 0) + 1); - } - for (const id of selected) { - if (labels.has(id)) continue; - const category = pluginCatalogCategory(id); - labels.set( - id, - id === UNCATEGORIZED_PLUGIN_CATEGORY_ID - ? "Uncategorized" - : (category?.displayName ?? id), - ); - if (id !== UNCATEGORIZED_PLUGIN_CATEGORY_ID && category === undefined) { - unknownIds.push(id); - } - } - const orderedIds = [ - ...PLUGIN_CATALOG_CATEGORIES.map((category) => category.id).filter((id) => - labels.has(id), - ), - ...unknownIds, - ...(labels.has(UNCATEGORIZED_PLUGIN_CATEGORY_ID) - ? [UNCATEGORIZED_PLUGIN_CATEGORY_ID] - : []), - ]; - return orderedIds.map((id) => ({ - id, - label: labels.get(id) ?? id, - count: counts.get(id) ?? 0, - })); -} - function BrowseShelf({ shelf, onInstall, @@ -483,91 +420,3 @@ function BrowseShelf({ ); } - -export function PluginCatalogGrid({ - entries, - showCategory = true, - onInstall, - onOpenPlugin, -}: { - entries: readonly PluginCatalogSearchEntry[]; - showCategory?: boolean; - onInstall: (initial: AddPluginInitial) => void; - onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; -}) { - return ( - - {entries.map((entry) => ( - - ))} - - ); -} - -function PluginCatalogCard({ - entry, - showCategory, - onInstall, - onOpenPlugin, -}: { - entry: PluginCatalogSearchEntry; - showCategory: boolean; - onInstall: (initial: AddPluginInitial) => void; - onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; -}) { - const count = pluginInstallCountPresentation(entry.installs); - return ( - } - title={entry.displayName} - description={entry.description || undefined} - byline={} - badge={ - showCategory && entry.category !== undefined - ? { - kind: "category", - categoryId: entry.categoryId, - label: entry.category, - } - : null - } - headerAction={ - entry.installed ? ( - - ) : ( - - onInstall({ - entryId: entry.entryId, - marketplace: entry.marketplace, - pluginId: entry.pluginId, - publisherLabel: entry.publisherLabel, - displayName: entry.displayName, - icon: entry.icon, - iconUrl: entry.iconUrl, - iconTinted: entry.iconTinted, - source: entry.source, - }) - } - /> - ) - } - openLabel={`Open ${entry.displayName} details`} - onOpen={(trigger) => onOpenPlugin(entry.pluginId, trigger)} - /> - ); -} diff --git a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx index 861745ff74b..dcc49d5854b 100644 --- a/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx +++ b/apps/app/src/components/plugin/management/InstalledPluginsTab.tsx @@ -3,10 +3,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { EmptyState } from "@bb/shared-ui/empty-state"; import { Switch } from "@bb/shared-ui/switch"; -import { - ResourceBrowseGrid, - ResourceIconFrame, -} from "@bb/shared-ui/resource-list"; +import { ResourceIconFrame } from "@bb/shared-ui/resource-list"; import { appToast } from "@/components/ui/app-toast.js"; import { invalidatePluginList } from "@/hooks/cache-owners/plugin-cache-owner"; import type { PluginListItem } from "@/hooks/queries/plugin-settings-queries"; @@ -17,7 +14,12 @@ import { pluginRuntimeStatusPresentation, } from "./plugin-status"; import { PluginRowSignalView, PluginSignalLogo } from "./PluginRowSignal"; -import { PluginCard, PluginCardAuthor, PluginAuthorByline } from "./PluginCard"; +import { + PluginCard, + PluginCardGrid, + PluginCardAuthor, + PluginAuthorByline, +} from "./PluginCard"; import { installedPluginCatalogEntry } from "./installed-plugin-catalog"; import { usePluginCatalogSearch, @@ -48,7 +50,7 @@ export function InstalledPluginsTab({ return ( <> - + {plugins.map((plugin) => ( ))} - + {updateTarget !== null ? ( + {children} + + ); +} + interface PluginCardProps { title: string; description: ReactNode; diff --git a/apps/app/src/components/plugin/management/PluginCatalogCard.tsx b/apps/app/src/components/plugin/management/PluginCatalogCard.tsx new file mode 100644 index 00000000000..4e8ae97cdb1 --- /dev/null +++ b/apps/app/src/components/plugin/management/PluginCatalogCard.tsx @@ -0,0 +1,99 @@ +import { + ResourceInstallControl, + ResourceInstalledControl, +} from "@bb/shared-ui/resource-list"; +import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; +import type { AddPluginInitial } from "./AddPluginDialog"; +import { PluginCard, PluginCardGrid, PluginCardAuthor } from "./PluginCard"; +import { + CatalogEntryIconChip, + pluginInstallCountPresentation, +} from "./plugin-ui"; + +export function PluginCatalogGrid({ + entries, + showCategory = true, + onInstall, + onOpenPlugin, +}: { + entries: readonly PluginCatalogSearchEntry[]; + showCategory?: boolean; + onInstall: (initial: AddPluginInitial) => void; + onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; +}) { + return ( + + {entries.map((entry) => ( + + ))} + + ); +} + +export function PluginCatalogCard({ + entry, + showCategory, + onInstall, + onOpenPlugin, +}: { + entry: PluginCatalogSearchEntry; + showCategory: boolean; + onInstall: (initial: AddPluginInitial) => void; + onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; +}) { + const count = pluginInstallCountPresentation(entry.installs); + return ( + } + title={entry.displayName} + description={entry.description || undefined} + byline={} + badge={ + showCategory && entry.category !== undefined + ? { + kind: "category", + categoryId: entry.categoryId, + label: entry.category, + } + : null + } + headerAction={ + entry.installed ? ( + + ) : ( + + onInstall({ + entryId: entry.entryId, + marketplace: entry.marketplace, + pluginId: entry.pluginId, + publisherLabel: entry.publisherLabel, + displayName: entry.displayName, + icon: entry.icon, + iconUrl: entry.iconUrl, + iconTinted: entry.iconTinted, + source: entry.source, + }) + } + /> + ) + } + openLabel={`Open ${entry.displayName} details`} + onOpen={(trigger) => onOpenPlugin(entry.pluginId, trigger)} + /> + ); +} diff --git a/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts b/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts index 9f4921bc0c7..c481e6bd87e 100644 --- a/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts +++ b/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts @@ -24,6 +24,7 @@ describe("installed plugin catalog identity", () => { source: "git:https://github.com/alice/notes.git@v1", }, [official, community], + { allowSourceFallback: false }, ), ).toBe(community); }); @@ -58,4 +59,19 @@ describe("installed plugin catalog identity", () => { ), ).toBe(official); }); + it("requires complete installation metadata when source fallback is disabled", () => { + for (const metadata of [ + { catalogEntryId: null, catalogMarketplaceName: null }, + { catalogEntryId: "notes", catalogMarketplaceName: null }, + { catalogEntryId: null, catalogMarketplaceName: "bb-official" }, + ]) { + expect( + installedPluginCatalogEntry( + { id: "notes", source: "builtin:notes", ...metadata }, + [community, official], + { allowSourceFallback: false }, + ), + ).toBeUndefined(); + } + }); }); diff --git a/apps/app/src/components/plugin/management/installed-plugin-catalog.ts b/apps/app/src/components/plugin/management/installed-plugin-catalog.ts index 4d4be632074..774d179edc2 100644 --- a/apps/app/src/components/plugin/management/installed-plugin-catalog.ts +++ b/apps/app/src/components/plugin/management/installed-plugin-catalog.ts @@ -12,8 +12,15 @@ export function installedPluginCatalogEntry< "id" | "source" | "catalogEntryId" | "catalogMarketplaceName" >, entries: readonly Entry[], + { allowSourceFallback = true }: { allowSourceFallback?: boolean } = {}, ): Entry | undefined { if (plugin.source.startsWith("path:")) return undefined; + if ( + !allowSourceFallback && + (plugin.catalogEntryId === null || plugin.catalogMarketplaceName === null) + ) { + return undefined; + } return entries.find( (entry) => entry.pluginId === plugin.id && diff --git a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts index fd2cea10270..e22f1a0a828 100644 --- a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts +++ b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts @@ -19,6 +19,66 @@ export interface PluginBrowseShelf { kind: "collection" | "category" | "uncategorized"; } +export interface PluginBrowseCategoryOption { + id: string; + label: string; + count: number; +} + +export function pluginCategoryFilterOptions( + entries: readonly PluginCatalogSearchEntry[], + selected: readonly string[], +): PluginBrowseCategoryOption[] { + const labels = new Map(); + const counts = new Map(); + const unknownIds: string[] = []; + for (const entry of entries) { + const id = pluginCategoryFilterId(entry); + if (!labels.has(id)) { + labels.set( + id, + id === UNCATEGORIZED_PLUGIN_CATEGORY_ID + ? "Uncategorized" + : (entry.category ?? id), + ); + if ( + id !== UNCATEGORIZED_PLUGIN_CATEGORY_ID && + pluginCatalogCategory(id) === undefined + ) { + unknownIds.push(id); + } + } + counts.set(id, (counts.get(id) ?? 0) + 1); + } + for (const id of selected) { + if (labels.has(id)) continue; + const category = pluginCatalogCategory(id); + labels.set( + id, + id === UNCATEGORIZED_PLUGIN_CATEGORY_ID + ? "Uncategorized" + : (category?.displayName ?? id), + ); + if (id !== UNCATEGORIZED_PLUGIN_CATEGORY_ID && category === undefined) { + unknownIds.push(id); + } + } + const orderedIds = [ + ...PLUGIN_CATALOG_CATEGORIES.map((category) => category.id).filter((id) => + labels.has(id), + ), + ...unknownIds, + ...(labels.has(UNCATEGORIZED_PLUGIN_CATEGORY_ID) + ? [UNCATEGORIZED_PLUGIN_CATEGORY_ID] + : []), + ]; + return orderedIds.map((id) => ({ + id, + label: labels.get(id) ?? id, + count: counts.get(id) ?? 0, + })); +} + function isCategorized(entry: PluginCatalogSearchEntry): boolean { return entry.categoryId !== undefined && entry.category !== undefined; } diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx index cd2ca1447c7..d9bd906cb8e 100644 --- a/apps/app/src/views/ToolsView.tsx +++ b/apps/app/src/views/ToolsView.tsx @@ -200,23 +200,16 @@ function PluginDetailToolView({ pluginId }: { pluginId: string }) { const isLoading = listQuery.isFetching && listQuery.data === undefined; const selectedPlugin = plugins.find((plugin) => plugin.id === pluginId) ?? null; - const selectedCatalogEntryId = selectedPlugin?.catalogEntryId ?? null; - const selectedCatalogMarketplaceName = - selectedPlugin?.catalogMarketplaceName ?? null; const selectedCatalogEntry = selectedPlugin === null ? (catalogQuery.data?.entries.find( (entry) => entry.pluginId === pluginId, ) ?? null) - : selectedCatalogEntryId === null || - selectedCatalogMarketplaceName === null - ? null - : (catalogQuery.data?.entries.find( - (entry) => - entry.pluginId === selectedPlugin.id && - entry.entryId === selectedCatalogEntryId && - entry.marketplace === selectedCatalogMarketplaceName, - ) ?? null); + : (installedPluginCatalogEntry( + selectedPlugin, + catalogQuery.data?.entries ?? [], + { allowSourceFallback: false }, + ) ?? null); useResourceRouteLabel( selectedPlugin?.name ?? selectedPlugin?.id ?? From f70fdc034aecca25b384309fcda8a0d4018e4e7e Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:13:36 +0000 Subject: [PATCH 33/79] Remove unused catalog type after component extraction --- .../src/components/plugin/management/BrowsePluginsTab.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 1af81a789ad..83ff9e3a14b 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -24,10 +24,7 @@ import { BrowseHeroCarousel } from "@/components/plugin/browse-hero/BrowseHeroCa import { nextComposerRequestNonce } from "@/components/plugin/browse-hero/browse-hero-archetypes"; import { TOOLS_PAGE_BAND_CLASSES } from "@/components/tools/tools-navigation"; import { getPluginsRoutePath } from "@/lib/route-paths"; -import { - usePluginCatalogSearch, - type PluginCatalogSearchEntry, -} from "@/hooks/queries/plugin-catalog-queries"; +import { usePluginCatalogSearch } from "@/hooks/queries/plugin-catalog-queries"; import type { AddPluginInitial } from "./AddPluginDialog"; import { PluginCatalogCard, PluginCatalogGrid } from "./PluginCatalogCard"; import { From 2097eb5e0c1691038bd051124e7c967dd7fbf15f Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:30:36 +0000 Subject: [PATCH 34/79] Align source fixture and navigation assertion with shared URL filters --- apps/app/src/components/plugin/PluginsOverview.test.tsx | 4 ++-- apps/app/src/views/ToolsView.plugin-detail.test.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index d34e40823aa..1e37f953784 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -346,8 +346,8 @@ describe("PluginsOverview", () => { ...AUTOMATIONS_PLUGIN, id: "local-notes", name: "Local notes", - source: "/plugins/local-notes", - provenance: "user", + source: "path:/plugins/local-notes", + provenance: "direct", publisherKey: null, publisherLabel: null, }, diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index 6be4d893b9a..28ec1c9256c 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -923,7 +923,7 @@ describe("BB Official plugin detail routing", () => { await waitFor(() => { expect(screen.getByTestId("route-path").textContent).toBe("/plugins"); expect(screen.getByTestId("route-search").textContent).toBe( - installed ? "?view=installed" : "", + installed ? "?view=installed&query=Local+GitHub" : "", ); expect(document.activeElement).toBe(card); if (installed) expect(search).toHaveProperty("value", "Local GitHub"); From 2ad2e2ae43354728dffcd2a64cb8a2d643b4258b Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:37:06 +0000 Subject: [PATCH 35/79] Use registered source icon for installed plugin filter --- apps/app/src/components/plugin/PluginsOverview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 87825d7deac..0e7153a049f 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -255,7 +255,7 @@ export function PluginsOverview({ <> Date: Thu, 17 Sep 2026 05:53:38 +0000 Subject: [PATCH 36/79] Unify plugin collection widths, detail authors, and persistent tabs --- .../plugin/management/BrowsePluginsTab.tsx | 2 +- .../plugin/management/PluginAuthorPage.tsx | 2 +- .../management/PluginBrowseControls.tsx | 2 +- .../plugin/management/PluginCatalogCard.tsx | 2 +- .../PluginMarketplaceListing.test.tsx | 14 +- .../management/PluginMarketplaceListing.tsx | 30 --- .../installed-plugin-catalog.test.ts | 7 +- .../management/installed-plugin-catalog.ts | 13 ++ .../plugin/plugin-workspace-state.ts | 37 +++ .../app/src/components/tools/PluginDetail.tsx | 23 +- .../views/ToolsView.plugin-detail.test.tsx | 112 +++++++++- apps/app/src/views/ToolsView.tsx | 211 ++++++++++++------ 12 files changed, 328 insertions(+), 127 deletions(-) create mode 100644 apps/app/src/components/plugin/plugin-workspace-state.ts diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 83ff9e3a14b..7fc29372c76 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -188,7 +188,7 @@ export function BrowsePluginsTab({ >
{shelfKey !== null ? ( -
+
-
+
void) => void; }) { return ( -
+
void; }) { return ( - + {entries.map((entry) => ( { it("routes the detail author name to the author page", () => { render( - - + + , ); + expect(screen.queryByText(/^By/u)).toBeNull(); expect( screen.getByRole("link", { name: "Pat Lee" }).getAttribute("href"), ).toBe( diff --git a/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx b/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx index 1cb8254ab2e..91d56a19410 100644 --- a/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx +++ b/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx @@ -22,41 +22,11 @@ import { formatUrlLabel, PluginCategoryLabel, } from "./plugin-ui"; -import { PluginAuthorAvatar } from "./PluginAuthorAvatar"; -import { PluginAuthorLink } from "./PluginAuthorLink"; import { entriesByMarketplaceAuthor, - pluginAuthorGithub, pluginMarketplaceAuthorKey, } from "./plugin-marketplace-author"; -export function PluginMarketplaceHeaderMetadata({ - entry, -}: { - entry: PluginCatalogSearchEntry; -}) { - if (entry.author === null) return null; - const author = entry.author; - return ( - - - - By{" "} - - {author.name} - - - - ); -} - export function PluginMarketplaceCategoryPill({ entry, }: { diff --git a/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts b/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts index c481e6bd87e..c67f0a71383 100644 --- a/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts +++ b/apps/app/src/components/plugin/management/installed-plugin-catalog.test.ts @@ -55,7 +55,8 @@ describe("installed plugin catalog identity", () => { catalogMarketplaceName: null, source: "builtin:notes", }, - [community, official], + [{ ...official, marketplace: "untrusted" }, community, official], + { allowSourceFallback: false }, ), ).toBe(official); }); @@ -63,11 +64,11 @@ describe("installed plugin catalog identity", () => { for (const metadata of [ { catalogEntryId: null, catalogMarketplaceName: null }, { catalogEntryId: "notes", catalogMarketplaceName: null }, - { catalogEntryId: null, catalogMarketplaceName: "bb-official" }, + { catalogEntryId: null, catalogMarketplaceName: "community" }, ]) { expect( installedPluginCatalogEntry( - { id: "notes", source: "builtin:notes", ...metadata }, + { id: "notes", source: community.source, ...metadata }, [community, official], { allowSourceFallback: false }, ), diff --git a/apps/app/src/components/plugin/management/installed-plugin-catalog.ts b/apps/app/src/components/plugin/management/installed-plugin-catalog.ts index 774d179edc2..3e67028caaa 100644 --- a/apps/app/src/components/plugin/management/installed-plugin-catalog.ts +++ b/apps/app/src/components/plugin/management/installed-plugin-catalog.ts @@ -15,6 +15,19 @@ export function installedPluginCatalogEntry< { allowSourceFallback = true }: { allowSourceFallback?: boolean } = {}, ): Entry | undefined { if (plugin.source.startsWith("path:")) return undefined; + if ( + plugin.source.startsWith("builtin:") && + plugin.catalogMarketplaceName === null + ) { + return entries.find( + (entry) => + entry.pluginId === plugin.id && + entry.marketplace === "bb-official" && + entry.source === plugin.source && + (plugin.catalogEntryId === null || + entry.entryId === plugin.catalogEntryId), + ); + } if ( !allowSourceFallback && (plugin.catalogEntryId === null || plugin.catalogMarketplaceName === null) diff --git a/apps/app/src/components/plugin/plugin-workspace-state.ts b/apps/app/src/components/plugin/plugin-workspace-state.ts new file mode 100644 index 00000000000..729c07e7769 --- /dev/null +++ b/apps/app/src/components/plugin/plugin-workspace-state.ts @@ -0,0 +1,37 @@ +import { atomWithStorage } from "jotai/utils"; +import { z } from "zod"; +import { createLocalStorageSyncStorage } from "@/lib/browser-storage"; + +const pluginWorkspaceSchema = z.object({ + tabs: z.array(z.string().min(1)).max(100), + activePluginId: z.string().nullable(), +}); + +export type PluginWorkspaceState = z.infer; + +const storage = createLocalStorageSyncStorage({ + parse: (storedValue, initialValue) => { + if (storedValue === null) return initialValue; + try { + const parsed = pluginWorkspaceSchema.safeParse(JSON.parse(storedValue)); + if (!parsed.success) return initialValue; + const tabs = [...new Set(parsed.data.tabs)]; + return { + tabs, + activePluginId: tabs.includes(parsed.data.activePluginId ?? "") + ? parsed.data.activePluginId + : null, + }; + } catch { + return initialValue; + } + }, + serialize: JSON.stringify, +}); + +export const pluginWorkspaceAtom = atomWithStorage( + "bb.plugins.workspace.tabs", + { tabs: [], activePluginId: null }, + storage, + { getOnInit: true }, +); diff --git a/apps/app/src/components/tools/PluginDetail.tsx b/apps/app/src/components/tools/PluginDetail.tsx index 604710a3671..9310bc9a43d 100644 --- a/apps/app/src/components/tools/PluginDetail.tsx +++ b/apps/app/src/components/tools/PluginDetail.tsx @@ -1,3 +1,4 @@ +import { PluginCardAuthor } from "@/components/plugin/management/PluginCard"; import { useSyncExternalStore } from "react"; import { ResourceActivitySection, @@ -35,7 +36,6 @@ import { } from "@/components/plugin/management/plugin-ui"; import { PluginMarketplaceCategoryPill, - PluginMarketplaceHeaderMetadata, PluginMarketplaceListingSections, PluginMoreFromAuthorSection, PluginOverviewLead, @@ -68,7 +68,9 @@ import { useClipboardCopy } from "@/lib/clipboard"; export function PluginProvenancePill({ plugin }: { plugin: PluginListItem }) { const label = plugin.publisherLabel; - return label === null ? null : ; + return label === null || label === "BB Official" ? null : ( + + ); } export function pluginIsLocalSource(plugin: PluginListItem): boolean { @@ -136,7 +138,7 @@ export function CatalogPluginDetail({ leadingClassName="size-10" title={entry.displayName} titleMeta={} - metadata={} + metadata={} actions={ - {catalogEntry === undefined ? null : ( - - )} + {catalogEntry !== undefined ? ( + + ) : plugin.provenance === "builtin" || + plugin.catalogMarketplaceName === "bb-official" ? ( + + ) : null}
} diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index 6be4d893b9a..bc1564cb10c 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -147,6 +147,7 @@ function HistoryBackButton() { afterEach(() => { cleanup(); + window.localStorage.removeItem("bb.plugins.workspace.tabs"); resetPluginSlotStoreForTest(); vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -165,7 +166,7 @@ describe("PluginDetail official catalog lifecycle", () => { ); expect(screen.getByRole("heading", { name: "GitHub" })).toBeTruthy(); - expect(screen.getByText("BB Official")).toBeTruthy(); + expect(screen.getAllByText("BB Official").length).toBeGreaterThan(0); expect(screen.getByText("Developer tools")).toBeTruthy(); expect( screen.getByText("Browse GitHub issues and pull requests in BB."), @@ -589,7 +590,7 @@ describe("PluginDetail official catalog lifecycle", () => { , ); - expect(screen.getByText("BB Official")).toBeTruthy(); + expect(screen.getAllByText("BB Official").length).toBeGreaterThan(0); expect( screen.getByRole("switch", { name: "Disable Automations" }), ).toBeTruthy(); @@ -613,6 +614,111 @@ describe("PluginDetail official catalog lifecycle", () => { }); describe("BB Official plugin detail routing", () => { + it("keeps official attribution and open details while navigating collections", async () => { + const author = { + name: "BB", + github: "get-bb", + url: "https://github.com/get-bb", + }; + const entries = [ + { ...GITHUB_CATALOG_ENTRY, author, installed: true }, + { + ...GITHUB_CATALOG_ENTRY, + author, + entryId: "automations", + pluginId: "automations", + source: "builtin:automations", + displayName: "Automations", + }, + ]; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/v1/plugins") + return new Response( + JSON.stringify({ + enabled: true, + plugins: [ + { + ...GITHUB_PLUGIN, + provenance: "builtin", + catalogEntryId: null, + catalogMarketplaceName: null, + }, + ], + }), + { headers: { "content-type": "application/json" } }, + ); + if (url.startsWith("/api/v1/plugin-catalog/search")) + return new Response( + JSON.stringify({ results: entries, collections: [] }), + { headers: { "content-type": "application/json" } }, + ); + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }), + ); + const { wrapper } = createQueryClientTestHarness(); + render( + + + , + { wrapper }, + ); + const title = await screen.findByRole("heading", { + name: "GitHub", + level: 1, + }); + const header = title.parentElement?.parentElement; + expect(header).not.toBeNull(); + if (!header) throw new Error("Plugin header missing"); + const authorLink = await within(header).findByRole("link", { + name: "BB Official", + }); + expect(within(header).queryByText(/^By/u)).toBeNull(); + expect(within(header).getAllByText("BB Official")).toHaveLength(1); + expect( + within(header).getByRole("img", { name: "BB Official's GitHub avatar" }), + ).toBeTruthy(); + fireEvent.click(authorLink); + expect( + await screen.findByRole("heading", { name: /^BB Official/u, level: 1 }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "GitHub", level: 1 })).toBe( + title, + ); + fireEvent.click( + ( + await screen.findAllByRole("button", { + name: "Open Automations details", + }) + )[0]!, + ); + expect( + await screen.findByRole("button", { name: "Close Automations" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); + fireEvent.click(screen.getByRole("link", { name: "Browse plugins" })); + await waitFor(() => + expect(screen.getByTestId("route-search").textContent).toBe(""), + ); + expect( + screen.getByRole("button", { name: "Close Automations" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Close Automations" })); + expect( + await screen.findByRole("heading", { name: "GitHub", level: 1 }), + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Close Automations" }), + ).toBeNull(); + }); + it("uses the installed catalog identity when plugin ids collide", async () => { const firstCatalogEntry = { ...GITHUB_CATALOG_ENTRY, @@ -1195,7 +1301,7 @@ describe("BB Official plugin detail routing", () => { expect( await screen.findByRole("heading", { name: "More from this author" }), ).toBeTruthy(); - const authorLinks = screen.getAllByRole("link", { name: "BB" }); + const authorLinks = screen.getAllByRole("link", { name: "BB Official" }); fireEvent.click(authorLinks.at(-1)!); await waitFor(() => { expect(screen.getByTestId("route-path").textContent).toBe("/plugins"); diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx index d9bd906cb8e..1b79fdd13c5 100644 --- a/apps/app/src/views/ToolsView.tsx +++ b/apps/app/src/views/ToolsView.tsx @@ -1,7 +1,10 @@ +import { useAtom } from "jotai"; +import { pluginWorkspaceAtom } from "@/components/plugin/plugin-workspace-state"; import { useSetPluginEnabled } from "@/components/plugin/useSetPluginEnabled"; import { Suspense, useCallback, + useEffect, useMemo, useRef, useState, @@ -58,9 +61,15 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { SkillsLibrary } from "@/components/tools/SkillsLibrary"; import { PluginIcon } from "@/components/plugin/PluginIcon"; import { pluginToast } from "@/components/plugin/PluginNotificationDescription"; -import { SecondaryPanelLayout } from "@/components/secondary-panel/SecondaryPanelLayout"; +import { + SecondaryPanelLayout, + type SecondaryPanelRenderArgs, +} from "@/components/secondary-panel/SecondaryPanelLayout"; import { ThreadSecondaryPanel } from "@/components/secondary-panel/ThreadSecondaryPanel"; -import type { SecondaryPanelRenderableTab } from "@/components/secondary-panel/secondaryPanelTab"; +import type { + SecondaryPanelRenderableTab, + SecondaryPanelTabReorderHandler, +} from "@/components/secondary-panel/secondaryPanelTab"; function ResourceBodyFallback() { return ( @@ -418,81 +427,142 @@ export function PluginsView({ pluginId }: { pluginId?: string } = {}) { const navigate = useNavigate(); const focusReturnRef = useRef(null); const [isPluginDetailFullPage, setIsPluginDetailFullPage] = useState(false); - const isPanelOpen = pluginId !== undefined; + const [workspace, setWorkspace] = useAtom(pluginWorkspaceAtom); + const activePluginId = pluginId ?? workspace.activePluginId; + const isPanelOpen = activePluginId !== null; const catalogQuery = usePluginCatalogSearch("", { enabled: isPanelOpen }); const listQuery = usePluginList({ enabled: true }); + const openIds = useMemo( + () => + pluginId !== undefined && !workspace.tabs.includes(pluginId) + ? [...workspace.tabs, pluginId] + : workspace.tabs, + [pluginId, workspace.tabs], + ); - const openPlugin = useCallback( - (nextPluginId: string, trigger: HTMLButtonElement) => { - focusReturnRef.current = trigger; + useEffect(() => { + if (pluginId === undefined) return; + setWorkspace((current) => + current.activePluginId === pluginId && current.tabs.includes(pluginId) + ? current + : { + tabs: current.tabs.includes(pluginId) + ? current.tabs + : [...current.tabs, pluginId], + activePluginId: pluginId, + }, + ); + }, [pluginId, setWorkspace]); + + const selectPlugin = useCallback( + (nextPluginId: string) => { + const params = new URLSearchParams(location.search); navigate({ pathname: getPluginDetailRoutePath({ pluginId: nextPluginId }), - search: location.search, + search: params.toString(), }); }, [location.search, navigate], ); - const closePanel = useCallback(() => { - setIsPluginDetailFullPage(false); - navigate({ - pathname: getPluginsRoutePath(), - search: location.search, - }); - const focusTarget = focusReturnRef.current; + const openPlugin = useCallback( + (nextPluginId: string, trigger: HTMLButtonElement) => { + focusReturnRef.current = trigger; + selectPlugin(nextPluginId); + }, + [selectPlugin], + ); + const restoreFocus = useCallback(() => { + const target = focusReturnRef.current; window.requestAnimationFrame(() => { - if (focusTarget?.isConnected) focusTarget.focus({ preventScroll: true }); + if (target?.isConnected) target.focus({ preventScroll: true }); }); - }, [location.search, navigate]); - const installedPlugin = listQuery.data?.plugins.find( - (entry) => entry.id === pluginId, + }, []); + const closePanel = useCallback(() => { + setIsPluginDetailFullPage(false); + setWorkspace((current) => ({ ...current, activePluginId: null })); + const params = new URLSearchParams(location.search); + navigate({ pathname: getPluginsRoutePath(), search: params.toString() }); + restoreFocus(); + }, [location.search, navigate, restoreFocus, setWorkspace]); + const closeTab = useCallback( + (closedPluginId: string) => { + const tabs = openIds.filter((id) => id !== closedPluginId); + const closedIndex = openIds.indexOf(closedPluginId); + const next = + activePluginId === closedPluginId + ? (tabs[Math.min(closedIndex, tabs.length - 1)] ?? null) + : activePluginId; + setWorkspace({ tabs, activePluginId: next }); + if (next !== null) selectPlugin(next); + else closePanel(); + }, + [activePluginId, closePanel, openIds, selectPlugin, setWorkspace], + ); + const reorderTab = useCallback( + ({ activeTabId, overTabId }) => { + const tabs = [...openIds]; + const from = tabs.findIndex( + (id) => `marketplace-plugin:${id}` === activeTabId, + ); + const to = tabs.findIndex( + (id) => `marketplace-plugin:${id}` === overTabId, + ); + if (from < 0 || to < 0 || from === to) return; + const moving = tabs.splice(from, 1)[0]; + if (moving === undefined) return; + tabs.splice(to, 0, moving); + setWorkspace((current) => ({ ...current, tabs })); + }, + [openIds, setWorkspace], ); - const catalogEntry = installedPlugin - ? installedPluginCatalogEntry( - installedPlugin, - catalogQuery.data?.entries ?? [], - ) - : catalogQuery.data?.entries.find((entry) => entry.pluginId === pluginId); - const panelLabel = - catalogEntry?.displayName ?? - installedPlugin?.name ?? - installedPlugin?.id ?? - pluginId ?? - "Plugin"; - const panelTab = useMemo(() => { - if (!isPanelOpen) return null; - return { - contentFillsRegion: true, - label: panelLabel, - leadingVisual: ( - - ), - onClose: closePanel, - onSelect: () => undefined, - renderContent: () => , - statusLabel: null, - tab: { - id: `marketplace-plugin:${pluginId}`, - kind: "marketplace-plugin-detail", - }, - }; - }, [ - catalogEntry?.icon, - closePanel, - installedPlugin?.compactIconUrl, - installedPlugin?.icon, - isPanelOpen, - panelLabel, - pluginId, - ]); const panelTabs = useMemo( - () => (panelTab === null ? [] : [panelTab]), - [panelTab], + () => + openIds.map((id) => { + const plugin = listQuery.data?.plugins.find( + (candidate) => candidate.id === id, + ); + const entry = + plugin === undefined + ? catalogQuery.data?.entries.find( + (candidate) => candidate.pluginId === id, + ) + : installedPluginCatalogEntry( + plugin, + catalogQuery.data?.entries ?? [], + ); + return { + contentFillsRegion: true, + label: entry?.displayName ?? plugin?.name ?? id, + leadingVisual: ( + + ), + onClose: () => closeTab(id), + onSelect: () => selectPlugin(id), + renderContent: () => , + statusLabel: null, + tab: { + id: `marketplace-plugin:${id}`, + kind: "marketplace-plugin-detail", + }, + }; + }), + [ + catalogQuery.data?.entries, + closeTab, + listQuery.data?.plugins, + openIds, + selectPlugin, + ], ); + const activeTab = + panelTabs.find( + (tab) => tab.tab.id === `marketplace-plugin:${activePluginId}`, + )?.tab ?? null; const mainContent = (
}> @@ -506,19 +576,14 @@ export function PluginsView({ pluginId }: { pluginId?: string } = {}) { isMainCollapsed, onToggleMainCollapse, resizablePanelId, - }: { - presentation: "inline" | "drawer"; - isMainCollapsed: boolean; - onToggleMainCollapse: () => void; - resizablePanelId?: string; - }) => ( + }: SecondaryPanelRenderArgs) => ( undefined} + onTabReorder={reorderTab} isOpen={isPanelOpen} showConversationCollapseControl showNewTabButton={false} @@ -532,7 +597,7 @@ export function PluginsView({ pluginId }: { pluginId?: string } = {}) { resizablePanelId={resizablePanelId} /> ), - [closePanel, isPanelOpen, panelTab?.tab, panelTabs], + [activeTab, closePanel, isPanelOpen, panelTabs, reorderTab], ); return ( @@ -543,7 +608,7 @@ export function PluginsView({ pluginId }: { pluginId?: string } = {}) { onClose={closePanel} panelGroupKey="extensions-plugin-details" resetKey="extensions-plugin-details" - contentKey={pluginId ?? "extensions-plugins"} + contentKey={activePluginId ?? "extensions-plugins"} drawerLabel="Plugin details" drawerFallback={} mainPanelId="extensions-main-panel" From ca23c60dd4d67dde09f9820d73086db267cb16f1 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:59:37 +0000 Subject: [PATCH 37/79] Keep mobile collections accessible while retaining plugin tabs --- .../management/PluginAuthorPage.test.tsx | 25 -- .../views/ToolsView.plugin-detail.test.tsx | 245 ++++++++++-------- apps/app/src/views/ToolsView.tsx | 5 +- 3 files changed, 145 insertions(+), 130 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx b/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx index f1a3189bfcf..6d7fcf6f541 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx @@ -152,31 +152,6 @@ afterEach(() => { }); describe("PluginAuthorPage", () => { - it("aligns the author header with the toolbar and card grid", async () => { - renderPage( - "/plugins?author=12%3Abb-community%3Agithub%3Apatlee", - ); - - await screen.findByRole("heading", { name: /^Pat Lee/u }); - const headerContainer = screen - .getByRole("link", { name: "Browse plugins" }) - .closest(".max-w-3xl"); - const toolbarContainer = screen - .getByRole("textbox", { name: "Search plugins" }) - .closest(".max-w-3xl"); - const gridContainer = screen - .getByRole("button", { name: "Open Alpha details" }) - .closest(".max-w-3xl"); - for (const container of [ - headerContainer, - toolbarContainer, - gridContainer, - ]) { - expect(container?.classList.contains("mx-auto")).toBe(true); - expect(container?.classList.contains("w-full")).toBe(true); - } - }); - it.each([ { rule: "frequency before length", diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index bc1564cb10c..eb82c4e8194 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { createStore, Provider } from "jotai"; import { splitLayoutAtom, maximizedPaneIdAtom } from "@/lib/split-layout/atoms"; import type { ReactNode } from "react"; @@ -614,110 +615,146 @@ describe("PluginDetail official catalog lifecycle", () => { }); describe("BB Official plugin detail routing", () => { - it("keeps official attribution and open details while navigating collections", async () => { - const author = { - name: "BB", - github: "get-bb", - url: "https://github.com/get-bb", - }; - const entries = [ - { ...GITHUB_CATALOG_ENTRY, author, installed: true }, - { - ...GITHUB_CATALOG_ENTRY, - author, - entryId: "automations", - pluginId: "automations", - source: "builtin:automations", - displayName: "Automations", - }, - ]; - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/v1/plugins") - return new Response( - JSON.stringify({ - enabled: true, - plugins: [ - { - ...GITHUB_PLUGIN, - provenance: "builtin", - catalogEntryId: null, - catalogMarketplaceName: null, - }, - ], - }), - { headers: { "content-type": "application/json" } }, - ); - if (url.startsWith("/api/v1/plugin-catalog/search")) - return new Response( - JSON.stringify({ results: entries, collections: [] }), - { headers: { "content-type": "application/json" } }, - ); - return new Response(JSON.stringify({ error: "not found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - }), - ); - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - const title = await screen.findByRole("heading", { - name: "GitHub", - level: 1, - }); - const header = title.parentElement?.parentElement; - expect(header).not.toBeNull(); - if (!header) throw new Error("Plugin header missing"); - const authorLink = await within(header).findByRole("link", { - name: "BB Official", - }); - expect(within(header).queryByText(/^By/u)).toBeNull(); - expect(within(header).getAllByText("BB Official")).toHaveLength(1); - expect( - within(header).getByRole("img", { name: "BB Official's GitHub avatar" }), - ).toBeTruthy(); - fireEvent.click(authorLink); - expect( - await screen.findByRole("heading", { name: /^BB Official/u, level: 1 }), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); - expect(screen.getByRole("heading", { name: "GitHub", level: 1 })).toBe( - title, - ); - fireEvent.click( - ( - await screen.findAllByRole("button", { - name: "Open Automations details", - }) - )[0]!, - ); - expect( - await screen.findByRole("button", { name: "Close Automations" }), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); - fireEvent.click(screen.getByRole("link", { name: "Browse plugins" })); - await waitFor(() => - expect(screen.getByTestId("route-search").textContent).toBe(""), - ); - expect( - screen.getByRole("button", { name: "Close Automations" }), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Close Automations" })); - expect( - await screen.findByRole("heading", { name: "GitHub", level: 1 }), - ).toBeTruthy(); - expect( - screen.queryByRole("button", { name: "Close Automations" }), - ).toBeNull(); - }); + it.each([false, true])( + "keeps official attribution and tabs through collection navigation (compact: %s)", + async (compact) => { + const author = { + name: "BB", + github: "get-bb", + url: "https://github.com/get-bb", + }; + const entries = [ + { ...GITHUB_CATALOG_ENTRY, author, installed: true }, + { + ...GITHUB_CATALOG_ENTRY, + author, + entryId: "automations", + pluginId: "automations", + source: "builtin:automations", + displayName: "Automations", + }, + ]; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/v1/plugins") + return new Response( + JSON.stringify({ + enabled: true, + plugins: [ + makeInstalledPlugin({ + id: "github", + name: "GitHub", + source: "builtin:github", + provenance: "builtin", + publisherLabel: "BB Official", + }), + ], + }), + { headers: { "content-type": "application/json" } }, + ); + if (url.startsWith("/api/v1/plugin-catalog/search")) + return new Response( + JSON.stringify({ results: entries, collections: [] }), + { headers: { "content-type": "application/json" } }, + ); + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }), + ); + const { wrapper } = createQueryClientTestHarness(); + render( + + + + + , + { wrapper }, + ); + const title = await screen.findByRole("heading", { + name: "GitHub", + level: 1, + }); + const header = title.parentElement?.parentElement; + expect(header).not.toBeNull(); + if (!header) throw new Error("Plugin header missing"); + const authorLink = await within(header).findByRole("link", { + name: "BB Official", + }); + expect(within(header).queryByText(/^By/u)).toBeNull(); + expect(within(header).getAllByText("BB Official")).toHaveLength(1); + expect( + within(header).getByRole("img", { + name: "BB Official's GitHub avatar", + }), + ).toBeTruthy(); + fireEvent.click(authorLink); + expect( + await screen.findByRole("heading", { name: /^BB Official/u, level: 1 }), + ).toBeTruthy(); + if (compact) { + await waitFor(() => + expect( + screen.queryByRole("heading", { name: "GitHub", level: 1 }), + ).toBeNull(), + ); + expect( + JSON.parse( + window.localStorage.getItem("bb.plugins.workspace.tabs") ?? "{}", + ).tabs, + ).toContain("github"); + fireEvent.click( + ( + await screen.findAllByRole("button", { + name: "Open Automations details", + }) + )[0]!, + ); + expect( + await screen.findByRole("button", { name: "Close Automations" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Close GitHub" }), + ).toBeTruthy(); + return; + } + expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "GitHub", level: 1 })).toBe( + title, + ); + fireEvent.click( + ( + await screen.findAllByRole("button", { + name: "Open Automations details", + }) + )[0]!, + ); + expect( + await screen.findByRole("button", { name: "Close Automations" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); + fireEvent.click(screen.getByRole("link", { name: "Browse plugins" })); + await waitFor(() => + expect(screen.getByTestId("route-search").textContent).toBe(""), + ); + expect( + screen.getByRole("button", { name: "Close Automations" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { name: "Close Automations" }), + ); + expect( + await screen.findByRole("heading", { name: "GitHub", level: 1 }), + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Close Automations" }), + ).toBeNull(); + }, + ); it("uses the installed catalog identity when plugin ids collide", async () => { const firstCatalogEntry = { diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx index 1b79fdd13c5..b740c711794 100644 --- a/apps/app/src/views/ToolsView.tsx +++ b/apps/app/src/views/ToolsView.tsx @@ -1,3 +1,4 @@ +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { useAtom } from "jotai"; import { pluginWorkspaceAtom } from "@/components/plugin/plugin-workspace-state"; import { useSetPluginEnabled } from "@/components/plugin/useSetPluginEnabled"; @@ -428,8 +429,10 @@ export function PluginsView({ pluginId }: { pluginId?: string } = {}) { const focusReturnRef = useRef(null); const [isPluginDetailFullPage, setIsPluginDetailFullPage] = useState(false); const [workspace, setWorkspace] = useAtom(pluginWorkspaceAtom); + const isCompact = useIsCompactViewport(); const activePluginId = pluginId ?? workspace.activePluginId; - const isPanelOpen = activePluginId !== null; + const isPanelOpen = + activePluginId !== null && (!isCompact || pluginId !== undefined); const catalogQuery = usePluginCatalogSearch("", { enabled: isPanelOpen }); const listQuery = usePluginList({ enabled: true }); const openIds = useMemo( From 785b98d455e0fc4e0f7a16f394991f48baacc6b7 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:25:51 +0000 Subject: [PATCH 38/79] Remove unused plugin grid width override --- .../components/plugin/management/PluginCard.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginCard.tsx b/apps/app/src/components/plugin/management/PluginCard.tsx index 40d4439e52c..805f5a3b6f4 100644 --- a/apps/app/src/components/plugin/management/PluginCard.tsx +++ b/apps/app/src/components/plugin/management/PluginCard.tsx @@ -3,26 +3,16 @@ import { ResourceBrowseCard, ResourceBrowseGrid, } from "@bb/shared-ui/resource-list"; -import { cn } from "@bb/shared-ui/lib/utils"; import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; import { PluginAuthorAvatar } from "./PluginAuthorAvatar"; import { PluginAuthorLink } from "./PluginAuthorLink"; import { pluginAuthorGithub } from "./plugin-marketplace-author"; import { PluginCategoryLabel } from "./plugin-ui"; -export function PluginCardGrid({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { +export function PluginCardGrid({ children }: { children: ReactNode }) { return ( {children} From 9f31ef7f45b919e5ff496e21f639f9735d1c6209 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:59:23 +0000 Subject: [PATCH 39/79] Add consistent plugin filter resets and check updates on entry --- .../plugin/PluginsOverview.test.tsx | 88 ++++++++++++++++++- .../src/components/plugin/PluginsOverview.tsx | 37 ++++---- .../management/PluginBrowseControls.test.tsx | 6 ++ .../management/PluginBrowseControls.tsx | 21 +++-- .../hooks/queries/plugin-catalog-queries.ts | 28 +++++- apps/app/src/hooks/queries/query-keys.ts | 4 + .../src/components/ui/resource/toolbar.tsx | 23 ++++- 7 files changed, 175 insertions(+), 32 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 1e37f953784..27250825617 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -142,6 +142,9 @@ function installFetch(plugins: readonly unknown[] = [AUTOMATIONS_PLUGIN]) { if (url.pathname === "/api/v1/plugins") { return responseJson({ plugins }); } + if (url.pathname === "/api/v1/plugins/updates/check") { + return responseJson({ results: [] }); + } if (url.pathname === "/api/v1/plugin-catalog") { return responseJson({ catalog: { @@ -198,6 +201,85 @@ afterEach(() => { }); describe("PluginsOverview", () => { + it("checks updates on entering Installed, without rechecking on filters or focus", async () => { + installFetch(); + const requestCount = (path: string) => + vi.mocked(fetch).mock.calls.filter(([input]) => + String(input).endsWith(path), + ).length; + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + + + , + ); + await screen.findByRole("textbox", { name: "Search plugins" }); + expect(requestCount("/plugins/updates/check")).toBe(0); + fireEvent.click(screen.getByRole("button", { name: "switch-to-installed" })); + await screen.findByTestId("plugin-row-automations"); + await waitFor(() => { + expect(requestCount("/plugins/updates/check")).toBe(1); + expect(requestCount("/api/v1/plugins")).toBeGreaterThan(1); + }); + fireEvent.change( + screen.getByRole("textbox", { name: "Search installed plugins" }), + { target: { value: "Automations" } }, + ); + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + expect(requestCount("/plugins/updates/check")).toBe(1); + fireEvent.click(screen.getByRole("button", { name: "switch-to-browse" })); + fireEvent.click(screen.getByRole("button", { name: "switch-to-installed" })); + await waitFor(() => + expect(requestCount("/plugins/updates/check")).toBe(2), + ); + }); + + it("clears only Source while retaining search, category, and sort", async () => { + installFetch(); + function LocationSearch() { + return {useLocation().search}; + } + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + + , + ); + fireEvent.pointerDown( + await screen.findByRole("button", { name: "Source: 1 selected" }), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear filter" })); + const params = new URLSearchParams( + screen.getByTestId("location-search").textContent ?? "", + ); + expect([...params]).toEqual([ + ["view", "installed"], + ["query", "Automations"], + ["category", "tasks-and-workflows"], + ["sort", "name"], + ["direction", "desc"], + ]); + expect( + screen + .getByRole("menuitem", { name: "Clear filter" }) + .getAttribute("aria-disabled"), + ).toBe("true"); + }); + it("opens on Browse and renders it before Installed", async () => { installFetch(); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); @@ -663,7 +745,11 @@ describe("PluginsOverview", () => { "plugin-row-inactive-local", "plugin-row-inactive-official", ]); - expect(screen.queryByRole("menuitem", { name: "Clear sort" })).toBeNull(); + expect( + screen + .getByRole("menuitem", { name: "Clear sort" }) + .getAttribute("aria-disabled"), + ).toBe("true"); }); it("filters Installed by catalog and local categories and clears the selection", async () => { diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 0e7153a049f..4f24bbc035d 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -25,10 +25,12 @@ import { type AddPluginInitial, } from "@/components/plugin/management/AddPluginDialog"; import { BrowsePluginsTab } from "@/components/plugin/management/BrowsePluginsTab"; -import { CheckPluginUpdatesButton } from "@/components/plugin/management/CheckPluginUpdatesButton"; import { InstalledPluginsTab } from "@/components/plugin/management/InstalledPluginsTab"; import { PluginAuthorPage } from "@/components/plugin/management/PluginAuthorPage"; -import { usePluginCatalogSearch } from "@/hooks/queries/plugin-catalog-queries"; +import { + usePluginCatalogSearch, + usePluginUpdateCheck, +} from "@/hooks/queries/plugin-catalog-queries"; import { installedPluginCatalogEntry } from "./management/installed-plugin-catalog"; import { PluginCollectionToolbar } from "./management/PluginBrowseControls"; import { @@ -66,6 +68,7 @@ export function PluginsOverview({ ); const activeMode = mode ?? (searchParams.get("view") === "installed" ? "installed" : "browse"); + usePluginUpdateCheck(null, { enabled: activeMode === "installed" }); const authorKey = searchParams.get("author"); const catalogQuery = usePluginCatalogSearch("", { enabled: activeMode === "installed", @@ -252,22 +255,20 @@ export function PluginsOverview({ changeSearchParams={changeSearchParams} action={installedActions} additionalControls={ - <> - - changeSearchParams((next) => { - next.delete("source"); - for (const value of values) next.append("source", value); - }) - } - /> - {plugins.length > 0 ? : null} - + + changeSearchParams((next) => { + next.delete("source"); + for (const value of values) next.append("source", value); + }) + } + /> } /> } diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 8d556d6eed2..870c4cdf084 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -69,6 +69,9 @@ describe("PluginBrowseCategoryFilter", () => { } render(); openMenu("All categories"); + expect( + screen.getByRole("button", { name: "Clear filter" }).hasAttribute("disabled"), + ).toBe(true); fireEvent.click(screen.getByRole("option", { name: /Security/u })); fireEvent.click(screen.getByRole("option", { name: /Tasks & Workflows/u })); @@ -83,6 +86,9 @@ describe("PluginBrowseCategoryFilter", () => { }), ).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + expect( + screen.getByRole("button", { name: "Clear filter" }).hasAttribute("disabled"), + ).toBe(true); expect( screen.getByRole("button", { name: "Filter plugins by category: All categories", diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index e00359521b0..3b18ee3c773 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -412,17 +412,16 @@ export function PluginBrowseCategoryFilter({ /> ) : null}
- {value.length > 0 ? ( -
- -
- ) : null} +
+ +
); diff --git a/apps/app/src/hooks/queries/plugin-catalog-queries.ts b/apps/app/src/hooks/queries/plugin-catalog-queries.ts index 655fce56ee6..5b29bc19d3e 100644 --- a/apps/app/src/hooks/queries/plugin-catalog-queries.ts +++ b/apps/app/src/hooks/queries/plugin-catalog-queries.ts @@ -11,7 +11,8 @@ import type { PluginSourceDetail as SdkPluginSourceDetail, PluginUpdateCheckEntry, } from "@bb/server-contract"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { invalidatePluginList } from "../cache-owners/plugin-cache-owner"; import { createPluginsClient } from "./plugin-client"; import { toEpochMs } from "./plugin-settings-queries"; import { @@ -19,6 +20,7 @@ import { pluginCatalogSearchQueryKey, pluginMarketplacesQueryKey, pluginSourceQueryKey, + pluginUpdateCheckQueryKey, } from "./query-keys"; type FetchLike = typeof fetch; @@ -187,6 +189,30 @@ export async function checkPluginUpdates( return results.map(toUpdatesEntry); } +export function usePluginUpdateCheck( + pluginId: string | null, + options: { enabled: boolean }, +) { + const queryClient = useQueryClient(); + return useQuery({ + queryKey: pluginUpdateCheckQueryKey(pluginId), + queryFn: async () => { + const results = await checkPluginUpdates( + fetch, + pluginId === null ? {} : { id: pluginId }, + ); + await invalidatePluginList({ queryClient }); + return results; + }, + enabled: options.enabled, + staleTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }); +} + export interface PluginUpdateResult { applied: boolean; outcome: SdkPluginApplyUpdateResult["outcome"]; diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 4a3a124fcc6..30c686a8c61 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -1247,6 +1247,10 @@ export function allPluginSettingsQueryKeyPrefix() { return [PLUGIN_SDK_SETTINGS_QUERY_KEY] as const; } +export function pluginUpdateCheckQueryKey(pluginId: string | null) { + return ["plugins", "update-check", pluginId] as const; +} + export function pluginSourceQueryKey(pluginId: string) { return [PLUGIN_SOURCE_QUERY_KEY, pluginId] as const; } diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 2c7f9714738..d0c4ec1cac5 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -190,6 +190,7 @@ export function ResourceMultiSelectMenu({ options, onChange, compact = false, + clearInFooter = false, }: { label: string; icon: IconName; @@ -197,6 +198,7 @@ export function ResourceMultiSelectMenu({ options: readonly ResourceOption[]; onChange: (values: string[]) => void; compact?: boolean; + clearInFooter?: boolean; }) { const [open, setOpen] = useState(false); const selected = new Set(selectedValues); @@ -250,6 +252,24 @@ export function ResourceMultiSelectMenu({ ))} + {clearInFooter ? ( + <> + + { + event.preventDefault(); + onChange([]); + }} + className={cn( + "text-xs text-muted-foreground", + compact && "md:px-1.5 md:py-1", + )} + > + Clear filter + + + ) : null} ); @@ -455,10 +475,11 @@ export function ResourceSortMenu({ ); })} - {clearInFooter && onClear !== undefined && value !== null ? ( + {clearInFooter && onClear !== undefined ? ( <> { event.preventDefault(); onClear(); From 8516b658cb6090864791eb647695fb1ffae985c8 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:35:01 +0000 Subject: [PATCH 40/79] Unify plugin toolbar controls and collapse filters in narrow panes --- .../src/components/plugin/PluginsOverview.tsx | 26 +- .../management/PluginBrowseControls.test.tsx | 216 ++++++- .../management/PluginBrowseControls.tsx | 588 +++++++++++------- .../src/components/ui/resource-list.tsx | 4 + .../src/components/ui/resource/toolbar.tsx | 447 ++++++++----- 5 files changed, 874 insertions(+), 407 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 4f24bbc035d..b324c7cbd2b 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -14,7 +14,6 @@ import { ResourceCollectionPage, ResourceCollectionViewport, ResourceListState, - ResourceMultiSelectMenu, } from "@bb/shared-ui/resource-list"; import { cn } from "@bb/shared-ui/lib/utils"; import { CreateWithTemplatesButton } from "@/components/create-via-prompt-examples"; @@ -254,22 +253,15 @@ export function PluginsOverview({ installsKnown={installsKnown} changeSearchParams={changeSearchParams} action={installedActions} - additionalControls={ - - changeSearchParams((next) => { - next.delete("source"); - for (const value of values) next.append("source", value); - }) - } - /> - } + sourceFilter={{ + options: sourceFilterOptions, + selectedValues: activeSourceFilters, + onChange: (values) => + changeSearchParams((next) => { + next.delete("source"); + for (const value of values) next.append("source", value); + }), + }} /> } > diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 870c4cdf084..3f96cab9ad6 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -1,9 +1,19 @@ // @vitest-environment jsdom import { useState } from "react"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { PluginBrowseCategoryFilter } from "./PluginBrowseControls"; +import { + PluginBrowseCategoryFilter, + PluginCollectionToolbar, +} from "./PluginBrowseControls"; import type { PluginBrowseCategoryOption } from "./plugin-browse-discovery"; const OPTIONS: PluginBrowseCategoryOption[] = [ @@ -23,6 +33,7 @@ function openMenu(selectionLabel: string) { afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("PluginBrowseCategoryFilter", () => { @@ -70,7 +81,9 @@ describe("PluginBrowseCategoryFilter", () => { render(); openMenu("All categories"); expect( - screen.getByRole("button", { name: "Clear filter" }).hasAttribute("disabled"), + screen + .getByRole("button", { name: "Clear filter" }) + .hasAttribute("disabled"), ).toBe(true); fireEvent.click(screen.getByRole("option", { name: /Security/u })); fireEvent.click(screen.getByRole("option", { name: /Tasks & Workflows/u })); @@ -87,7 +100,9 @@ describe("PluginBrowseCategoryFilter", () => { ).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); expect( - screen.getByRole("button", { name: "Clear filter" }).hasAttribute("disabled"), + screen + .getByRole("button", { name: "Clear filter" }) + .hasAttribute("disabled"), ).toBe(true); expect( screen.getByRole("button", { @@ -159,3 +174,196 @@ describe("PluginBrowseCategoryFilter", () => { expect(firstList.contains(document.activeElement)).toBe(false); }); }); + +function ToolbarHarness({ + installed = false, + categoryShelf = false, +}: { + installed?: boolean; + categoryShelf?: boolean; +}) { + const [params, setParams] = useState( + new URLSearchParams( + "query=Memory&category=security&source=user&sort=name&direction=desc", + ), + ); + const changeSearchParams = (change: (next: URLSearchParams) => void) => { + setParams((previous) => { + const next = new URLSearchParams(previous); + change(next); + return next; + }); + }; + return ( + <> + + changeSearchParams((next) => { + next.delete("source"); + values.forEach((value) => next.append("source", value)); + }), + } + : undefined + } + /> + {params.toString()} + + ); +} + +function mockToolbarWidth(initial: number) { + let width = initial; + const callbacks = new Set<() => void>(); + const original = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function () { + if (this.hasAttribute("data-resource-toolbar")) + return new DOMRect(0, 0, width, 32); + return original.call(this); + }, + ); + vi.stubGlobal( + "ResizeObserver", + class { + callback: () => void; + constructor(callback: () => void) { + this.callback = callback; + } + observe() { + callbacks.add(this.callback); + } + unobserve() {} + disconnect() { + callbacks.delete(this.callback); + } + }, + ); + return (next: number) => + act(() => { + width = next; + callbacks.forEach((callback) => callback()); + }); +} + +describe("PluginCollectionToolbar", () => { + it.each([ + { + installed: true, + categoryShelf: false, + labels: ["Sort by", "Category", "Source"], + }, + { installed: false, categoryShelf: false, labels: ["Sort by", "Category"] }, + { installed: false, categoryShelf: true, labels: ["Sort by"] }, + ])( + "preserves the allowed overflow controls for %j", + ({ labels, ...props }) => { + mockToolbarWidth(320); + render(); + fireEvent.click(screen.getByRole("button", { name: "Plugin controls" })); + expect( + screen + .getAllByRole("menuitem") + .map((item) => item.textContent?.replace("Active", "")), + ).toEqual(labels); + expect(screen.queryByRole("button", { name: /^Sort:/u })).toBeNull(); + }, + ); + + it("shares sort, category search, and source clearing in overflow without resetting other values", async () => { + mockToolbarWidth(320); + render(); + fireEvent.click(screen.getByRole("button", { name: "Plugin controls" })); + fireEvent.click(screen.getByRole("menuitem", { name: /^Sort by/u })); + const sort = screen.getByRole("menuitemradio", { name: "Plugin name" }); + await waitFor(() => expect(document.activeElement).toBe(sort)); + fireEvent.click(sort); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "direction=asc", + ); + expect( + screen + .getByRole("menuitemradio", { name: "Most installed" }) + .getAttribute("aria-disabled"), + ).toBe("true"); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Memory&category=security&source=user", + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Back to controls" })); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: "Sort by" }), + ), + ); + fireEvent.click(screen.getByRole("menuitem", { name: /^Category/u })); + const search = screen.getByRole("combobox", { + name: "Search plugin categories", + }); + await waitFor(() => expect(document.activeElement).toBe(search)); + fireEvent.change(search, { target: { value: "security" } }); + fireEvent.keyDown(search, { key: "ArrowDown" }); + expect(document.activeElement).toBe( + screen.getByRole("option", { name: /Security/u }), + ); + fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Memory&source=user", + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Back to controls" })); + fireEvent.click(screen.getByRole("menuitem", { name: /^Source/u })); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear filter" })); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Memory", + ); + fireEvent.keyDown(document.activeElement ?? document.body, { + key: "Escape", + }); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Plugin controls" }), + ), + ); + }); + + it("moves focused controls into overflow and back without losing search or selection", () => { + const resize = mockToolbarWidth(600); + render(); + screen.getByRole("button", { name: /^Source:/u }).focus(); + resize(320); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Plugin controls" }), + ); + fireEvent.change(screen.getByRole("textbox", { name: "Search plugins" }), { + target: { value: "Notes" }, + }); + resize(600); + expect( + screen.queryByRole("button", { name: "Plugin controls" }), + ).toBeNull(); + expect(screen.getByRole("button", { name: /^Source: 1/u })).toBeTruthy(); + expect( + screen + .getByRole("textbox", { name: "Search plugins" }) + .getAttribute("value"), + ).toBe("Notes"); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "category=security&source=user&sort=name&direction=desc", + ); + }); +}); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 3b18ee3c773..11e652d5f7b 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -6,18 +6,26 @@ import { useState, type ReactNode, } from "react"; -import { Button } from "@bb/shared-ui/button"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, -} from "@bb/shared-ui/tooltip"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; import { Input } from "@bb/shared-ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@bb/shared-ui/popover"; import { cn } from "@bb/shared-ui/lib/utils"; -import { ResourceSortMenu, ResourceToolbar } from "@bb/shared-ui/resource-list"; +import { + ResourceControlButton, + ResourceMultiSelectMenu, + ResourceMultiSelectMenuItems, + ResourceSortMenu, + ResourceSortMenuItems, + ResourceToolbar, + type ResourceOption, +} from "@bb/shared-ui/resource-list"; import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; import type { PluginBrowseSort, @@ -63,12 +71,16 @@ export function PluginCollectionToolbar({ changeSearchParams, searchPlaceholder = "Search plugins", action, - additionalControls, + sourceFilter, className, }: { searchPlaceholder?: string; action?: ReactNode; - additionalControls?: ReactNode; + sourceFilter?: { + options: readonly ResourceOption[]; + selectedValues: readonly string[]; + onChange: (values: string[]) => void; + }; className?: string; query: string; selectedCategories: readonly string[]; @@ -79,6 +91,72 @@ export function PluginCollectionToolbar({ installsKnown: boolean; changeSearchParams: (change: (next: URLSearchParams) => void) => void; }) { + const sortProps = { + value: sort, + direction: sortDirection, + compact: true, + clearInFooter: true, + placeholderLabel: "Default", + options: pluginBrowseSortOptions(installsKnown), + onChange: (value: string) => + changeSearchParams((next) => { + if (value === sort) { + next.set("direction", sortDirection === "asc" ? "desc" : "asc"); + } else { + next.set("sort", value); + next.set("direction", value === "name" ? "asc" : "desc"); + } + }), + onClear: () => + changeSearchParams((next) => { + next.delete("sort"); + next.delete("direction"); + }), + }; + const categoryProps = { + value: selectedCategories, + options: categoryOptions, + onChange: (values: string[]) => + changeSearchParams((next) => { + next.delete("category"); + for (const value of values) next.append("category", value); + }), + }; + const sourceProps = sourceFilter + ? { ...sourceFilter, label: "Source", compact: true, clearInFooter: true } + : null; + const controls = [ + { + id: "sort", + label: "Sort by", + icon: "ArrowUpDown", + active: sort !== null, + content: , + }, + ...(showCategoryFilter + ? [ + { + id: "category", + label: "Category", + icon: "SlidersHorizontal", + active: selectedCategories.length > 0, + content: , + } satisfies PluginControlPage, + ] + : []), + ...(sourceProps + ? [ + { + id: "source", + label: "Source", + icon: "Layers", + active: sourceProps.selectedValues.length > 0, + content: , + } satisfies PluginControlPage, + ] + : []), + ] satisfies PluginControlPage[]; + return (
- - changeSearchParams((next) => { - if (value === sort) { - next.set( - "direction", - sortDirection === "asc" ? "desc" : "asc", - ); - } else { - next.set("sort", value); - next.set("direction", value === "name" ? "asc" : "desc"); - } - }) - } - onClear={() => - changeSearchParams((next) => { - next.delete("sort"); - next.delete("direction"); - }) - } - /> + {showCategoryFilter ? ( - - changeSearchParams((next) => { - next.delete("category"); - for (const value of values) { - next.append("category", value); - } - }) - } - /> + + ) : null} + {sourceProps ? ( + ) : null} - {additionalControls} } + overflowControls={} />
); } -const ENGAGED_CONTROL_CLASS = - "bg-state-active text-foreground hover:bg-state-active"; +type PluginControlPage = { + id: string; + label: string; + icon: IconName; + active: boolean; + content: ReactNode; +}; + +function PluginControlsMenu({ + pages, +}: { + pages: readonly PluginControlPage[]; +}) { + const [open, setOpen] = useState(false); + const [pageId, setPageId] = useState(null); + const contentRef = useRef(null); + const returnPageRef = useRef(null); + const page = pages.find((candidate) => candidate.id === pageId); + const activeLabels = pages + .filter((candidate) => candidate.active) + .map((candidate) => candidate.label); + + useEffect(() => { + if (!open) return; + const frame = requestAnimationFrame(() => { + const content = contentRef.current; + if (pageId) { + content + ?.querySelector( + 'input, [role="menuitemradio"], [role="menuitemcheckbox"]', + ) + ?.focus(); + } else if (returnPageRef.current) { + content + ?.querySelector( + `[data-control-page="${returnPageRef.current}"]`, + ) + ?.focus(); + } + }); + return () => cancelAnimationFrame(frame); + }, [open, pageId]); + + return ( + { + setOpen(next); + if (!next) { + setPageId(null); + returnPageRef.current = null; + } + }} + > + + 0} + open={open} + /> + + + {page ? ( + <> + { + event.preventDefault(); + returnPageRef.current = page.id; + setPageId(null); + }} + > + + Back to controls + + + {page.content} + + ) : ( + pages.map((control) => ( + { + event.preventDefault(); + setPageId(control.id); + }} + > + + {control.label} + {control.active ? ( + Active + ) : null} + + + )) + )} + + + ); +} const SCROLLBAR_IDLE_DELAY_MS = 600; @@ -166,23 +319,15 @@ function CategoryOptionCheckbox({ enabled }: { enabled: boolean }) { ); } -export function PluginBrowseCategoryFilter({ - options, - value, - onChange, -}: { +type PluginCategoryFilterProps = { options: readonly PluginBrowseCategoryOption[]; value: readonly string[]; onChange: (value: string[]) => void; -}) { +}; + +export function PluginBrowseCategoryFilter(props: PluginCategoryFilterProps) { + const { options, value } = props; const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const [showKeyboardFocus, setShowKeyboardFocus] = useState(false); - const [scrollbarScrolling, setScrollbarScrolling] = useState(false); - const listboxId = useId(); - const inputRef = useRef(null); - const keyboardFocusRef = useRef(false); - const selected = new Set(value); const selectedOptions = value.flatMap((selectedId) => { const option = options.find((candidate) => candidate.id === selectedId); return option === undefined ? [] : [option]; @@ -191,6 +336,40 @@ export function PluginBrowseCategoryFilter({ selectedOptions.length === 0 ? "All categories" : selectedOptions.map((option) => option.label).join(", "); + return ( + + + 0} + open={open} + /> + + + {open ? : null} + + + ); +} + +function PluginCategoryOptions({ + options, + value, + onChange, +}: PluginCategoryFilterProps) { + const [search, setSearch] = useState(""); + const [showKeyboardFocus, setShowKeyboardFocus] = useState(false); + const [scrollbarScrolling, setScrollbarScrolling] = useState(false); + const listboxId = useId(); + const inputRef = useRef(null); + const keyboardFocusRef = useRef(false); + const selected = new Set(value); const normalizedSearch = search.trim().toLocaleLowerCase(); const filteredOptions = options.filter((option) => `${option.label} ${option.id}` @@ -217,12 +396,11 @@ export function PluginBrowseCategoryFilter({ const scrollbarIdleRef = useRef(null); useEffect(() => { - if (!open) return; const animationFrame = requestAnimationFrame(() => inputRef.current?.focus(), ); return () => cancelAnimationFrame(animationFrame); - }, [open]); + }, []); useEffect( () => () => { @@ -258,172 +436,121 @@ export function PluginBrowseCategoryFilter({ }; return ( - { - setOpen(nextOpen); - if (!nextOpen) setSearch(""); - }} - > - - - - - - - - {`Category: ${accessibleSelectionLabel}`} - - - -
- - setSearch(event.target.value)} - placeholder="Search categories" - aria-label="Search plugin categories" - role="combobox" - aria-controls={listboxId} - aria-expanded={open} - aria-autocomplete="list" - className={cn( - "h-7 border-transparent bg-surface-recessed pl-7 pr-2 text-xs focus-visible:ring-0", - showKeyboardFocus && "ring-1 ring-ring", - )} - onFocus={() => setShowKeyboardFocus(keyboardFocusRef.current)} - onBlur={() => setShowKeyboardFocus(false)} - onPointerDown={() => { - keyboardFocusRef.current = false; - setShowKeyboardFocus(false); - }} - onKeyDown={(event) => { - keyboardFocusRef.current = true; - setShowKeyboardFocus(true); - if (event.key === "ArrowDown") { - event.preventDefault(); - categoryOptionElements(listElement)[0]?.focus(); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - categoryOptionElements(listElement).at(-1)?.focus(); - } else if (event.key !== "Escape" && event.key !== "Tab") { - event.stopPropagation(); - } - }} - /> -
-
-
-
- {filteredOptions.length === 0 ? ( -

- {options.length === 0 - ? "No categories are available." - : "No categories match your search."} -

- ) : ( - filteredOptions.map((option) => { - return ( - - ); - }) - )} -
-
- {belowOverflow ? ( -
- ) : null} -
-
- + + + {option.label} + + + + ); + }) + )} +
- - + {belowOverflow ? ( +
+ ) : null} +
+
+ +
+ ); } @@ -442,6 +569,7 @@ function focusCategoryOption( else if (event.key === "End") nextIndex = options.length - 1; if (nextIndex === null) return; event.preventDefault(); + event.stopPropagation(); options[nextIndex]?.focus(); } diff --git a/packages/shared-ui/src/components/ui/resource-list.tsx b/packages/shared-ui/src/components/ui/resource-list.tsx index bd76b2384d4..fbd8268a46b 100644 --- a/packages/shared-ui/src/components/ui/resource-list.tsx +++ b/packages/shared-ui/src/components/ui/resource-list.tsx @@ -7,6 +7,10 @@ export { } from "./resource/atoms"; export { useResourceRouteLabel } from "./resource-route-label"; export { + ResourceControlButton, + ResourceMultiSelectMenuItems, + ResourceSortMenuItems, + type ResourceOption, ResourceCreateButton, type ResourceCreateMenuAction, type ResourceCreateTemplateGroup, diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index d0c4ec1cac5..f67d22f848f 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -1,5 +1,12 @@ -import { Fragment, useState, type ReactNode } from "react"; -import { Button } from "../button"; +import { + Fragment, + forwardRef, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Button, type ButtonProps } from "../button"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -26,6 +33,7 @@ export function ResourceToolbar({ searchLabel, onSearchChange, controls, + overflowControls, action, compact = false, }: { @@ -34,11 +42,48 @@ export function ResourceToolbar({ searchLabel?: string; onSearchChange: (value: string) => void; controls?: ReactNode; + overflowControls?: ReactNode; action?: ReactNode; compact?: boolean; }) { + const toolbarRef = useRef(null); + const controlsRef = useRef(null); + const [overflow, setOverflow] = useState(false); + const restoreControlFocus = useRef(false); + const overflowRef = useRef(false); + const hasOverflowControls = Boolean(overflowControls); + + useLayoutEffect(() => { + const toolbar = toolbarRef.current; + if (!toolbar || !compact || !hasOverflowControls) return; + const measure = () => { + const width = toolbar.getBoundingClientRect().width; + if (width === 0) return; + const next = width < 384; + if (overflowRef.current === next) return; + restoreControlFocus.current = Boolean( + controlsRef.current?.contains(document.activeElement) || + controlsRef.current?.querySelector('[data-state="open"]'), + ); + overflowRef.current = next; + setOverflow(next); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(toolbar); + return () => observer.disconnect(); + }, [compact, hasOverflowControls]); + + useLayoutEffect(() => { + if (!restoreControlFocus.current) return; + controlsRef.current?.querySelector("button")?.focus(); + restoreControlFocus.current = false; + }, [overflow]); + return (
{controls ? ( -
{controls}
+
+ {overflow && overflowControls ? overflowControls : controls} +
) : null} {action ? (
@@ -129,43 +178,62 @@ const RESOURCE_MENU_TRIGGER_ENGAGED_CLASS = const RESOURCE_MENU_TRIGGER_RESTING_CLASS = "border border-input bg-background"; -function ResourceMenuTrigger({ - label, - icon, - active = false, - open = false, - tooltip = label, -}: { - label: string; - icon: IconName; - active?: boolean; - open?: boolean; - tooltip?: ReactNode; -}) { +export const ResourceControlButton = forwardRef< + HTMLButtonElement, + ButtonProps & { + label: string; + icon: IconName; + active?: boolean; + open?: boolean; + tooltip?: ReactNode; + } +>(function ResourceControlButton( + { + label, + icon, + active = false, + open = false, + tooltip = label, + className, + ...props + }, + ref, +) { return ( - - - + {tooltip} ); +}); + +function ResourceMenuTrigger( + props: React.ComponentProps, +) { + return ( + + + + ); } function nextSelectedValues( @@ -183,6 +251,70 @@ function nextSelectedValues( return [...next]; } +export function ResourceMultiSelectMenuItems({ + label, + selectedValues, + options, + onChange, + compact = false, + clearInFooter = false, +}: { + label: string; + selectedValues: readonly string[]; + options: readonly ResourceOption[]; + onChange: (values: string[]) => void; + compact?: boolean; + clearInFooter?: boolean; +}) { + const selected = new Set(selectedValues); + function updateValue(option: ResourceOption, checked: boolean) { + const next = nextSelectedValues(option, checked, selectedValues); + if (next !== null) onChange(next); + } + return ( + <> + + {label} + + {options.map((option) => ( + event.preventDefault()} + onCheckedChange={(checked) => updateValue(option, checked === true)} + > + + + ))} + {clearInFooter ? ( + <> + + { + event.preventDefault(); + onChange([]); + }} + className={cn( + "text-xs text-muted-foreground", + compact && "md:px-1.5 md:py-1", + )} + > + Clear filter + + + ) : null} + + ); +} + export function ResourceMultiSelectMenu({ label, icon, @@ -232,44 +364,14 @@ export function ResourceMultiSelectMenu({ mobileTitle={label} className={cn(compact ? "w-max max-w-64 md:p-0.5" : "min-w-44")} > - - {label} - - {options.map((option) => ( - event.preventDefault()} - onCheckedChange={(checked) => updateValue(option, checked === true)} - > - - - ))} - {clearInFooter ? ( - <> - - { - event.preventDefault(); - onChange([]); - }} - className={cn( - "text-xs text-muted-foreground", - compact && "md:px-1.5 md:py-1", - )} - > - Clear filter - - - ) : null} + ); @@ -328,7 +430,6 @@ export function ResourceFilterMenu({ {renderedGroups.map(({ group, selected }, groupIndex) => ( {groupIndex > 0 ? : null} - {} void; + onClear?: () => void; + placeholderLabel?: string; + compact?: boolean; + clearInFooter?: boolean; +}) { + return ( + <> + + Sort by + + {onClear === undefined || clearInFooter ? null : ( + { + event.preventDefault(); + onClear(); + }} + className={cn( + "flex items-center justify-between gap-3", + compact && "md:gap-2 md:px-1.5 md:py-1", + )} + > + {placeholderLabel} + + + )} + {options.map((option) => { + const selected = option.id === value; + return ( + { + event.preventDefault(); + if (option.disabled) return; + onChange(option.id); + }} + className={cn( + "flex items-center justify-between gap-3", + compact && "md:gap-2 md:px-1.5 md:py-1", + )} + > + + + + ); + })} + {clearInFooter && onClear !== undefined ? ( + <> + + { + event.preventDefault(); + onClear(); + }} + className={cn( + "text-xs text-muted-foreground", + compact && "md:px-1.5 md:py-1", + )} + > + Clear sort + + + ) : null} + + ); +} + export function ResourceSortMenu({ value, direction, @@ -397,7 +607,6 @@ export function ResourceSortMenu({ return ( - {} - - Sort by - - {onClear === undefined || clearInFooter ? null : ( - { - event.preventDefault(); - onClear(); - }} - className={cn( - "flex items-center justify-between gap-3", - compact && "md:gap-2 md:px-1.5 md:py-1", - )} - > - {placeholderLabel} - - - )} - {options.map((option) => { - const selected = option.id === value; - return ( - { - event.preventDefault(); - if (option.disabled) return; - onChange(option.id); - }} - className={cn( - "flex items-center justify-between gap-3", - compact && "md:gap-2 md:px-1.5 md:py-1", - )} - > - - - - ); - })} - {clearInFooter && onClear !== undefined ? ( - <> - - { - event.preventDefault(); - onClear(); - }} - className={cn( - "text-xs text-muted-foreground", - compact && "md:px-1.5 md:py-1", - )} - > - Clear sort - - - ) : null} + ); From ddb5207019bb095e983fa808ef9f04fe18f3320b Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:39:26 +0000 Subject: [PATCH 41/79] Remove extracted handler and exercise menu keyboard triggers --- .../management/PluginBrowseControls.test.tsx | 14 ++++++++++---- .../src/components/ui/resource/toolbar.tsx | 6 ------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 3f96cab9ad6..bb9c3118928 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -245,8 +245,9 @@ function mockToolbarWidth(initial: number) { constructor(callback: () => void) { this.callback = callback; } - observe() { - callbacks.add(this.callback); + observe(target: Element) { + if (target.hasAttribute("data-resource-toolbar")) + callbacks.add(this.callback); } unobserve() {} disconnect() { @@ -275,7 +276,10 @@ describe("PluginCollectionToolbar", () => { ({ labels, ...props }) => { mockToolbarWidth(320); render(); - fireEvent.click(screen.getByRole("button", { name: "Plugin controls" })); + fireEvent.keyDown( + screen.getByRole("button", { name: "Plugin controls" }), + { key: "Enter" }, + ); expect( screen .getAllByRole("menuitem") @@ -288,7 +292,9 @@ describe("PluginCollectionToolbar", () => { it("shares sort, category search, and source clearing in overflow without resetting other values", async () => { mockToolbarWidth(320); render(); - fireEvent.click(screen.getByRole("button", { name: "Plugin controls" })); + fireEvent.keyDown(screen.getByRole("button", { name: "Plugin controls" }), { + key: "Enter", + }); fireEvent.click(screen.getByRole("menuitem", { name: /^Sort by/u })); const sort = screen.getByRole("menuitemradio", { name: "Plugin name" }); await waitFor(() => expect(document.activeElement).toBe(sort)); diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index f67d22f848f..1513439a953 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -344,12 +344,6 @@ export function ResourceMultiSelectMenu({ : `${label}: ${activeSelectedCount} selected`; const triggerTooltip = `${label}: ${selectionSummary}`; - function updateValue(option: ResourceOption, checked: boolean) { - const next = nextSelectedValues(option, checked, selectedValues); - if (next === null) return; - onChange(next); - } - return ( Date: Thu, 17 Sep 2026 17:43:39 +0000 Subject: [PATCH 42/79] Type the toolbar geometry test receiver --- .../components/plugin/management/PluginBrowseControls.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index bb9c3118928..9966db78e2f 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -232,7 +232,7 @@ function mockToolbarWidth(initial: number) { const callbacks = new Set<() => void>(); const original = HTMLElement.prototype.getBoundingClientRect; vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( - function () { + function (this: HTMLElement) { if (this.hasAttribute("data-resource-toolbar")) return new DOMRect(0, 0, width, 32); return original.call(this); From 3304704b6dba116e502e3ae49e1863b758b86770 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:48:46 +0000 Subject: [PATCH 43/79] Preserve control-page focus in the responsive drawer --- .../management/PluginBrowseControls.test.tsx | 27 +++++++++++++++++++ .../management/PluginBrowseControls.tsx | 6 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 9966db78e2f..0d18a45dcdc 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -16,6 +16,11 @@ import { } from "./PluginBrowseControls"; import type { PluginBrowseCategoryOption } from "./plugin-browse-discovery"; +const viewport = vi.hoisted(() => ({ compact: false })); +vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ + useIsCompactViewport: () => viewport.compact, +})); + const OPTIONS: PluginBrowseCategoryOption[] = [ { id: "memory-and-context", label: "Memory & Context", count: 4 }, { id: "security", label: "Security", count: 2 }, @@ -32,6 +37,7 @@ function openMenu(selectionLabel: string) { afterEach(() => { cleanup(); + viewport.compact = false; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -347,6 +353,27 @@ describe("PluginCollectionToolbar", () => { ); }); + it("restores focus to the category entry in the compact drawer", async () => { + viewport.compact = true; + mockToolbarWidth(320); + render(); + fireEvent.click(screen.getByRole("button", { name: "Plugin controls" })); + fireEvent.click( + await screen.findByRole("menuitem", { name: /^Category/u }), + ); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("combobox", { name: "Search plugin categories" }), + ), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Back to controls" })); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: /^Category/u }), + ), + ); + }); + it("moves focused controls into overflow and back without losing search or selection", () => { const resize = mockToolbarWidth(600); render(); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 11e652d5f7b..b6301fc7498 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -224,6 +224,7 @@ function PluginControlsMenu({ ?.querySelector( `[data-control-page="${returnPageRef.current}"]`, ) + ?.closest('[role="menuitem"]') ?.focus(); } }); @@ -279,14 +280,15 @@ function PluginControlsMenu({ pages.map((control) => ( { event.preventDefault(); setPageId(control.id); }} > - {control.label} + + {control.label} + {control.active ? ( Active ) : null} From 4f0267c7ae356588aabed0d39ce30750515f27a2 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:52:51 +0000 Subject: [PATCH 44/79] Keep spacing unchanged for noncompact resource toolbars --- packages/shared-ui/src/components/ui/resource/toolbar.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 1513439a953..75139df0236 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -111,7 +111,13 @@ export function ResourceToolbar({ />
{controls ? ( -
+
{overflow && overflowControls ? overflowControls : controls}
) : null} From 7a721aa695abba811d20d9955f39d4cb5953d1c2 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:22:19 +0000 Subject: [PATCH 45/79] Let plugin search fill available toolbar width --- packages/shared-ui/src/components/ui/resource/toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 75139df0236..5c566aa4017 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -93,7 +93,7 @@ export function ResourceToolbar({ className={cn( "relative", compact - ? "min-w-0 max-w-64 flex-[1_1_16rem]" + ? "min-w-0 flex-1" : "w-full min-w-0 sm:w-auto sm:flex-1", )} > From 165329559ad35f319aeb13444cfbdb7fe1349e30 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:19:30 +0000 Subject: [PATCH 46/79] Use sliders icon for compact plugin controls --- .../src/components/plugin/management/PluginBrowseControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index b6301fc7498..134a7c270a1 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -250,7 +250,7 @@ function PluginControlsMenu({ ? `Plugin controls: ${activeLabels.join(", ")}` : "Plugin controls" } - icon="MoreHorizontal" + icon="SlidersHorizontal" active={activeLabels.length > 0} open={open} /> From 2f4a52c6d1470a1c667180c3a1ac5a6bbc8061a6 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:38:42 +0000 Subject: [PATCH 47/79] Match author GitHub logo to plugin detail --- .../src/components/plugin/management/PluginAuthorPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx index e4dbaf01c27..5d2bb4182cb 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx @@ -171,7 +171,11 @@ export function PluginAuthorPage({ className="inline-flex items-center gap-1 rounded-sm text-xs text-subtle-foreground underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > {author.url.startsWith("https://github.com/") ? ( - + ) : null} {formatUrlLabel(author.url)} From 6fd2daa3d5d874d3bdf858f8e1a2b036bb354cea Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:10:49 +0000 Subject: [PATCH 48/79] Fit labeled plugin controls within the collection toolbar --- .../management/PluginBrowseControls.test.tsx | 66 ++++++++++++- .../management/PluginBrowseControls.tsx | 15 ++- .../src/components/ui/resource/toolbar.tsx | 97 +++++++++++++++---- 3 files changed, 152 insertions(+), 26 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 0d18a45dcdc..3c7003b0044 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -184,9 +184,11 @@ describe("PluginBrowseCategoryFilter", () => { function ToolbarHarness({ installed = false, categoryShelf = false, + createAction = false, }: { installed?: boolean; categoryShelf?: boolean; + createAction?: boolean; }) { const [params, setParams] = useState( new URLSearchParams( @@ -203,6 +205,9 @@ function ToolbarHarness({ return ( <> Create : undefined + } query={params.get("query") ?? ""} selectedCategories={params.getAll("category")} categoryOptions={OPTIONS} @@ -241,9 +246,26 @@ function mockToolbarWidth(initial: number) { function (this: HTMLElement) { if (this.hasAttribute("data-resource-toolbar")) return new DOMRect(0, 0, width, 32); + if (this.hasAttribute("data-resource-individual-controls")) + return new DOMRect( + 0, + 0, + this.querySelectorAll("button").length * 96, + 32, + ); + if (this.hasAttribute("data-resource-toolbar-action")) + return new DOMRect(0, 0, 120, 32); return original.call(this); }, ); + const originalStyle = window.getComputedStyle; + vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { + const style = originalStyle(element); + if (element.hasAttribute("data-resource-toolbar")) style.columnGap = "8px"; + if (element.parentElement?.hasAttribute("data-resource-toolbar")) + style.flexBasis = "160px"; + return style; + }); vi.stubGlobal( "ResizeObserver", class { @@ -276,9 +298,8 @@ describe("PluginCollectionToolbar", () => { labels: ["Sort by", "Category", "Source"], }, { installed: false, categoryShelf: false, labels: ["Sort by", "Category"] }, - { installed: false, categoryShelf: true, labels: ["Sort by"] }, ])( - "preserves the allowed overflow controls for %j", + "preserves the allowed combined controls for %j", ({ labels, ...props }) => { mockToolbarWidth(320); render(); @@ -295,7 +316,44 @@ describe("PluginCollectionToolbar", () => { }, ); - it("shares sort, category search, and source clearing in overflow without resetting other values", async () => { + it("keeps a lone Sort control directly accessible at narrow widths", () => { + mockToolbarWidth(240); + render(); + expect(screen.getByRole("button", { name: /^Sort:/u }).textContent).toBe( + "Sort by", + ); + expect( + screen.queryByRole("button", { name: "Plugin controls" }), + ).toBeNull(); + }); + + it("uses the space required by each surface instead of a common viewport cutoff", () => { + const resize = mockToolbarWidth(400); + const { rerender } = render(); + expect(screen.getByRole("button", { name: /^Sort:/u }).textContent).toBe( + "Sort by", + ); + expect( + screen.getByRole("button", { name: /^Filter plugins by category:/u }) + .textContent, + ).toBe("Category"); + rerender(); + resize(400); + expect( + screen.getByRole("button", { name: "Plugin controls" }), + ).toBeTruthy(); + }); + + it("reserves space for the create action before expanding controls", () => { + mockToolbarWidth(500); + render(); + expect( + screen.getByRole("button", { name: "Plugin controls" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + }); + + it("shares sort, category search, and source clearing in the combined menu without resetting other values", async () => { mockToolbarWidth(320); render(); fireEvent.keyDown(screen.getByRole("button", { name: "Plugin controls" }), { @@ -374,7 +432,7 @@ describe("PluginCollectionToolbar", () => { ); }); - it("moves focused controls into overflow and back without losing search or selection", () => { + it("moves focused controls into the combined menu and back without losing search or selection", () => { const resize = mockToolbarWidth(600); render(); screen.getByRole("button", { name: /^Source:/u }).focus(); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 134a7c270a1..a5df6ae1ea9 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -172,16 +172,24 @@ export function PluginCollectionToolbar({ } controls={ <> - + {showCategoryFilter ? ( ) : null} {sourceProps ? ( - + ) : null} } - overflowControls={} + combinedControls={ + controls.length > 1 ? ( + + ) : undefined + } />
); @@ -343,6 +351,7 @@ export function PluginBrowseCategoryFilter(props: PluginCategoryFilterProps) { 0} diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 5c566aa4017..6c809e12ced 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -33,7 +33,7 @@ export function ResourceToolbar({ searchLabel, onSearchChange, controls, - overflowControls, + combinedControls, action, compact = false, }: { @@ -42,58 +42,79 @@ export function ResourceToolbar({ searchLabel?: string; onSearchChange: (value: string) => void; controls?: ReactNode; - overflowControls?: ReactNode; + combinedControls?: ReactNode; action?: ReactNode; compact?: boolean; }) { const toolbarRef = useRef(null); const controlsRef = useRef(null); - const [overflow, setOverflow] = useState(false); + const individualControlsRef = useRef(null); + const searchRef = useRef(null); + const actionRef = useRef(null); + const [combined, setCombined] = useState(false); const restoreControlFocus = useRef(false); - const overflowRef = useRef(false); - const hasOverflowControls = Boolean(overflowControls); + const combinedRef = useRef(false); + const hasCombinedControls = Boolean(combinedControls); + const showCombined = compact && hasCombinedControls && combined; useLayoutEffect(() => { const toolbar = toolbarRef.current; - if (!toolbar || !compact || !hasOverflowControls) return; + const individualControls = individualControlsRef.current; + const search = searchRef.current; + if (!toolbar || !individualControls || !search || !compact) return; const measure = () => { const width = toolbar.getBoundingClientRect().width; if (width === 0) return; - const next = width < 384; - if (overflowRef.current === next) return; + const gap = Number.parseFloat(getComputedStyle(toolbar).columnGap) || 0; + const searchWidth = Number.parseFloat(getComputedStyle(search).flexBasis); + const actionWidth = actionRef.current?.getBoundingClientRect().width ?? 0; + const requiredWidth = + searchWidth + + individualControls.getBoundingClientRect().width + + actionWidth + + gap * (actionRef.current ? 2 : 1); + const next = hasCombinedControls && width < requiredWidth; + if (combinedRef.current === next) return; restoreControlFocus.current = Boolean( controlsRef.current?.contains(document.activeElement) || controlsRef.current?.querySelector('[data-state="open"]'), ); - overflowRef.current = next; - setOverflow(next); + combinedRef.current = next; + setCombined(next); }; measure(); const observer = new ResizeObserver(measure); observer.observe(toolbar); + observer.observe(individualControls); + if (actionRef.current) observer.observe(actionRef.current); return () => observer.disconnect(); - }, [compact, hasOverflowControls]); + }, [compact, hasCombinedControls, showCombined]); useLayoutEffect(() => { if (!restoreControlFocus.current) return; - controlsRef.current?.querySelector("button")?.focus(); + controlsRef.current + ?.querySelector( + showCombined ? "[data-resource-combined-controls] button" : "button", + ) + ?.focus(); restoreControlFocus.current = false; - }, [overflow]); + }, [showCombined]); return (
@@ -118,11 +139,39 @@ export function ResourceToolbar({ compact ? "gap-2" : "gap-1.5", )} > - {overflow && overflowControls ? overflowControls : controls} +
+
+ {controls} +
+
+ {showCombined ? ( +
{combinedControls}
+ ) : null}
) : null} {action ? ( -
+
{action}
) : null} @@ -192,6 +241,7 @@ export const ResourceControlButton = forwardRef< active?: boolean; open?: boolean; tooltip?: ReactNode; + text?: string; } >(function ResourceControlButton( { @@ -200,6 +250,7 @@ export const ResourceControlButton = forwardRef< active = false, open = false, tooltip = label, + text, className, ...props }, @@ -214,9 +265,10 @@ export const ResourceControlButton = forwardRef< ref={ref} type="button" variant="outline" - size="icon" + size={text ? "sm" : "icon"} className={cn( - "size-8 shrink-0 rounded-md p-0 text-muted-foreground", + "h-8 shrink-0 rounded-md text-muted-foreground", + text ? "gap-1.5 px-2.5 text-xs" : "size-8 p-0", RESOURCE_MENU_TRIGGER_RESTING_CLASS, (open || active) && RESOURCE_MENU_TRIGGER_ENGAGED_CLASS, className, @@ -224,6 +276,7 @@ export const ResourceControlButton = forwardRef< aria-label={label} > + {text ? {text} : null} {tooltip} @@ -329,6 +382,7 @@ export function ResourceMultiSelectMenu({ onChange, compact = false, clearInFooter = false, + showLabel = false, }: { label: string; icon: IconName; @@ -337,6 +391,7 @@ export function ResourceMultiSelectMenu({ onChange: (values: string[]) => void; compact?: boolean; clearInFooter?: boolean; + showLabel?: boolean; }) { const [open, setOpen] = useState(false); const selected = new Set(selectedValues); @@ -354,6 +409,7 @@ export function ResourceMultiSelectMenu({ 0} open={open} @@ -585,6 +641,7 @@ export function ResourceSortMenu({ placeholderLabel = "Sort", compact = false, clearInFooter = false, + showLabel = false, }: { value: string | null; direction: "asc" | "desc"; @@ -594,6 +651,7 @@ export function ResourceSortMenu({ placeholderLabel?: string; compact?: boolean; clearInFooter?: boolean; + showLabel?: boolean; }) { const [open, setOpen] = useState(false); const selectedOption = options.find((option) => option.id === value); @@ -609,6 +667,7 @@ export function ResourceSortMenu({ Date: Thu, 17 Sep 2026 22:15:33 +0000 Subject: [PATCH 49/79] Provide toolbar layout styles in DOM regressions --- .../management/PluginBrowseControls.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 3c7003b0044..35ddbfd1a14 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -37,6 +37,7 @@ function openMenu(selectionLabel: string) { afterEach(() => { cleanup(); + document.getElementById("toolbar-test-layout")?.remove(); viewport.compact = false; vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -258,14 +259,13 @@ function mockToolbarWidth(initial: number) { return original.call(this); }, ); - const originalStyle = window.getComputedStyle; - vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { - const style = originalStyle(element); - if (element.hasAttribute("data-resource-toolbar")) style.columnGap = "8px"; - if (element.parentElement?.hasAttribute("data-resource-toolbar")) - style.flexBasis = "160px"; - return style; - }); + const style = document.createElement("style"); + style.id = "toolbar-test-layout"; + style.textContent = ` + [data-resource-toolbar] { column-gap: 8px; } + [data-resource-toolbar] > div:first-child { flex-basis: 160px; } + `; + document.head.append(style); vi.stubGlobal( "ResizeObserver", class { From ff496eafb3c2bec09101bd3dccd2eb60adc6a087 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:53:26 +0000 Subject: [PATCH 50/79] Refine plugin control labels, sort icons, and compact search --- .../plugin/PluginsOverview.test.tsx | 26 +-- .../management/BrowsePluginsTab.test.tsx | 16 +- .../management/PluginAuthorPage.test.tsx | 6 +- .../management/PluginBrowseControls.test.tsx | 156 ++++++++++++++---- .../management/PluginBrowseControls.tsx | 128 +++++++++----- .../src/components/ui/icon-extended.tsx | 12 ++ .../src/components/ui/icon-registry.ts | 6 + .../src/components/ui/resource/toolbar.tsx | 100 ++++++++--- 8 files changed, 335 insertions(+), 115 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 27250825617..89e6dea40b0 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -204,9 +204,9 @@ describe("PluginsOverview", () => { it("checks updates on entering Installed, without rechecking on filters or focus", async () => { installFetch(); const requestCount = (path: string) => - vi.mocked(fetch).mock.calls.filter(([input]) => - String(input).endsWith(path), - ).length; + vi + .mocked(fetch) + .mock.calls.filter(([input]) => String(input).endsWith(path)).length; const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( @@ -219,7 +219,9 @@ describe("PluginsOverview", () => { ); await screen.findByRole("textbox", { name: "Search plugins" }); expect(requestCount("/plugins/updates/check")).toBe(0); - fireEvent.click(screen.getByRole("button", { name: "switch-to-installed" })); + fireEvent.click( + screen.getByRole("button", { name: "switch-to-installed" }), + ); await screen.findByTestId("plugin-row-automations"); await waitFor(() => { expect(requestCount("/plugins/updates/check")).toBe(1); @@ -235,10 +237,10 @@ describe("PluginsOverview", () => { }); expect(requestCount("/plugins/updates/check")).toBe(1); fireEvent.click(screen.getByRole("button", { name: "switch-to-browse" })); - fireEvent.click(screen.getByRole("button", { name: "switch-to-installed" })); - await waitFor(() => - expect(requestCount("/plugins/updates/check")).toBe(2), + fireEvent.click( + screen.getByRole("button", { name: "switch-to-installed" }), ); + await waitFor(() => expect(requestCount("/plugins/updates/check")).toBe(2)); }); it("clears only Source while retaining search, category, and sort", async () => { @@ -447,7 +449,7 @@ describe("PluginsOverview", () => { fireEvent.pointerDown( screen.getByRole("button", { name: "Sort: Default" }), ); - fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Name" })); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByText("Automations")).toBeNull(); fireEvent.pointerDown( @@ -710,18 +712,18 @@ describe("PluginsOverview", () => { const sortTrigger = screen.getByRole("button", { name: "Sort: Default" }); fireEvent.pointerDown(sortTrigger); expect( - screen.getByRole("menuitemradio", { name: "Recently added" }), + screen.getByRole("menuitemradio", { name: "Published" }), ).toBeTruthy(); expect( screen - .getByRole("menuitemradio", { name: "Most installed" }) + .getByRole("menuitemradio", { name: "Installs" }) .getAttribute("aria-disabled"), ).toBe("true"); const rowIds = () => [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( (row) => row.getAttribute("data-testid"), ); - fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Name" })); expect(rowIds()).toEqual([ "plugin-row-enabled-local-alpha", "plugin-row-enabled-official-alpha", @@ -729,7 +731,7 @@ describe("PluginsOverview", () => { "plugin-row-inactive-local", "plugin-row-inactive-official", ]); - fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Name" })); expect(rowIds()).toEqual([ "plugin-row-inactive-official", "plugin-row-inactive-local", diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index de0e4bcb1f3..289becb9f33 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -300,7 +300,7 @@ describe("BrowsePluginsTab", () => { fireEvent.pointerDown(sortTrigger); expect( screen - .getByRole("menuitemradio", { name: "Most installed" }) + .getByRole("menuitemradio", { name: "Installs" }) .getAttribute("aria-disabled"), ).toBe("true"); }); @@ -318,12 +318,10 @@ describe("BrowsePluginsTab", () => { "Open Tasks details", ]); const trigger = screen.getByRole("button", { - name: "Sort: Most installed, descending", + name: "Sort: Installs, descending", }); fireEvent.pointerDown(trigger); - fireEvent.click( - screen.getByRole("menuitemradio", { name: "Most installed" }), - ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Installs" })); expect(cardOrder()).toEqual([ "Open Security details", "Open Memory details", @@ -345,12 +343,10 @@ describe("BrowsePluginsTab", () => { "Open Security details", ]); const trigger = screen.getByRole("button", { - name: "Sort: Recently added, descending", + name: "Sort: Published, descending", }); fireEvent.pointerDown(trigger); - fireEvent.click( - screen.getByRole("menuitemradio", { name: "Recently added" }), - ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Published" })); expect(cardOrder()).toEqual([ "Open Memory details", "Open Tasks details", @@ -365,7 +361,7 @@ describe("BrowsePluginsTab", () => { ); const trigger = await screen.findByRole("button", { - name: "Sort: Most installed, descending", + name: "Sort: Installs, descending", }); expect(screen.queryByTestId("plugin-browse-shelves")).toBeNull(); fireEvent.pointerDown(trigger); diff --git a/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx b/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx index 6d7fcf6f541..132b18f9c6d 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx @@ -218,12 +218,10 @@ describe("PluginAuthorPage", () => { "Open Alpha details", ]); const sort = screen.getByRole("button", { - name: "Sort: Most installed, descending", + name: "Sort: Installs, descending", }); fireEvent.pointerDown(sort); - fireEvent.click( - screen.getByRole("menuitemradio", { name: "Most installed" }), - ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Installs" })); expect(cardOrder()).toEqual([ "Open Beta details", "Open Gamma details", diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 35ddbfd1a14..c8833663108 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -186,16 +186,19 @@ function ToolbarHarness({ installed = false, categoryShelf = false, createAction = false, + installsKnown = false, }: { installed?: boolean; categoryShelf?: boolean; createAction?: boolean; + installsKnown?: boolean; }) { const [params, setParams] = useState( new URLSearchParams( "query=Memory&category=security&source=user&sort=name&direction=desc", ), ); + const sort = params.get("sort"); const changeSearchParams = (change: (next: URLSearchParams) => void) => { setParams((previous) => { const next = new URLSearchParams(previous); @@ -213,9 +216,15 @@ function ToolbarHarness({ selectedCategories={params.getAll("category")} categoryOptions={OPTIONS} showCategoryFilter={!categoryShelf} - sort={params.has("sort") ? "name" : null} + sort={ + sort === "name" || + sort === "recently-added" || + sort === "most-installed" + ? sort + : null + } sortDirection={params.get("direction") === "desc" ? "desc" : "asc"} - installsKnown={false} + installsKnown={installsKnown} changeSearchParams={changeSearchParams} sourceFilter={ installed @@ -254,6 +263,8 @@ function mockToolbarWidth(initial: number) { this.querySelectorAll("button").length * 96, 32, ); + if (this.hasAttribute("data-resource-combined-controls")) + return new DOMRect(0, 0, 112, 32); if (this.hasAttribute("data-resource-toolbar-action")) return new DOMRect(0, 0, 120, 32); return original.call(this); @@ -291,26 +302,115 @@ function mockToolbarWidth(initial: number) { } describe("PluginCollectionToolbar", () => { + it.each([ + { label: "Name", value: "name", direction: "asc" }, + { label: "Published", value: "recently-added", direction: "desc" }, + { label: "Installs", value: "most-installed", direction: "desc" }, + ])( + "updates the selected $label label and direction without clearing filters", + ({ label, value, direction }) => { + mockToolbarWidth(800); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: /^Sort:/u }), { + key: "Enter", + }); + fireEvent.click(screen.getByRole("menuitemradio", { name: label })); + expect(screen.getByLabelText("Parameters").textContent).toContain( + `sort=${value}&direction=${direction}`, + ); + expect(screen.getByRole("button", { name: /^Sort:/u }).textContent).toBe( + label, + ); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "query=Memory&category=security&source=user", + ); + fireEvent.click(screen.getByRole("menuitemradio", { name: label })); + expect(screen.getByLabelText("Parameters").textContent).toContain( + `direction=${direction === "asc" ? "desc" : "asc"}`, + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); + expect( + screen.getByRole("button", { name: "Sort: Default" }).textContent, + ).toBe("Sort"); + }, + ); + + it("shows actual selections and sort direction in the combined menu", () => { + mockToolbarWidth(320); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Filter & sort" }), { + key: "Enter", + }); + expect( + screen.getByRole("menuitem", { name: /Category.*Security/u }), + ).toBeTruthy(); + expect( + screen.getByRole("menuitem", { name: /Source.*Local/u }), + ).toBeTruthy(); + expect( + screen.getByRole("menuitem", { name: /Sort.*Name · Z–A/u }), + ).toBeTruthy(); + }); + + it("expands cramped search and restores controls on submit or blur without losing the query", () => { + mockToolbarWidth(320); + render(); + const search = screen.getByRole("textbox", { name: "Search plugins" }); + act(() => search.focus()); + expect( + search + .closest("[data-resource-toolbar]") + ?.getAttribute("data-search-expanded"), + ).toBe("true"); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); + fireEvent.change(search, { target: { value: "Notes" } }); + fireEvent.keyDown(search, { key: "Enter", isComposing: true }); + expect(document.activeElement).toBe(search); + fireEvent.keyDown(search, { key: "Enter" }); + expect( + search + .closest("[data-resource-toolbar]") + ?.hasAttribute("data-search-expanded"), + ).toBe(false); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Notes&category=security&source=user&sort=name&direction=desc", + ); + act(() => search.focus()); + act(() => search.blur()); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); + }); + + it("keeps wide search beside its controls when focused", () => { + mockToolbarWidth(800); + render(); + act(() => screen.getByRole("textbox", { name: "Search plugins" }).focus()); + expect(screen.getByRole("button", { name: /^Sort:/u })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + }); + it.each([ { installed: true, categoryShelf: false, - labels: ["Sort by", "Category", "Source"], + labels: ["Category", "Source", "Sort"], }, - { installed: false, categoryShelf: false, labels: ["Sort by", "Category"] }, + { installed: false, categoryShelf: false, labels: ["Category", "Sort"] }, ])( "preserves the allowed combined controls for %j", ({ labels, ...props }) => { mockToolbarWidth(320); render(); - fireEvent.keyDown( - screen.getByRole("button", { name: "Plugin controls" }), - { key: "Enter" }, - ); + fireEvent.keyDown(screen.getByRole("button", { name: "Filter & sort" }), { + key: "Enter", + }); expect( screen .getAllByRole("menuitem") - .map((item) => item.textContent?.replace("Active", "")), + .map( + (item) => item.querySelector("[data-control-page]")?.textContent, + ), ).toEqual(labels); expect(screen.queryByRole("button", { name: /^Sort:/u })).toBeNull(); }, @@ -320,47 +420,41 @@ describe("PluginCollectionToolbar", () => { mockToolbarWidth(240); render(); expect(screen.getByRole("button", { name: /^Sort:/u }).textContent).toBe( - "Sort by", + "Name", ); - expect( - screen.queryByRole("button", { name: "Plugin controls" }), - ).toBeNull(); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); }); it("uses the space required by each surface instead of a common viewport cutoff", () => { const resize = mockToolbarWidth(400); const { rerender } = render(); expect(screen.getByRole("button", { name: /^Sort:/u }).textContent).toBe( - "Sort by", + "Name", ); expect( screen.getByRole("button", { name: /^Filter plugins by category:/u }) .textContent, - ).toBe("Category"); + ).toBe("Category1"); rerender(); resize(400); - expect( - screen.getByRole("button", { name: "Plugin controls" }), - ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); }); it("reserves space for the create action before expanding controls", () => { mockToolbarWidth(500); render(); - expect( - screen.getByRole("button", { name: "Plugin controls" }), - ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); }); it("shares sort, category search, and source clearing in the combined menu without resetting other values", async () => { mockToolbarWidth(320); render(); - fireEvent.keyDown(screen.getByRole("button", { name: "Plugin controls" }), { + fireEvent.keyDown(screen.getByRole("button", { name: "Filter & sort" }), { key: "Enter", }); - fireEvent.click(screen.getByRole("menuitem", { name: /^Sort by/u })); - const sort = screen.getByRole("menuitemradio", { name: "Plugin name" }); + fireEvent.click(screen.getByRole("menuitem", { name: /^Sort/u })); + const sort = screen.getByRole("menuitemradio", { name: "Name" }); await waitFor(() => expect(document.activeElement).toBe(sort)); fireEvent.click(sort); expect(screen.getByLabelText("Parameters").textContent).toContain( @@ -368,7 +462,7 @@ describe("PluginCollectionToolbar", () => { ); expect( screen - .getByRole("menuitemradio", { name: "Most installed" }) + .getByRole("menuitemradio", { name: "Installs" }) .getAttribute("aria-disabled"), ).toBe("true"); fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); @@ -378,7 +472,7 @@ describe("PluginCollectionToolbar", () => { fireEvent.click(screen.getByRole("menuitem", { name: "Back to controls" })); await waitFor(() => expect(document.activeElement).toBe( - screen.getByRole("menuitem", { name: "Sort by" }), + screen.getByRole("menuitem", { name: /^Sort/u }), ), ); fireEvent.click(screen.getByRole("menuitem", { name: /^Category/u })); @@ -406,7 +500,7 @@ describe("PluginCollectionToolbar", () => { }); await waitFor(() => expect(document.activeElement).toBe( - screen.getByRole("button", { name: "Plugin controls" }), + screen.getByRole("button", { name: "Filter & sort" }), ), ); }); @@ -415,7 +509,7 @@ describe("PluginCollectionToolbar", () => { viewport.compact = true; mockToolbarWidth(320); render(); - fireEvent.click(screen.getByRole("button", { name: "Plugin controls" })); + fireEvent.click(screen.getByRole("button", { name: "Filter & sort" })); fireEvent.click( await screen.findByRole("menuitem", { name: /^Category/u }), ); @@ -438,15 +532,13 @@ describe("PluginCollectionToolbar", () => { screen.getByRole("button", { name: /^Source:/u }).focus(); resize(320); expect(document.activeElement).toBe( - screen.getByRole("button", { name: "Plugin controls" }), + screen.getByRole("button", { name: "Filter & sort" }), ); fireEvent.change(screen.getByRole("textbox", { name: "Search plugins" }), { target: { value: "Notes" }, }); resize(600); - expect( - screen.queryByRole("button", { name: "Plugin controls" }), - ).toBeNull(); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); expect(screen.getByRole("button", { name: /^Source: 1/u })).toBeTruthy(); expect( screen diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index a5df6ae1ea9..f4124c413b9 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -40,24 +40,49 @@ const PLUGIN_BROWSE_SORTS = [ ] as const satisfies readonly PluginBrowseSort[]; const PLUGIN_BROWSE_SORT_LABELS: Record = { - name: "Plugin name", - "recently-added": "Recently added", - "most-installed": "Most installed", + name: "Name", + "recently-added": "Published", + "most-installed": "Installs", }; -const PLUGIN_BROWSE_SORT_ICONS: Record = { - name: "Sort", - "recently-added": "Clock", - "most-installed": "Download", +const PLUGIN_BROWSE_SORT_ICONS: Record< + PluginBrowseSort, + Record +> = { + name: { asc: "SortingAZ02", desc: "SortingZA01" }, + "recently-added": { asc: "ClockArrowUp", desc: "ClockArrowDown" }, + "most-installed": { asc: "SortingOneNine", desc: "SortingNineOne" }, +}; + +const PLUGIN_BROWSE_SORT_DIRECTIONS: Record< + PluginBrowseSort, + Record +> = { + name: { asc: "A–Z", desc: "Z–A" }, + "recently-added": { asc: "Oldest first", desc: "Newest first" }, + "most-installed": { asc: "Fewest first", desc: "Most first" }, }; -export function pluginBrowseSortOptions(hasInstallCounts: boolean) { - return PLUGIN_BROWSE_SORTS.map((sort) => ({ - id: sort, - label: PLUGIN_BROWSE_SORT_LABELS[sort], - leading: , - disabled: sort === "most-installed" && !hasInstallCounts, - })); +export function pluginBrowseSortOptions( + hasInstallCounts: boolean, + selected: PluginBrowseSort | null = null, + direction: PluginBrowseSortDirection = "asc", +) { + return PLUGIN_BROWSE_SORTS.map((sort) => { + const optionDirection = + sort === selected ? direction : sort === "name" ? "asc" : "desc"; + return { + id: sort, + label: PLUGIN_BROWSE_SORT_LABELS[sort], + leading: ( + + ), + disabled: sort === "most-installed" && !hasInstallCounts, + }; + }); } export function PluginCollectionToolbar({ @@ -97,7 +122,7 @@ export function PluginCollectionToolbar({ compact: true, clearInFooter: true, placeholderLabel: "Default", - options: pluginBrowseSortOptions(installsKnown), + options: pluginBrowseSortOptions(installsKnown, sort, sortDirection), onChange: (value: string) => changeSearchParams((next) => { if (value === sort) { @@ -125,21 +150,22 @@ export function PluginCollectionToolbar({ const sourceProps = sourceFilter ? { ...sourceFilter, label: "Source", compact: true, clearInFooter: true } : null; + const sortIcon = + sort === null + ? "ArrowUpDown" + : PLUGIN_BROWSE_SORT_ICONS[sort][sortDirection]; const controls = [ - { - id: "sort", - label: "Sort by", - icon: "ArrowUpDown", - active: sort !== null, - content: , - }, ...(showCategoryFilter ? [ { id: "category", label: "Category", - icon: "SlidersHorizontal", active: selectedCategories.length > 0, + summary: + categoryOptions + .filter((option) => selectedCategories.includes(option.id)) + .map((option) => option.label) + .join(", ") || "All", content: , } satisfies PluginControlPage, ] @@ -149,18 +175,36 @@ export function PluginCollectionToolbar({ { id: "source", label: "Source", - icon: "Layers", active: sourceProps.selectedValues.length > 0, + summary: + sourceProps.options + .filter((option) => + sourceProps.selectedValues.includes(option.id), + ) + .map((option) => option.label) + .join(", ") || "All", content: , } satisfies PluginControlPage, ] : []), + { + id: "sort", + label: "Sort", + icon: sortIcon, + active: sort !== null, + summary: + sort === null + ? "Default" + : `${PLUGIN_BROWSE_SORT_LABELS[sort]} · ${PLUGIN_BROWSE_SORT_DIRECTIONS[sort][sortDirection]}`, + content: , + }, ] satisfies PluginControlPage[]; return (
- {showCategoryFilter ? ( ) : null} @@ -183,6 +226,7 @@ export function PluginCollectionToolbar({ showLabel /> ) : null} + } combinedControls={ @@ -198,8 +242,9 @@ export function PluginCollectionToolbar({ type PluginControlPage = { id: string; label: string; - icon: IconName; + icon?: IconName; active: boolean; + summary: string; content: ReactNode; }; @@ -252,13 +297,14 @@ function PluginControlsMenu({ > 0} open={open} /> @@ -266,7 +312,7 @@ function PluginControlsMenu({ {page ? ( @@ -278,7 +324,7 @@ function PluginControlsMenu({ setPageId(null); }} > - + Back to controls @@ -293,13 +339,18 @@ function PluginControlsMenu({ setPageId(control.id); }} > - - + {control.icon ? ( + + ) : null} + {control.label} - {control.active ? ( - Active - ) : null} + + {control.summary} + )) @@ -353,7 +404,8 @@ export function PluginBrowseCategoryFilter(props: PluginCategoryFilterProps) { label={`Filter plugins by category: ${accessibleSelectionLabel}`} text="Category" tooltip={`Category: ${accessibleSelectionLabel}`} - icon="SlidersHorizontal" + count={value.length} + trailingIcon="ChevronDown" active={value.length > 0} open={open} /> diff --git a/packages/shared-ui/src/components/ui/icon-extended.tsx b/packages/shared-ui/src/components/ui/icon-extended.tsx index e003f273ada..24ec43a9cad 100644 --- a/packages/shared-ui/src/components/ui/icon-extended.tsx +++ b/packages/shared-ui/src/components/ui/icon-extended.tsx @@ -27,6 +27,8 @@ import { CircleArrowShrink01Icon, CleanIcon, Clock01Icon, + ClockArrowUpIcon, + ClockArrowDownIcon, CloudIcon, CloudOffIcon, Coffee02Icon, @@ -88,6 +90,10 @@ import { SidebarRightIcon, SmartPhone01Icon, Sorting01Icon, + SortingAZ02Icon, + SortingZA01Icon, + SortingOneNineIcon, + SortingNineOneIcon, SquareIcon, SquareUnlock02Icon, StarIcon, @@ -235,6 +241,8 @@ export const EXTENDED_ICON_MAP: ExtendedIconMap = { CircleArrowShrink: CircleArrowShrink01Icon, Clean: CleanIcon, Clock: Clock01Icon, + ClockArrowUp: ClockArrowUpIcon, + ClockArrowDown: ClockArrowDownIcon, Cloud: CloudIcon, CloudOff: CloudOffIcon, Coffee: Coffee02Icon, @@ -304,6 +312,10 @@ export const EXTENDED_ICON_MAP: ExtendedIconMap = { SideChat: MessageAdd02Icon, Smartphone: SmartPhone01Icon, Sort: Sorting01Icon, + SortingAZ02: SortingAZ02Icon, + SortingZA01: SortingZA01Icon, + SortingOneNine: SortingOneNineIcon, + SortingNineOne: SortingNineOneIcon, Square: SquareIcon, SquareUnlock02: SquareUnlock02Icon, Star: StarIcon, diff --git a/packages/shared-ui/src/components/ui/icon-registry.ts b/packages/shared-ui/src/components/ui/icon-registry.ts index 3ba9da2180d..a4e5454e3d2 100644 --- a/packages/shared-ui/src/components/ui/icon-registry.ts +++ b/packages/shared-ui/src/components/ui/icon-registry.ts @@ -29,6 +29,8 @@ export const EXTENDED_ICON_NAMES = [ "CircleArrowShrink", "Clean", "Clock", + "ClockArrowUp", + "ClockArrowDown", "Cloud", "CloudOff", "Coffee", @@ -98,6 +100,10 @@ export const EXTENDED_ICON_NAMES = [ "SideChat", "Smartphone", "Sort", + "SortingAZ02", + "SortingZA01", + "SortingOneNine", + "SortingNineOne", "Square", "SquareUnlock02", "Star", diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 6c809e12ced..b04491317cf 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -36,6 +36,7 @@ export function ResourceToolbar({ combinedControls, action, compact = false, + expandSearchOnFocus = false, }: { searchValue: string; searchPlaceholder: string; @@ -45,6 +46,7 @@ export function ResourceToolbar({ combinedControls?: ReactNode; action?: ReactNode; compact?: boolean; + expandSearchOnFocus?: boolean; }) { const toolbarRef = useRef(null); const controlsRef = useRef(null); @@ -52,6 +54,8 @@ export function ResourceToolbar({ const searchRef = useRef(null); const actionRef = useRef(null); const [combined, setCombined] = useState(false); + const [searchCondensed, setSearchCondensed] = useState(false); + const [searchExpanded, setSearchExpanded] = useState(false); const restoreControlFocus = useRef(false); const combinedRef = useRef(false); const hasCombinedControls = Boolean(combinedControls); @@ -74,6 +78,19 @@ export function ResourceToolbar({ actionWidth + gap * (actionRef.current ? 2 : 1); const next = hasCombinedControls && width < requiredWidth; + const controlsWidth = next + ? (controlsRef.current + ?.querySelector("[data-resource-combined-controls]") + ?.getBoundingClientRect().width ?? 0) + : individualControls.getBoundingClientRect().width; + setSearchCondensed( + expandSearchOnFocus && + width - + controlsWidth - + actionWidth - + gap * (actionRef.current ? 2 : 1) < + searchWidth, + ); if (combinedRef.current === next) return; restoreControlFocus.current = Boolean( controlsRef.current?.contains(document.activeElement) || @@ -86,9 +103,10 @@ export function ResourceToolbar({ const observer = new ResizeObserver(measure); observer.observe(toolbar); observer.observe(individualControls); + if (controlsRef.current) observer.observe(controlsRef.current); if (actionRef.current) observer.observe(actionRef.current); return () => observer.disconnect(); - }, [compact, hasCombinedControls, showCombined]); + }, [compact, expandSearchOnFocus, hasCombinedControls, showCombined]); useLayoutEffect(() => { if (!restoreControlFocus.current) return; @@ -104,6 +122,7 @@ export function ResourceToolbar({
onSearchChange(event.target.value)} - placeholder={searchPlaceholder} + placeholder={ + searchCondensed && !searchExpanded ? "Search" : searchPlaceholder + } aria-label={searchLabel ?? searchPlaceholder} + enterKeyHint={expandSearchOnFocus ? "search" : undefined} + onFocus={() => { + if (searchCondensed) setSearchExpanded(true); + }} + onBlur={() => setSearchExpanded(false)} + onKeyDown={(event) => { + if ( + searchExpanded && + !event.nativeEvent.isComposing && + (event.key === "Enter" || event.key === "Escape") + ) { + event.preventDefault(); + event.currentTarget.blur(); + } + }} className="h-8 pl-8" />
{controls ? (
{action} @@ -237,11 +279,13 @@ export const ResourceControlButton = forwardRef< HTMLButtonElement, ButtonProps & { label: string; - icon: IconName; + icon?: IconName; active?: boolean; open?: boolean; tooltip?: ReactNode; text?: string; + count?: number; + trailingIcon?: IconName; } >(function ResourceControlButton( { @@ -251,6 +295,8 @@ export const ResourceControlButton = forwardRef< open = false, tooltip = label, text, + count, + trailingIcon, className, ...props }, @@ -268,15 +314,26 @@ export const ResourceControlButton = forwardRef< size={text ? "sm" : "icon"} className={cn( "h-8 shrink-0 rounded-md text-muted-foreground", - text ? "gap-1.5 px-2.5 text-xs" : "size-8 p-0", + text ? "gap-2 px-2 text-xs" : "size-8 p-0", RESOURCE_MENU_TRIGGER_RESTING_CLASS, (open || active) && RESOURCE_MENU_TRIGGER_ENGAGED_CLASS, className, )} aria-label={label} > - + {icon ? : null} {text ? {text} : null} + {count !== undefined && count > 0 ? ( + + {count} + + ) : null} + {trailingIcon ? ( + + ) : null} {tooltip} @@ -410,7 +467,9 @@ export function ResourceMultiSelectMenu({ 0} open={open} tooltip={triggerTooltip} @@ -550,7 +609,7 @@ export function ResourceSortMenuItems({ compact && "md:px-1.5 md:py-1", )} > - Sort by + Sort {onClear === undefined || clearInFooter ? null : ( option.id === value); @@ -667,8 +728,8 @@ export function ResourceSortMenu({ @@ -733,20 +794,18 @@ export function ResourceCreateButton({ aria-label={label} type="button" size="sm" - className={cn( - "rounded-r-none", - compactWhenNarrow && "@max-[36rem]/resource-toolbar:px-2", - )} + className={cn("rounded-r-none", compactWhenNarrow && "pl-2 pr-1")} onClick={() => onCreate()} > - - - {label} - + aria-hidden + /> + {label} ); return ( @@ -767,7 +826,10 @@ export function ResourceCreateButton({ type="button" size="sm" aria-label={`${label} options`} - className="rounded-l-none px-1.5" + className={cn( + "rounded-l-none px-1.5", + compactWhenNarrow && "pl-1 pr-2", + )} > From 92a1222c2a402d2868111202857542d3c9721a9b Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:56:21 +0000 Subject: [PATCH 51/79] Regenerate plugin registry with directional sort icons --- packages/plugin-registry/r/icon-extended.json | 2 +- packages/plugin-registry/r/icon-registry.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-registry/r/icon-extended.json b/packages/plugin-registry/r/icon-extended.json index a07bdd87df3..845f3f3b3a5 100644 --- a/packages/plugin-registry/r/icon-extended.json +++ b/packages/plugin-registry/r/icon-extended.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/components/ui/icon-extended.tsx", - "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport {\n AiBrain01Icon,\n AiBrowserIcon,\n AiContentGenerator01Icon,\n ArrowDown02Icon,\n ArrowDownDoubleIcon,\n ArrowMoveDownLeftIcon,\n ArrowMoveDownRightIcon,\n ArrowReloadHorizontalIcon,\n ArrowRight02Icon,\n ArrowTurnBackwardIcon,\n ArrowTurnForwardIcon,\n ArrowUp01Icon,\n ArrowUp02Icon,\n ArrowUpDoubleIcon,\n ArrowUpDownIcon,\n ArrowUpRight01Icon,\n AttachmentIcon,\n BellDotIcon,\n Book02Icon,\n BrainIcon,\n BrowserIcon,\n Calendar03Icon,\n CalendarCheckOut02Icon,\n ChartColumnIcon,\n CircleArrowShrink01Icon,\n CleanIcon,\n Clock01Icon,\n CloudIcon,\n CloudOffIcon,\n Coffee02Icon,\n CollapseIcon,\n DashedLine02Icon,\n DateTimeIcon,\n DiscordIcon,\n DragDropHorizontalIcon,\n DragDropVerticalIcon,\n Edit04Icon,\n ElectricPlugsIcon,\n ExpandIcon,\n File01Icon,\n FileAttachmentIcon,\n FileEmpty02Icon,\n FileQuestionMarkIcon,\n Folder02Icon,\n FolderEditIcon,\n FolderRemoveIcon,\n GitBranchIcon,\n GitForkIcon,\n GithubIcon,\n GitMergeIcon,\n GitPullRequestArrow,\n GitPullRequestClosedIcon,\n GitPullRequestDraftIcon,\n GitPullRequestIcon,\n GridViewIcon,\n InternetIcon,\n LaptopIcon,\n Layers01Icon,\n LayoutTwoColumnIcon,\n LayoutTwoRowIcon,\n LimitationIcon,\n LinkSquare02Icon,\n ListViewIcon,\n LockIcon,\n Mail02Icon,\n MailOpen01Icon,\n Menu02Icon,\n MessageAdd02Icon,\n Mic02Icon,\n MoveToIcon,\n News01Icon,\n PackageReceiveIcon,\n PauseIcon,\n PinIcon,\n PinOffIcon,\n PlayIcon,\n Plug02Icon,\n PlusMinusSquare01Icon,\n PlusSignIcon,\n PuzzleIcon,\n Refresh01Icon,\n RepeatIcon,\n SecurityCheckIcon,\n SentIcon,\n SidebarBottomIcon,\n SidebarRightIcon,\n SmartPhone01Icon,\n Sorting01Icon,\n SquareIcon,\n SquareUnlock02Icon,\n StarIcon,\n TestTube01Icon,\n TextWrapIcon,\n TimeScheduleIcon,\n Unarchive03Icon,\n UserIcon,\n ViewIcon,\n ViewOffIcon,\n ZoomInAreaIcon,\n ZoomOutAreaIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nconst DiscordLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nconst GithubLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrain01: AiBrain01Icon,\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n BellDot: BellDotIcon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DiscordLogo: DiscordLogoIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n GithubLogo: GithubLogoIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n Limitation: LimitationIcon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minimize2: CollapseIcon,\n MoveTo: MoveToIcon,\n NewTab: DashedLine02Icon,\n News01: News01Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plug02: Plug02Icon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", + "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport {\n AiBrain01Icon,\n AiBrowserIcon,\n AiContentGenerator01Icon,\n ArrowDown02Icon,\n ArrowDownDoubleIcon,\n ArrowMoveDownLeftIcon,\n ArrowMoveDownRightIcon,\n ArrowReloadHorizontalIcon,\n ArrowRight02Icon,\n ArrowTurnBackwardIcon,\n ArrowTurnForwardIcon,\n ArrowUp01Icon,\n ArrowUp02Icon,\n ArrowUpDoubleIcon,\n ArrowUpDownIcon,\n ArrowUpRight01Icon,\n AttachmentIcon,\n BellDotIcon,\n Book02Icon,\n BrainIcon,\n BrowserIcon,\n Calendar03Icon,\n CalendarCheckOut02Icon,\n ChartColumnIcon,\n CircleArrowShrink01Icon,\n CleanIcon,\n Clock01Icon,\n ClockArrowUpIcon,\n ClockArrowDownIcon,\n CloudIcon,\n CloudOffIcon,\n Coffee02Icon,\n CollapseIcon,\n DashedLine02Icon,\n DateTimeIcon,\n DiscordIcon,\n DragDropHorizontalIcon,\n DragDropVerticalIcon,\n Edit04Icon,\n ElectricPlugsIcon,\n ExpandIcon,\n File01Icon,\n FileAttachmentIcon,\n FileEmpty02Icon,\n FileQuestionMarkIcon,\n Folder02Icon,\n FolderEditIcon,\n FolderRemoveIcon,\n GitBranchIcon,\n GitForkIcon,\n GithubIcon,\n GitMergeIcon,\n GitPullRequestArrow,\n GitPullRequestClosedIcon,\n GitPullRequestDraftIcon,\n GitPullRequestIcon,\n GridViewIcon,\n InternetIcon,\n LaptopIcon,\n Layers01Icon,\n LayoutTwoColumnIcon,\n LayoutTwoRowIcon,\n LimitationIcon,\n LinkSquare02Icon,\n ListViewIcon,\n LockIcon,\n Mail02Icon,\n MailOpen01Icon,\n Menu02Icon,\n MessageAdd02Icon,\n Mic02Icon,\n MoveToIcon,\n News01Icon,\n PackageReceiveIcon,\n PauseIcon,\n PinIcon,\n PinOffIcon,\n PlayIcon,\n Plug02Icon,\n PlusMinusSquare01Icon,\n PlusSignIcon,\n PuzzleIcon,\n Refresh01Icon,\n RepeatIcon,\n SecurityCheckIcon,\n SentIcon,\n SidebarBottomIcon,\n SidebarRightIcon,\n SmartPhone01Icon,\n Sorting01Icon,\n SortingAZ02Icon,\n SortingZA01Icon,\n SortingOneNineIcon,\n SortingNineOneIcon,\n SquareIcon,\n SquareUnlock02Icon,\n StarIcon,\n TestTube01Icon,\n TextWrapIcon,\n TimeScheduleIcon,\n Unarchive03Icon,\n UserIcon,\n ViewIcon,\n ViewOffIcon,\n ZoomInAreaIcon,\n ZoomOutAreaIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nconst DiscordLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nconst GithubLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrain01: AiBrain01Icon,\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n BellDot: BellDotIcon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n ClockArrowUp: ClockArrowUpIcon,\n ClockArrowDown: ClockArrowDownIcon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DiscordLogo: DiscordLogoIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n GithubLogo: GithubLogoIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n Limitation: LimitationIcon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minimize2: CollapseIcon,\n MoveTo: MoveToIcon,\n NewTab: DashedLine02Icon,\n News01: News01Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plug02: Plug02Icon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n SortingAZ02: SortingAZ02Icon,\n SortingZA01: SortingZA01Icon,\n SortingOneNine: SortingOneNineIcon,\n SortingNineOne: SortingNineOneIcon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", "type": "registry:ui", "target": "components/ui/icon-extended.tsx" } diff --git a/packages/plugin-registry/r/icon-registry.json b/packages/plugin-registry/r/icon-registry.json index 66d3e3831c1..8b3a4f8ee43 100644 --- a/packages/plugin-registry/r/icon-registry.json +++ b/packages/plugin-registry/r/icon-registry.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/components/ui/icon-registry.ts", - "content": "import type { ComponentType } from \"react\";\nimport type { IconSvgElement } from \"@hugeicons/react\";\n\nexport const EXTENDED_ICON_NAMES = [\n \"AiBrain01\",\n \"AiBrowser\",\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"BellDot\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DiscordLogo\",\n \"DateTime\",\n \"Github\",\n \"GithubLogo\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"Limitation\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minimize2\",\n \"MoveTo\",\n \"NewTab\",\n \"News01\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plug02\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\ninterface AppIconDefinition {\n component: ComponentType<{ className?: string }>;\n key: string;\n}\n\nlet appIcons: ReadonlyMap = new Map();\nconst appIconListeners = new Set<() => void>();\n\nexport function setAppIcons(\n next: ReadonlyMap,\n): void {\n appIcons = next;\n for (const listener of appIconListeners) listener();\n}\n\nexport function getAppIcon(name: string): AppIconDefinition | undefined {\n return appIcons.get(name);\n}\n\nexport function subscribeAppIcons(listener: () => void): () => void {\n appIconListeners.add(listener);\n return () => {\n appIconListeners.delete(listener);\n };\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type { IconSvgElement } from \"@hugeicons/react\";\n\nexport const EXTENDED_ICON_NAMES = [\n \"AiBrain01\",\n \"AiBrowser\",\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"BellDot\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"ClockArrowUp\",\n \"ClockArrowDown\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DiscordLogo\",\n \"DateTime\",\n \"Github\",\n \"GithubLogo\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"Limitation\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minimize2\",\n \"MoveTo\",\n \"NewTab\",\n \"News01\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plug02\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"SortingAZ02\",\n \"SortingZA01\",\n \"SortingOneNine\",\n \"SortingNineOne\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\ninterface AppIconDefinition {\n component: ComponentType<{ className?: string }>;\n key: string;\n}\n\nlet appIcons: ReadonlyMap = new Map();\nconst appIconListeners = new Set<() => void>();\n\nexport function setAppIcons(\n next: ReadonlyMap,\n): void {\n appIcons = next;\n for (const listener of appIconListeners) listener();\n}\n\nexport function getAppIcon(name: string): AppIconDefinition | undefined {\n return appIcons.get(name);\n}\n\nexport function subscribeAppIcons(listener: () => void): () => void {\n appIconListeners.add(listener);\n return () => {\n appIconListeners.delete(listener);\n };\n}\n", "type": "registry:ui", "target": "components/ui/icon-registry.ts" } From 76849048eb877e9388024b709d3dc2b984ebb1d7 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:57:33 +0000 Subject: [PATCH 52/79] Assert sort trigger state across the open menu --- .../plugin/management/PluginBrowseControls.test.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index c8833663108..5a4cd497025 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -311,16 +311,15 @@ describe("PluginCollectionToolbar", () => { ({ label, value, direction }) => { mockToolbarWidth(800); render(); - fireEvent.keyDown(screen.getByRole("button", { name: /^Sort:/u }), { + const trigger = screen.getByRole("button", { name: /^Sort:/u }); + fireEvent.keyDown(trigger, { key: "Enter", }); fireEvent.click(screen.getByRole("menuitemradio", { name: label })); expect(screen.getByLabelText("Parameters").textContent).toContain( `sort=${value}&direction=${direction}`, ); - expect(screen.getByRole("button", { name: /^Sort:/u }).textContent).toBe( - label, - ); + expect(trigger.textContent).toBe(label); expect(screen.getByLabelText("Parameters").textContent).toContain( "query=Memory&category=security&source=user", ); @@ -329,9 +328,7 @@ describe("PluginCollectionToolbar", () => { `direction=${direction === "asc" ? "desc" : "asc"}`, ); fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); - expect( - screen.getByRole("button", { name: "Sort: Default" }).textContent, - ).toBe("Sort"); + expect(trigger.textContent).toBe("Sort"); }, ); From 62e7dc09e05fcf503ca0af664654eaac7935be56 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:04:41 +0000 Subject: [PATCH 53/79] Keep plugin menus stable while selection labels change --- .../management/PluginBrowseControls.test.tsx | 34 +++++++++++++++++-- .../src/components/ui/resource/toolbar.tsx | 25 +++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 5a4cd497025..2caee406357 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -248,7 +248,7 @@ function ToolbarHarness({ ); } -function mockToolbarWidth(initial: number) { +function mockToolbarWidth(initial: number, publishedWidth = 96) { let width = initial; const callbacks = new Set<() => void>(); const original = HTMLElement.prototype.getBoundingClientRect; @@ -260,7 +260,12 @@ function mockToolbarWidth(initial: number) { return new DOMRect( 0, 0, - this.querySelectorAll("button").length * 96, + [...this.querySelectorAll("button")].reduce( + (total, button) => + total + + (button.textContent === "Published" ? publishedWidth : 96), + 0, + ), 32, ); if (this.hasAttribute("data-resource-combined-controls")) @@ -302,6 +307,31 @@ function mockToolbarWidth(initial: number) { } describe("PluginCollectionToolbar", () => { + it("keeps an open sort menu stable when its selected label grows, then combines after dismissal", async () => { + const resize = mockToolbarWidth(370, 128); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: /^Sort:/u }), { + key: "Enter", + }); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Published" })); + resize(370); + expect( + screen.getByRole("menuitemradio", { name: "Published" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); + fireEvent.keyDown(document.activeElement ?? document.body, { + key: "Escape", + }); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Filter & sort" }), + ).toBeTruthy(), + ); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "sort=recently-added&direction=desc", + ); + }); + it.each([ { label: "Name", value: "name", direction: "asc" }, { label: "Published", value: "recently-added", direction: "desc" }, diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index b04491317cf..133d11485e9 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -66,9 +66,17 @@ export function ResourceToolbar({ const individualControls = individualControlsRef.current; const search = searchRef.current; if (!toolbar || !individualControls || !search || !compact) return; + let previousWidth = 0; const measure = () => { const width = toolbar.getBoundingClientRect().width; if (width === 0) return; + const widthChanged = previousWidth !== width; + previousWidth = width; + if ( + !widthChanged && + controlsRef.current?.querySelector('[aria-haspopup][data-state="open"]') + ) + return; const gap = Number.parseFloat(getComputedStyle(toolbar).columnGap) || 0; const searchWidth = Number.parseFloat(getComputedStyle(search).flexBasis); const actionWidth = actionRef.current?.getBoundingClientRect().width ?? 0; @@ -105,7 +113,22 @@ export function ResourceToolbar({ observer.observe(individualControls); if (controlsRef.current) observer.observe(controlsRef.current); if (actionRef.current) observer.observe(actionRef.current); - return () => observer.disconnect(); + let frame = 0; + const menuObserver = new MutationObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + }); + if (controlsRef.current) + menuObserver.observe(controlsRef.current, { + attributes: true, + attributeFilter: ["data-state"], + subtree: true, + }); + return () => { + observer.disconnect(); + menuObserver.disconnect(); + cancelAnimationFrame(frame); + }; }, [compact, expandSearchOnFocus, hasCombinedControls, showCombined]); useLayoutEffect(() => { From ccd9587887c6336bf5109f3d5ae353c02814f909 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:27:41 +0000 Subject: [PATCH 54/79] Restore icons on labeled plugin filters --- .../src/components/plugin/management/PluginBrowseControls.tsx | 1 + packages/shared-ui/src/components/ui/resource/toolbar.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index f4124c413b9..2ed6e2c8bbe 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -404,6 +404,7 @@ export function PluginBrowseCategoryFilter(props: PluginCategoryFilterProps) { label={`Filter plugins by category: ${accessibleSelectionLabel}`} text="Category" tooltip={`Category: ${accessibleSelectionLabel}`} + icon="SlidersHorizontal" count={value.length} trailingIcon="ChevronDown" active={value.length > 0} diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 133d11485e9..6943406f16c 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -490,7 +490,7 @@ export function ResourceMultiSelectMenu({ 0} From c2bb5e11ace36194e5eeb8775242fcce8c261da1 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:55:39 +0000 Subject: [PATCH 55/79] Remove search from plugin category menus --- .../management/PluginBrowseControls.test.tsx | 60 +++++++--------- .../management/PluginBrowseControls.tsx | 71 ++----------------- 2 files changed, 33 insertions(+), 98 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 2caee406357..3b947ea05ae 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -44,7 +44,7 @@ afterEach(() => { }); describe("PluginBrowseCategoryFilter", () => { - it("shows searchable counts and checkboxes", () => { + it("shows category counts and checkboxes without search", () => { render( { .querySelector("[data-category-option-checkbox]") ?.getAttribute("data-state"), ).toBe("disabled"); - const search = screen.getByRole("combobox", { - name: "Search plugin categories", - }); - fireEvent.change(search, { target: { value: "work" } }); - expect(screen.getAllByRole("option")).toHaveLength(1); - expect(screen.getByRole("option").textContent).toContain( - "Tasks & Workflows", - ); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.queryByRole("textbox")).toBeNull(); }); it("keeps the menu open for multiple selections", () => { @@ -118,7 +112,7 @@ describe("PluginBrowseCategoryFilter", () => { ).toBeTruthy(); }); - it("moves focus through options with the keyboard", () => { + it("moves focus through options with the keyboard", async () => { const onChange = vi.fn(); render( { />, ); openMenu("Tasks & Workflows"); - const search = screen.getByRole("combobox", { - name: "Search plugin categories", + const firstOption = screen.getByRole("option", { + name: /Memory & Context/u, }); - fireEvent.keyDown(search, { key: "ArrowDown" }); - expect(document.activeElement?.textContent).toContain("Memory & Context"); + await waitFor(() => expect(document.activeElement).toBe(firstOption)); + fireEvent.keyDown(firstOption, { key: "ArrowDown" }); + expect(document.activeElement).toBe( + screen.getByRole("option", { name: /Security/u }), + ); fireEvent.keyDown(document.activeElement as HTMLElement, { key: "End" }); expect(document.activeElement?.textContent).toContain("Tasks & Workflows"); fireEvent.click(document.activeElement as HTMLElement); expect(onChange).toHaveBeenCalledWith([]); }); - it("keeps keyboard focus inside each filter instance", () => { + it("keeps keyboard focus inside each filter instance", async () => { render( <> { name: "Filter plugins by category: All categories", }); fireEvent.click(triggers[0] as HTMLButtonElement); - const firstSearch = screen.getByRole("combobox", { - name: "Search plugin categories", - }); const firstList = screen.getByRole("listbox", { name: "Plugin categories", }); - expect(firstSearch.getAttribute("aria-controls")).toBe(firstList.id); + await waitFor(() => + expect(firstList.contains(document.activeElement)).toBe(true), + ); fireEvent.click(triggers[0] as HTMLButtonElement); fireEvent.click(triggers[1] as HTMLButtonElement); - const secondSearch = screen.getByRole("combobox", { - name: "Search plugin categories", - }); const secondList = screen.getByRole("listbox", { name: "Plugin categories", }); - expect(secondSearch.getAttribute("aria-controls")).toBe(secondList.id); - expect(firstList.id).not.toBe(secondList.id); - - fireEvent.keyDown(secondSearch, { key: "ArrowDown" }); + const firstOption = screen.getByRole("option", { + name: /Memory & Context/u, + }); + await waitFor(() => expect(document.activeElement).toBe(firstOption)); + fireEvent.keyDown(firstOption, { key: "ArrowDown" }); expect(secondList.contains(document.activeElement)).toBe(true); expect(firstList.contains(document.activeElement)).toBe(false); }); @@ -474,7 +468,7 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); }); - it("shares sort, category search, and source clearing in the combined menu without resetting other values", async () => { + it("shares sort, category selection, and source clearing in the combined menu without resetting other values", async () => { mockToolbarWidth(320); render(); fireEvent.keyDown(screen.getByRole("button", { name: "Filter & sort" }), { @@ -503,12 +497,12 @@ describe("PluginCollectionToolbar", () => { ), ); fireEvent.click(screen.getByRole("menuitem", { name: /^Category/u })); - const search = screen.getByRole("combobox", { - name: "Search plugin categories", + expect(screen.queryByRole("combobox")).toBeNull(); + const firstOption = screen.getByRole("option", { + name: /Memory & Context/u, }); - await waitFor(() => expect(document.activeElement).toBe(search)); - fireEvent.change(search, { target: { value: "security" } }); - fireEvent.keyDown(search, { key: "ArrowDown" }); + await waitFor(() => expect(document.activeElement).toBe(firstOption)); + fireEvent.keyDown(firstOption, { key: "ArrowDown" }); expect(document.activeElement).toBe( screen.getByRole("option", { name: /Security/u }), ); @@ -542,7 +536,7 @@ describe("PluginCollectionToolbar", () => { ); await waitFor(() => expect(document.activeElement).toBe( - screen.getByRole("combobox", { name: "Search plugin categories" }), + screen.getByRole("option", { name: /Memory & Context/u }), ), ); fireEvent.click(screen.getByRole("menuitem", { name: "Back to controls" })); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 2ed6e2c8bbe..67532ed78d1 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, - useId, useRef, useState, type ReactNode, @@ -14,7 +13,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; -import { Input } from "@bb/shared-ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@bb/shared-ui/popover"; import { cn } from "@bb/shared-ui/lib/utils"; import { @@ -427,19 +425,8 @@ function PluginCategoryOptions({ value, onChange, }: PluginCategoryFilterProps) { - const [search, setSearch] = useState(""); - const [showKeyboardFocus, setShowKeyboardFocus] = useState(false); const [scrollbarScrolling, setScrollbarScrolling] = useState(false); - const listboxId = useId(); - const inputRef = useRef(null); - const keyboardFocusRef = useRef(false); const selected = new Set(value); - const normalizedSearch = search.trim().toLocaleLowerCase(); - const filteredOptions = options.filter((option) => - `${option.label} ${option.id}` - .toLocaleLowerCase() - .includes(normalizedSearch), - ); const [listElement, setListElement] = useState(null); const { scrollRef: listRef, @@ -461,10 +448,10 @@ function PluginCategoryOptions({ useEffect(() => { const animationFrame = requestAnimationFrame(() => - inputRef.current?.focus(), + categoryOptionElements(listRef.current)[0]?.focus(), ); return () => cancelAnimationFrame(animationFrame); - }, []); + }, [listRef]); useEffect( () => () => { @@ -501,52 +488,8 @@ function PluginCategoryOptions({ return ( <> -
- - setSearch(event.target.value)} - placeholder="Search categories" - aria-label="Search plugin categories" - role="combobox" - aria-controls={listboxId} - aria-expanded={true} - aria-autocomplete="list" - className={cn( - "h-7 border-transparent bg-surface-recessed pl-7 pr-2 text-xs focus-visible:ring-0", - showKeyboardFocus && "ring-1 ring-ring", - )} - onFocus={() => setShowKeyboardFocus(keyboardFocusRef.current)} - onBlur={() => setShowKeyboardFocus(false)} - onPointerDown={() => { - keyboardFocusRef.current = false; - setShowKeyboardFocus(false); - }} - onKeyDown={(event) => { - keyboardFocusRef.current = true; - setShowKeyboardFocus(true); - if (event.key === "ArrowDown") { - event.preventDefault(); - event.stopPropagation(); - categoryOptionElements(listElement)[0]?.focus(); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - event.stopPropagation(); - categoryOptionElements(listElement).at(-1)?.focus(); - } else if (event.key !== "Escape" && event.key !== "Tab") { - event.stopPropagation(); - } - }} - /> -
-
+
- {filteredOptions.length === 0 ? ( + {options.length === 0 ? (

- {options.length === 0 - ? "No categories are available." - : "No categories match your search."} + No categories are available.

) : ( - filteredOptions.map((option) => { + options.map((option) => { return (
) : ( <> -
- -
- - - - - - - - - Install from source - - - + {isCompact ? ( + + {PLUGINS_BROWSE_DESCRIPTION} + + ) : ( +
+ + {createAction}
-
+ )} -
+
{(searchQuery.isError || activeQuery.isError) && diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 1c416959baf..dc32972e8c1 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -8,6 +8,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -302,6 +303,52 @@ function mockToolbarWidth(initial: number, publishedWidth = 96) { } describe("PluginCollectionToolbar", () => { + it.each([ + { control: /^Filter plugins by category:/u, tooltip: "Category: Security" }, + { control: /^Source:/u, tooltip: "Source: Local" }, + { control: /^Sort:/u, tooltip: "Sort: Name · Z–A" }, + ])( + "describes applied selections in the $tooltip tooltip", + async ({ control, tooltip }) => { + mockToolbarWidth(800); + render(); + const trigger = screen.getByRole("button", { name: control }); + act(() => trigger.focus()); + expect((await screen.findByRole("tooltip")).textContent).toBe(tooltip); + }, + ); + + it("describes all applied selections in the combined trigger tooltip", async () => { + mockToolbarWidth(320); + render(); + act(() => screen.getByRole("button", { name: "Filter & sort" }).focus()); + expect((await screen.findByRole("tooltip")).textContent).toBe( + "Category: Security; Source: Local; Sort: Name · Z–A", + ); + }); + + it.each([ + { + control: /^Source:/u, + heading: "Source", + role: "menuitemcheckbox" as const, + }, + { control: /^Sort:/u, heading: "Sort", role: "menuitemradio" as const }, + ])( + "opens $heading without a redundant heading", + ({ control, heading, role }) => { + mockToolbarWidth(800); + render(); + const trigger = screen.getByRole("button", { name: control }); + expect(trigger.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); + expect(trigger.querySelector('[data-icon="Layers"]')).toBeNull(); + fireEvent.keyDown(trigger, { key: "Enter" }); + const menu = screen.getByRole("menu"); + expect(screen.getAllByRole(role).length).toBeGreaterThan(0); + expect(within(menu).queryByText(heading)).toBeNull(); + }, + ); + it("keeps an open sort menu stable when its selected label grows, then combines after dismissal", async () => { const resize = mockToolbarWidth(370, 128); render(); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 67532ed78d1..1375c0b4b45 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -119,6 +119,7 @@ export function PluginCollectionToolbar({ direction: sortDirection, compact: true, clearInFooter: true, + showHeading: false, placeholderLabel: "Default", options: pluginBrowseSortOptions(installsKnown, sort, sortDirection), onChange: (value: string) => @@ -146,12 +147,22 @@ export function PluginCollectionToolbar({ }), }; const sourceProps = sourceFilter - ? { ...sourceFilter, label: "Source", compact: true, clearInFooter: true } + ? { + ...sourceFilter, + label: "Source", + compact: true, + clearInFooter: true, + showHeading: false, + } : null; const sortIcon = sort === null ? "ArrowUpDown" : PLUGIN_BROWSE_SORT_ICONS[sort][sortDirection]; + const sortSummary = + sort === null + ? "Default" + : `${PLUGIN_BROWSE_SORT_LABELS[sort]} · ${PLUGIN_BROWSE_SORT_DIRECTIONS[sort][sortDirection]}`; const controls = [ ...(showCategoryFilter ? [ @@ -190,10 +201,7 @@ export function PluginCollectionToolbar({ label: "Sort", icon: sortIcon, active: sort !== null, - summary: - sort === null - ? "Default" - : `${PLUGIN_BROWSE_SORT_LABELS[sort]} · ${PLUGIN_BROWSE_SORT_DIRECTIONS[sort][sortDirection]}`, + summary: sortSummary, content: , }, ] satisfies PluginControlPage[]; @@ -218,13 +226,14 @@ export function PluginCollectionToolbar({ ) : null} {sourceProps ? ( - + ) : null} - + } combinedControls={ @@ -258,7 +267,7 @@ function PluginControlsMenu({ const page = pages.find((candidate) => candidate.id === pageId); const activeLabels = pages .filter((candidate) => candidate.active) - .map((candidate) => candidate.label); + .map((candidate) => `${candidate.label}: ${candidate.summary}`); useEffect(() => { if (!open) return; @@ -298,9 +307,7 @@ function PluginControlsMenu({ label="Filter & sort" text="Filter & sort" tooltip={ - activeLabels.length - ? `Filter & sort: ${activeLabels.join(", ")}` - : "Filter & sort" + activeLabels.length ? activeLabels.join("; ") : "Filter & sort" } icon="FilterHorizontal" active={activeLabels.length > 0} diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index 0330ed00cd2..511f358bd71 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -41,7 +41,6 @@ import { THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT, } from "./secondaryPanelSizing"; import { - getCompactPanelPresentation, RIGHT_PANEL_TOGGLE_ICON_NAME, resolveConversationCollapseControl, } from "./panelToggleControlState"; @@ -248,12 +247,6 @@ function ThreadSecondaryPanelContent({ () => tabs.filter((tab) => tab.isHidden !== true), [tabs], ); - const reservesCompactSidebarToggle = - renderAsDrawer && - getCompactPanelPresentation( - activeTab?.kind, - fixedTabs[0]?.tab.kind ?? visibleTabs[0]?.tab.kind, - ) === "full"; const activeRenderableTab = tabs.find((tab) => tab.tab.id === activeTab?.id) ?? (activeTab === null && fixedTabs.length === 0 ? visibleTabs[0] : undefined); @@ -707,7 +700,6 @@ function ThreadSecondaryPanelContent({ className={cn( CHROME_ROW_CLASS, "min-w-0 justify-between gap-2 px-4", - reservesCompactSidebarToggle && "pl-14", usesDesktopChrome && usesWindowChrome && MACOS_WINDOW_DRAG_CLASS, usesDesktopChrome && usesWindowChrome && diff --git a/apps/app/src/components/ui/secondary-panel-shelf-visibility.ts b/apps/app/src/components/ui/secondary-panel-shelf-visibility.ts index 588432f847c..b1162b95c01 100644 --- a/apps/app/src/components/ui/secondary-panel-shelf-visibility.ts +++ b/apps/app/src/components/ui/secondary-panel-shelf-visibility.ts @@ -3,7 +3,7 @@ import { useSyncExternalStore } from "react"; export type CompactSecondaryPanelPresentation = "closed" | "shelf" | "full"; export const COMPACT_SHELF_HIDDEN_FIXED_CHROME_CLASS = - "data-[panel-shelf=shelf]:invisible"; + "data-[panel-shelf=shelf]:invisible data-[panel-shelf=full]:invisible"; let compactSecondaryPanelPresentation: CompactSecondaryPanelPresentation = "closed"; diff --git a/packages/shared-ui/src/components/ui/resource-list.tsx b/packages/shared-ui/src/components/ui/resource-list.tsx index fbd8268a46b..ed3cfe05d56 100644 --- a/packages/shared-ui/src/components/ui/resource-list.tsx +++ b/packages/shared-ui/src/components/ui/resource-list.tsx @@ -18,6 +18,7 @@ export { ResourceMultiSelectMenu, ResourceSortMenu, ResourceToolbar, + ResourceTabDescription, } from "./resource/toolbar"; export { ResourceActionButton, diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 6943406f16c..109f2dc3db7 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -397,6 +397,7 @@ export function ResourceMultiSelectMenuItems({ onChange, compact = false, clearInFooter = false, + showHeading = true, }: { label: string; selectedValues: readonly string[]; @@ -404,6 +405,7 @@ export function ResourceMultiSelectMenuItems({ onChange: (values: string[]) => void; compact?: boolean; clearInFooter?: boolean; + showHeading?: boolean; }) { const selected = new Set(selectedValues); function updateValue(option: ResourceOption, checked: boolean) { @@ -412,14 +414,16 @@ export function ResourceMultiSelectMenuItems({ } return ( <> - - {label} - + {showHeading ? ( + + {label} + + ) : null} {options.map((option) => ( void; compact?: boolean; clearInFooter?: boolean; showLabel?: boolean; + showHeading?: boolean; }) { const [open, setOpen] = useState(false); const selected = new Set(selectedValues); const activeOptions = options.filter((option) => selected.has(option.id)); const activeSelectedCount = activeOptions.length; const selectionSummary = - activeSelectedCount === 0 ? "All" : `${activeSelectedCount} selected`; + activeSelectedCount === 0 + ? "All" + : activeOptions.map((option) => option.label).join(", "); const triggerLabel = activeSelectedCount === 0 ? label @@ -509,6 +517,7 @@ export function ResourceMultiSelectMenu({ onChange={onChange} compact={compact} clearInFooter={clearInFooter} + showHeading={showHeading} /> @@ -614,6 +623,7 @@ export function ResourceSortMenuItems({ placeholderLabel = "Sort", compact = false, clearInFooter = false, + showHeading = true, }: { value: string | null; direction: "asc" | "desc"; @@ -623,17 +633,20 @@ export function ResourceSortMenuItems({ placeholderLabel?: string; compact?: boolean; clearInFooter?: boolean; + showHeading?: boolean; }) { return ( <> - - Sort - + {showHeading ? ( + + Sort + + ) : null} {onClear === undefined || clearInFooter ? null : ( option.id === value); @@ -753,6 +770,8 @@ export function ResourceSortMenu({ label={sortStateLabel} text={showLabel ? (selectedOption?.label ?? "Sort") : undefined} icon={triggerIcon} + trailingIcon={showLabel ? "ChevronDown" : undefined} + tooltip={tooltip ?? sortStateLabel} active={onClear !== undefined && value !== null} open={open} /> @@ -770,6 +789,7 @@ export function ResourceSortMenu({ placeholderLabel={placeholderLabel} compact={compact} clearInFooter={clearInFooter} + showHeading={showHeading} /> From 20b75f5246f8ea35b8e940bd2ba1c765fcda4e28 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:22:06 +0000 Subject: [PATCH 58/79] Remove obsolete compact sidebar padding assertion --- .../ThreadSecondaryPanel.collapseControl.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx index 9682c5b8b5d..ebee4d52eef 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx @@ -195,11 +195,6 @@ describe("ThreadSecondaryPanel compact file content", () => { ); expect(screen.getByText("Recovered tab body")).toBeTruthy(); - expect( - screen - .getByTestId("thread-secondary-panel-top-chrome") - .classList.contains("pl-14"), - ).toBe(true); }); it("renders arbitrary fixed-tab content through the shared surface", () => { From 0fcc123e402c50cc81b3b5cd99ccff8c8cdb6ba0 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:52:30 +0000 Subject: [PATCH 59/79] Align plugin toolbar icons and local category filtering --- .../plugin/PluginsOverview.test.tsx | 22 +++++++++++++++---- .../src/components/plugin/PluginsOverview.tsx | 7 ++++-- .../management/PluginBrowseControls.test.tsx | 11 ++++++++-- .../management/PluginBrowseControls.tsx | 1 + .../plugin/plugins-collection-copy.ts | 2 +- .../src/components/ui/resource/toolbar.tsx | 9 +------- 6 files changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index f2b229dc5ec..7a56994cac2 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -751,7 +751,7 @@ describe("PluginsOverview", () => { ).toBe("true"); }); - it("filters Installed by catalog and local categories and clears the selection", async () => { + it("groups path installs as Local while preserving marketplace categories", async () => { installFetch([ AUTOMATIONS_PLUGIN, { @@ -766,12 +766,20 @@ describe("PluginsOverview", () => { }, { ...AUTOMATIONS_PLUGIN, - id: "uncategorized", + id: "local-other", source: "path:/workspace/other", name: "Other Plugin", provenance: "direct", publisherLabel: null, }, + { + ...AUTOMATIONS_PLUGIN, + id: "uncategorized", + source: "git:https://github.com/example/uncategorized.git", + name: "Marketplace Plugin", + provenance: "direct", + publisherLabel: null, + }, ]); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( @@ -788,17 +796,23 @@ describe("PluginsOverview", () => { }), ); fireEvent.click( - await screen.findByRole("option", { name: /Memory & Context, 1 plugin/ }), + await screen.findByRole("option", { name: /Local, 2 plugins/ }), ); expect(screen.getByText("Local Notes")).toBeTruthy(); + expect(screen.getByText("Other Plugin")).toBeTruthy(); + expect(screen.queryByText("Marketplace Plugin")).toBeNull(); + expect( + screen.queryByRole("option", { name: /Memory & Context/ }), + ).toBeNull(); expect(screen.queryByText("Automations")).toBeNull(); fireEvent.click( screen.getByRole("option", { name: /Workflow management, 1 plugin/ }), ); expect(screen.getByText("Automations")).toBeTruthy(); - expect(screen.queryByText("Other Plugin")).toBeNull(); + expect(screen.queryByText("Marketplace Plugin")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); expect(screen.getByText("Other Plugin")).toBeTruthy(); + expect(screen.getByText("Marketplace Plugin")).toBeTruthy(); }); it("keeps disabled plugins installed regardless of provenance", async () => { diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 38fc52fb29a..5fd24717fdc 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -79,12 +79,15 @@ export function PluginsOverview({ plugin, catalogQuery.data?.entries ?? [], ); + const isLocal = plugin.source.startsWith("path:"); return { plugin, entryId: plugin.id, displayName: plugin.name ?? plugin.id, - categoryId: entry?.categoryId ?? plugin.categoryId, - category: entry?.category ?? plugin.category, + categoryId: isLocal + ? "local" + : (entry?.categoryId ?? plugin.categoryId), + category: isLocal ? "Local" : (entry?.category ?? plugin.category), publishedAt: entry?.publishedAt, installs: entry?.installs ?? null, }; diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index dc32972e8c1..f7a9ac96e31 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -331,17 +331,24 @@ describe("PluginCollectionToolbar", () => { { control: /^Source:/u, heading: "Source", + icon: "PackageReceive", role: "menuitemcheckbox" as const, }, - { control: /^Sort:/u, heading: "Sort", role: "menuitemradio" as const }, + { + control: /^Sort:/u, + heading: "Sort", + icon: "SortingZA01", + role: "menuitemradio" as const, + }, ])( "opens $heading without a redundant heading", - ({ control, heading, role }) => { + ({ control, heading, icon, role }) => { mockToolbarWidth(800); render(); const trigger = screen.getByRole("button", { name: control }); expect(trigger.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); expect(trigger.querySelector('[data-icon="Layers"]')).toBeNull(); + expect(trigger.querySelector(`[data-icon="${icon}"]`)).not.toBeNull(); fireEvent.keyDown(trigger, { key: "Enter" }); const menu = screen.getByRole("menu"); expect(screen.getAllByRole(role).length).toBeGreaterThan(0); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 1375c0b4b45..5b543d28221 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -150,6 +150,7 @@ export function PluginCollectionToolbar({ ? { ...sourceFilter, label: "Source", + icon: "PackageReceive" as const, compact: true, clearInFooter: true, showHeading: false, diff --git a/apps/app/src/components/plugin/plugins-collection-copy.ts b/apps/app/src/components/plugin/plugins-collection-copy.ts index 0ad964f5ad2..99ea0ba298e 100644 --- a/apps/app/src/components/plugin/plugins-collection-copy.ts +++ b/apps/app/src/components/plugin/plugins-collection-copy.ts @@ -1,5 +1,5 @@ export const PLUGINS_BROWSE_DESCRIPTION = - "Plugins add app surfaces, commands, services, schedules, and skills to bb. Install an official plugin, or describe your own and build it from a prompt."; + "Discover plugins for bb. Install one, or describe your own and build it from a prompt."; export const PLUGINS_INSTALLED_DESCRIPTION = "The plugins installed on this bb host. Turn one on or off, apply updates, or open it for settings and details."; diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 109f2dc3db7..277833117db 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -840,14 +840,7 @@ export function ResourceCreateButton({ className={cn("rounded-r-none", compactWhenNarrow && "pl-2 pr-1")} onClick={() => onCreate()} > - + {label} ); From df59b2860f04d35e994a02f3b70c7efbfc7b9929 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:56:47 +0000 Subject: [PATCH 60/79] Keep condensed search within its available toolbar width --- .../src/components/ui/resource/toolbar.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 277833117db..c722ce411a4 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -154,7 +154,7 @@ export function ResourceToolbar({
{controls ? ( From c74e9ac2b4a941f6837056751e4958ffa812ec1f Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:37:26 +0000 Subject: [PATCH 61/79] Omit uncategorized plugins from category filter options --- .../plugin/PluginsOverview.test.tsx | 1 + .../management/BrowsePluginsTab.test.tsx | 3 +-- .../plugin-browse-discovery.test.ts | 26 ++++++++++++++++++ .../management/plugin-browse-discovery.ts | 27 +++++-------------- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 7a56994cac2..b209c275f7f 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -798,6 +798,7 @@ describe("PluginsOverview", () => { fireEvent.click( await screen.findByRole("option", { name: /Local, 2 plugins/ }), ); + expect(screen.queryByRole("option", { name: /Uncategorized/ })).toBeNull(); expect(screen.getByText("Local Notes")).toBeTruthy(); expect(screen.getByText("Other Plugin")).toBeTruthy(); expect(screen.queryByText("Marketplace Plugin")).toBeNull(); diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index be9a7d6e3a6..4c8f4147b4d 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -277,7 +277,7 @@ describe("BrowsePluginsTab", () => { ]); }); - it("orders category options by the shelf category order", async () => { + it("orders category options by shelf order and omits missing categories", async () => { renderBrowse({ entries: [ TASKS_ENTRY, @@ -317,7 +317,6 @@ describe("BrowsePluginsTab", () => { expect.stringContaining("Security"), expect.stringContaining("Tasks & Workflows"), expect.stringContaining("Unknown Category"), - expect.stringContaining("Uncategorized"), ]); }); diff --git a/apps/app/src/components/plugin/management/plugin-browse-discovery.test.ts b/apps/app/src/components/plugin/management/plugin-browse-discovery.test.ts index 27e8f9cfd01..b44c6a15e91 100644 --- a/apps/app/src/components/plugin/management/plugin-browse-discovery.test.ts +++ b/apps/app/src/components/plugin/management/plugin-browse-discovery.test.ts @@ -5,6 +5,7 @@ import type { } from "@/hooks/queries/plugin-catalog-queries"; import { pluginBrowseShelves, + pluginCategoryFilterOptions, sortPluginEntries, } from "./plugin-browse-discovery"; @@ -148,6 +149,31 @@ describe("plugin browse shelves", () => { }); }); +describe("plugin category filters", () => { + it("omits missing categories even when previously selected, preserving Local and custom categories", () => { + expect( + pluginCategoryFilterOptions( + [ + entry("categorized"), + entry("no-category", { categoryId: undefined, category: undefined }), + entry("no-id", { categoryId: undefined }), + entry("no-label", { category: undefined }), + entry("local", { categoryId: "local", category: "Local" }), + entry("custom", { + categoryId: "observability", + category: "Observability", + }), + ], + ["uncategorized"], + ), + ).toEqual([ + { id: "thread-content", label: "Thread Content", count: 1 }, + { id: "local", label: "Local", count: 1 }, + { id: "observability", label: "Observability", count: 1 }, + ]); + }); +}); + describe("plugin browse sorting", () => { it("puts entries without a published date last in both directions", () => { const entries = [ diff --git a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts index 1435c706aab..8f55f10f03e 100644 --- a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts +++ b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts @@ -34,32 +34,20 @@ export function pluginCategoryFilterOptions( const unknownIds: string[] = []; for (const entry of entries) { const id = pluginCategoryFilterId(entry); + if (id === UNCATEGORIZED_PLUGIN_CATEGORY_ID) continue; if (!labels.has(id)) { - labels.set( - id, - id === UNCATEGORIZED_PLUGIN_CATEGORY_ID - ? "Uncategorized" - : (entry.category ?? id), - ); - if ( - id !== UNCATEGORIZED_PLUGIN_CATEGORY_ID && - pluginCatalogCategory(id) === undefined - ) { + labels.set(id, entry.category ?? id); + if (pluginCatalogCategory(id) === undefined) { unknownIds.push(id); } } counts.set(id, (counts.get(id) ?? 0) + 1); } for (const id of selected) { - if (labels.has(id)) continue; + if (id === UNCATEGORIZED_PLUGIN_CATEGORY_ID || labels.has(id)) continue; const category = pluginCatalogCategory(id); - labels.set( - id, - id === UNCATEGORIZED_PLUGIN_CATEGORY_ID - ? "Uncategorized" - : (category?.displayName ?? id), - ); - if (id !== UNCATEGORIZED_PLUGIN_CATEGORY_ID && category === undefined) { + labels.set(id, category?.displayName ?? id); + if (category === undefined) { unknownIds.push(id); } } @@ -68,9 +56,6 @@ export function pluginCategoryFilterOptions( labels.has(id), ), ...unknownIds, - ...(labels.has(UNCATEGORIZED_PLUGIN_CATEGORY_ID) - ? [UNCATEGORIZED_PLUGIN_CATEGORY_ID] - : []), ]; return orderedIds.map((id) => ({ id, From 7455bf8420cd7a6cdd4c4e5e179cd8d3bf90d8c0 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:41:17 +0000 Subject: [PATCH 62/79] Align Browse shelves with the collection toolbar --- apps/app/src/components/plugin/management/BrowsePluginsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 9297bd561c3..3b871341068 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -240,7 +240,7 @@ export function BrowsePluginsTab({ {composing && shelfKey === null ? ( ) : ( -
+
Date: Sat, 19 Sep 2026 20:25:17 +0000 Subject: [PATCH 63/79] Truncate collapsed search queries and clarify filter menu back label --- .../plugin/management/PluginBrowseControls.test.tsx | 6 +++--- .../plugin/management/PluginBrowseControls.tsx | 2 +- .../shared-ui/src/components/ui/resource/toolbar.tsx | 10 +++++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index f7a9ac96e31..4782dacbc47 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -545,7 +545,7 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByLabelText("Parameters").textContent).toBe( "query=Memory&category=security&source=user", ); - fireEvent.click(screen.getByRole("menuitem", { name: "Back to controls" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Filter & sort" })); await waitFor(() => expect(document.activeElement).toBe( screen.getByRole("menuitem", { name: /^Sort/u }), @@ -565,7 +565,7 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByLabelText("Parameters").textContent).toBe( "query=Memory&source=user", ); - fireEvent.click(screen.getByRole("menuitem", { name: "Back to controls" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Filter & sort" })); fireEvent.click(screen.getByRole("menuitem", { name: /^Source/u })); fireEvent.click(screen.getByRole("menuitem", { name: "Clear filter" })); expect(screen.getByLabelText("Parameters").textContent).toBe( @@ -594,7 +594,7 @@ describe("PluginCollectionToolbar", () => { screen.getByRole("option", { name: /Memory & Context/u }), ), ); - fireEvent.click(screen.getByRole("menuitem", { name: "Back to controls" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Filter & sort" })); await waitFor(() => expect(document.activeElement).toBe( screen.getByRole("menuitem", { name: /^Category/u }), diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 5b543d28221..e2e8f0bfc69 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -331,7 +331,7 @@ function PluginControlsMenu({ }} > - Back to controls + Filter & sort {page.content} diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index c722ce411a4..b048f6b10e8 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -166,7 +166,9 @@ export function ResourceToolbar({ "pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground", searchCondensed && !searchExpanded && - "@max-[4rem]/resource-search:left-1/2 @max-[4rem]/resource-search:-translate-x-1/2", + (searchValue + ? "@max-[4rem]/resource-search:hidden" + : "@max-[4rem]/resource-search:left-1/2 @max-[4rem]/resource-search:-translate-x-1/2"), )} aria-hidden /> @@ -193,10 +195,12 @@ export function ResourceToolbar({ } }} className={cn( - "h-8 pl-8", + "h-8 truncate pl-8 focus:text-clip", searchCondensed && !searchExpanded && - "@max-[4rem]/resource-search:px-0 @max-[4rem]/resource-search:text-transparent @max-[4rem]/resource-search:placeholder:text-transparent", + (searchValue + ? "@max-[4rem]/resource-search:px-1" + : "@max-[4rem]/resource-search:px-0 @max-[4rem]/resource-search:text-transparent @max-[4rem]/resource-search:placeholder:text-transparent"), )} />
From d309effae81ca0fcb63381396fb6aa1b30e06c9f Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:41:11 +0000 Subject: [PATCH 64/79] Use install icon for the plugin Source filter --- .../src/components/plugin/management/PluginBrowseControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index e2e8f0bfc69..b8d62361c8c 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -150,7 +150,7 @@ export function PluginCollectionToolbar({ ? { ...sourceFilter, label: "Source", - icon: "PackageReceive" as const, + icon: "Download" as const, compact: true, clearInFooter: true, showHeading: false, From 672a181953da01e3aa447376dc131524e6541bb9 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:45:13 +0000 Subject: [PATCH 65/79] Align Source control interaction assertion with install icon --- .../components/plugin/management/PluginBrowseControls.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 4782dacbc47..8db17906502 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -331,7 +331,7 @@ describe("PluginCollectionToolbar", () => { { control: /^Source:/u, heading: "Source", - icon: "PackageReceive", + icon: "Download", role: "menuitemcheckbox" as const, }, { From 2594d0499965117f7954d5c1573ecca7a7e3c608 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:12:12 +0000 Subject: [PATCH 66/79] Make compact plugin search readable and explicitly submittable --- .../management/PluginBrowseControls.test.tsx | 29 ++++- .../src/components/ui/resource/toolbar.tsx | 116 ++++++++++-------- 2 files changed, 96 insertions(+), 49 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 8db17906502..ad88a9cba83 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -275,7 +275,7 @@ function mockToolbarWidth(initial: number, publishedWidth = 96) { style.id = "toolbar-test-layout"; style.textContent = ` [data-resource-toolbar] { column-gap: 8px; } - [data-resource-toolbar] > div:first-child { flex-basis: 160px; } + [data-resource-toolbar] > form { flex-basis: 160px; } `; document.head.append(style); vi.stubGlobal( @@ -458,6 +458,33 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); }); + it("keeps expanded search open while tabbing to its submit button and restores input focus after submission", () => { + mockToolbarWidth(320); + render(); + const search = screen.getByRole("textbox", { name: "Search plugins" }); + act(() => search.focus()); + fireEvent.change(search, { target: { value: "Notes" } }); + const submit = screen.getByRole("button", { name: "Search", exact: true }); + act(() => submit.focus()); + expect(document.activeElement).toBe(submit); + expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); + fireEvent.click(submit); + expect(document.activeElement).toBe(search); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Notes&category=security&source=user&sort=name&direction=desc", + ); + fireEvent.click(search); + expect( + screen.getByRole("button", { name: "Search", exact: true }), + ).toBeTruthy(); + fireEvent.keyDown(search, { key: "Escape" }); + expect(document.activeElement).toBe(search); + expect( + screen.queryByRole("button", { name: "Search", exact: true }), + ).toBeNull(); + }); + it("keeps wide search beside its controls when focused", () => { mockToolbarWidth(800); render(); diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index b048f6b10e8..9792c6e3270 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -51,7 +51,7 @@ export function ResourceToolbar({ const toolbarRef = useRef(null); const controlsRef = useRef(null); const individualControlsRef = useRef(null); - const searchRef = useRef(null); + const searchRef = useRef(null); const actionRef = useRef(null); const [combined, setCombined] = useState(false); const [searchCondensed, setSearchCondensed] = useState(false); @@ -151,59 +151,66 @@ export function ResourceToolbar({ compact ? "@container/resource-toolbar flex-nowrap" : "flex-wrap", )} > -
{ + event.preventDefault(); + searchRef.current?.querySelector("input")?.focus(); + setSearchExpanded(false); + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) + setSearchExpanded(false); + }} className={cn( - "relative @container/resource-search", + "flex items-center gap-2", compact ? "min-w-0 flex-1 basis-40" : "w-full min-w-0 sm:w-auto sm:flex-1", + expandSearchOnFocus && "min-w-20", )} > - - onSearchChange(event.target.value)} - placeholder={ - searchCondensed && !searchExpanded ? "Search" : searchPlaceholder - } - aria-label={searchLabel ?? searchPlaceholder} - enterKeyHint={expandSearchOnFocus ? "search" : undefined} - onFocus={() => { - if (searchCondensed) setSearchExpanded(true); - }} - onBlur={() => setSearchExpanded(false)} - onKeyDown={(event) => { - if ( - searchExpanded && - !event.nativeEvent.isComposing && - (event.key === "Enter" || event.key === "Escape") - ) { - event.preventDefault(); - event.currentTarget.blur(); +
+ + onSearchChange(event.target.value)} + placeholder={ + searchCondensed && !searchExpanded ? "Search" : searchPlaceholder } - }} - className={cn( - "h-8 truncate pl-8 focus:text-clip", - searchCondensed && - !searchExpanded && - (searchValue - ? "@max-[4rem]/resource-search:px-1" - : "@max-[4rem]/resource-search:px-0 @max-[4rem]/resource-search:text-transparent @max-[4rem]/resource-search:placeholder:text-transparent"), - )} - /> -
+ aria-label={searchLabel ?? searchPlaceholder} + enterKeyHint={expandSearchOnFocus ? "search" : undefined} + onFocus={() => { + if (searchCondensed) setSearchExpanded(true); + }} + onClick={() => { + if (searchCondensed) setSearchExpanded(true); + }} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } else if (event.key === "Escape" && searchExpanded) { + event.preventDefault(); + setSearchExpanded(false); + } + }} + className="h-8 truncate pl-8 focus:border-ring/60 focus:text-clip focus:ring-2 focus:ring-ring/20 focus-visible:ring-2 focus-visible:ring-ring/20" + /> +
+ {searchExpanded ? ( + + ) : null} + {controls ? (
- {icon ? : null} + {icon ? ( + + ) : null} {text ? {text} : null} {count !== undefined && count > 0 ? ( onCreate()} > - + {label} ); From b720d9bd70c750f6f27d3fd185bb988838bf9d6b Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:20:55 +0000 Subject: [PATCH 67/79] Update search form accessibility assertions --- .../app/src/components/plugin/PluginsOverview.test.tsx | 6 +++--- .../plugin/management/PluginBrowseControls.test.tsx | 10 +++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index b209c275f7f..95bbcd82319 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -505,13 +505,13 @@ describe("PluginsOverview", () => { ), ).toBeNull(); const search = screen.getByRole("textbox", { name: "Search plugins" }); - const toolbar = search.parentElement?.parentElement as HTMLElement; + const toolbar = search.closest("[data-resource-toolbar]"); const category = screen.getByRole("button", { name: "Filter plugins by category: All categories", }); const sort = screen.getByRole("button", { name: /^Sort:/ }); - expect(toolbar.contains(category)).toBe(true); - expect(toolbar.contains(sort)).toBe(true); + expect(toolbar?.contains(category)).toBe(true); + expect(toolbar?.contains(sort)).toBe(true); const heroHeading = screen.getByRole("heading", { level: 2, name: /^Turn bb into/, diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index ad88a9cba83..54dd1477de6 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -464,7 +464,7 @@ describe("PluginCollectionToolbar", () => { const search = screen.getByRole("textbox", { name: "Search plugins" }); act(() => search.focus()); fireEvent.change(search, { target: { value: "Notes" } }); - const submit = screen.getByRole("button", { name: "Search", exact: true }); + const submit = screen.getByRole("button", { name: "Search" }); act(() => submit.focus()); expect(document.activeElement).toBe(submit); expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); @@ -475,14 +475,10 @@ describe("PluginCollectionToolbar", () => { "query=Notes&category=security&source=user&sort=name&direction=desc", ); fireEvent.click(search); - expect( - screen.getByRole("button", { name: "Search", exact: true }), - ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Search" })).toBeTruthy(); fireEvent.keyDown(search, { key: "Escape" }); expect(document.activeElement).toBe(search); - expect( - screen.queryByRole("button", { name: "Search", exact: true }), - ).toBeNull(); + expect(screen.queryByRole("button", { name: "Search" })).toBeNull(); }); it("keeps wide search beside its controls when focused", () => { From 78586a56338b30afd78c62f6716b8dec36a484f6 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:24:20 +0000 Subject: [PATCH 68/79] Narrow the collection toolbar assertion --- apps/app/src/components/plugin/PluginsOverview.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 95bbcd82319..f2c2c55342a 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -506,12 +506,13 @@ describe("PluginsOverview", () => { ).toBeNull(); const search = screen.getByRole("textbox", { name: "Search plugins" }); const toolbar = search.closest("[data-resource-toolbar]"); + if (!toolbar) throw new Error("Missing collection toolbar"); const category = screen.getByRole("button", { name: "Filter plugins by category: All categories", }); const sort = screen.getByRole("button", { name: /^Sort:/ }); - expect(toolbar?.contains(category)).toBe(true); - expect(toolbar?.contains(sort)).toBe(true); + expect(toolbar.contains(category)).toBe(true); + expect(toolbar.contains(sort)).toBe(true); const heroHeading = screen.getByRole("heading", { level: 2, name: /^Turn bb into/, From 51920b96c9225a563b4972a9e176f357966d12d2 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:09:12 +0000 Subject: [PATCH 69/79] Flatten filtered plugin results and align cards and attribution --- .../management/BrowsePluginsTab.test.tsx | 16 +++++++- .../plugin/management/BrowsePluginsTab.tsx | 5 ++- .../plugin/management/PluginCard.tsx | 9 +++-- .../management/PluginMarketplaceListing.tsx | 11 +++++- .../app/src/components/tools/PluginDetail.tsx | 17 ++------- .../PluginsAndSkillsDetailStates.stories.tsx | 11 ------ .../views/ToolsView.plugin-detail.test.tsx | 37 ++++++------------- 7 files changed, 48 insertions(+), 58 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index 4c8f4147b4d..03f9f4d0d64 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, useLocation } from "react-router-dom"; import type { @@ -218,12 +224,17 @@ describe("BrowsePluginsTab", () => { name: "Search plugins", }); expect((search as HTMLInputElement).value).toBe("Mem"); + expect(screen.queryByTestId("plugin-browse-shelves")).toBeNull(); fireEvent.change(search, { target: { value: "Memory" } }); const params = new URLSearchParams( screen.getByTestId("location-search").textContent ?? "", ); expect(params.get("query")).toBe("Memory"); + fireEvent.change(search, { target: { value: "" } }); + await waitFor(() => + expect(screen.getByTestId("plugin-browse-shelves")).toBeTruthy(), + ); }); it("routes the card author name and preserves the Browse filters", async () => { @@ -254,6 +265,7 @@ describe("BrowsePluginsTab", () => { const trigger = await screen.findByRole("button", { name: "Filter plugins by category: Memory & Context, Security", }); + expect(screen.queryByTestId("plugin-browse-shelves")).toBeNull(); fireEvent.click(trigger); fireEvent.click( await screen.findByRole("option", { name: /Tasks & Workflows/u }), @@ -275,6 +287,8 @@ describe("BrowsePluginsTab", () => { "memory-and-context", "tasks-and-workflows", ]); + fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + expect(screen.getByTestId("plugin-browse-shelves")).toBeTruthy(); }); it("orders category options by shelf order and omits missing categories", async () => { diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 3b871341068..ee8cbe27e54 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -288,7 +288,10 @@ export function BrowsePluginsTab({ state="empty" message="No plugins match these category filters." /> - ) : sort === null && shelfKey === null ? ( + ) : sort === null && + shelfKey === null && + selectedCategories.length === 0 && + query.trim().length === 0 ? (
{shelves.map((shelf) => ( + {children} ); @@ -37,8 +35,11 @@ export function PluginCard({ badge, ...props }: PluginCardProps) { return ( {props.description} + } title={ {props.title} } diff --git a/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx b/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx index 91d56a19410..35333661162 100644 --- a/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx +++ b/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx @@ -79,10 +79,10 @@ function PluginMarketplaceDetails({ ); } -function PluginMarketplaceSource({ +export function PluginMarketplaceSource({ entry, }: { - entry: PluginCatalogSearchEntry; + entry: Pick; }) { if (entry.repositoryUrl === null) return null; return ( @@ -93,6 +93,13 @@ function PluginMarketplaceSource({ rel="noreferrer" className="inline-flex max-w-full items-center gap-1.5 rounded-sm text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > + {entry.repositoryUrl.startsWith("https://github.com/") ? ( + + ) : null} {formatUrlLabel(entry.repositoryUrl)} Opens in a new tab diff --git a/apps/app/src/components/tools/PluginDetail.tsx b/apps/app/src/components/tools/PluginDetail.tsx index 9310bc9a43d..8755b2ffe05 100644 --- a/apps/app/src/components/tools/PluginDetail.tsx +++ b/apps/app/src/components/tools/PluginDetail.tsx @@ -52,7 +52,6 @@ import { PluginDetailTable, } from "@/components/tools/plugin-detail-table"; import { PluginBannerBar } from "@/components/tools/plugin-detail-banner"; -import { ProvenancePill } from "@/components/tools/ProvenancePill"; import { usePluginSource, type PluginCatalogSearchEntry, @@ -66,13 +65,6 @@ import { import { usePluginSlots } from "@/lib/plugin-slots"; import { useClipboardCopy } from "@/lib/clipboard"; -export function PluginProvenancePill({ plugin }: { plugin: PluginListItem }) { - const label = plugin.publisherLabel; - return label === null || label === "BB Official" ? null : ( - - ); -} - export function pluginIsLocalSource(plugin: PluginListItem): boolean { return plugin.source.startsWith("path:"); } @@ -330,12 +322,9 @@ export function PluginDetail({ leading={} title={pluginName} titleMeta={ - - - {catalogEntry === undefined ? null : ( - - )} - + catalogEntry === undefined ? null : ( + + ) } metadata={
diff --git a/apps/app/src/components/tools/PluginsAndSkillsDetailStates.stories.tsx b/apps/app/src/components/tools/PluginsAndSkillsDetailStates.stories.tsx index 2d0c26fe264..16ab522fa71 100644 --- a/apps/app/src/components/tools/PluginsAndSkillsDetailStates.stories.tsx +++ b/apps/app/src/components/tools/PluginsAndSkillsDetailStates.stories.tsx @@ -30,7 +30,6 @@ import { CatalogPluginDetailBanner, PluginDetail, PluginDetailBanners, - PluginProvenancePill, } from "@/components/tools/PluginDetail"; import { ProviderLogo, @@ -1165,16 +1164,6 @@ export function ResourceControlStates() { title="Owned detail-page badges" description="Badges appear only when provenance changes how the resource should be understood. Ordinary owned resources stay unlabelled in their detail-page stories." > - } - meaning="Published by bb and installed from the catalog." - /> - } - meaning="Ships with bb. The same badge communicates publisher; lifecycle differences remain in metadata and actions." - /> { ).toBe(true); }); - it("omits a provenance badge for default direct and local sources", () => { - const directPlugin: PluginListItem = { - ...GITHUB_PLUGIN, - source: "npm:@example/github@^1.0.0", - provenance: "direct", - catalogEntryId: null, - publisherLabel: null, - }; - const { container, rerender } = render( - , - ); - expect(container.textContent).toBe(""); - - rerender( - , - ); - expect(container.textContent).toBe(""); - }); - it("keeps catalog provenance and release management in the unified detail taxonomy", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText } }); @@ -852,6 +827,18 @@ describe("BB Official plugin detail routing", () => { "Description from the installed catalog.", ); }); + const title = screen.getByRole("heading", { name: "GitHub", level: 1 }); + const header = title.parentElement?.parentElement; + if (!header) throw new Error("Plugin header missing"); + expect(within(header).queryByText("Partner Catalog")).toBeNull(); + const source = screen.getByRole("link", { + name: /github.com\/example\/installed-catalog-plugin/u, + }); + expect(source.querySelector('[data-icon="GithubLogo"]')).not.toBeNull(); + expect( + within(header).getByRole("link", { name: "Installed publisher" }), + ).toBeTruthy(); + expect(screen.getByText("Partner Catalog")).toBeTruthy(); }); it("uses installed metadata when its catalog entry is unavailable", async () => { From 25162b370dd8b6849386965a25921a933d2ae764 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:13:21 +0000 Subject: [PATCH 70/79] Preserve icon import aliases from main --- packages/plugin-registry/r/icon-extended.json | 2 +- packages/shared-ui/src/components/ui/icon-extended.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/plugin-registry/r/icon-extended.json b/packages/plugin-registry/r/icon-extended.json index 87d02938bfe..7e0f393bb8e 100644 --- a/packages/plugin-registry/r/icon-extended.json +++ b/packages/plugin-registry/r/icon-extended.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/components/ui/icon-extended.tsx", - "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport AiBrain01Icon from \"@hugeicons/core-free-icons/AiBrain01Icon\";\nimport AiBrowserIcon from \"@hugeicons/core-free-icons/AiBrowserIcon\";\nimport AiContentGenerator01Icon from \"@hugeicons/core-free-icons/AiContentGenerator01Icon\";\nimport ArrowDown02Icon from \"@hugeicons/core-free-icons/ArrowDown02Icon\";\nimport ArrowDownDoubleIcon from \"@hugeicons/core-free-icons/ArrowDownDoubleIcon\";\nimport ArrowLeft02Icon from \"@hugeicons/core-free-icons/ArrowLeft02Icon\";\nimport ArrowMoveDownLeftIcon from \"@hugeicons/core-free-icons/ArrowMoveDownLeftIcon\";\nimport ArrowMoveDownRightIcon from \"@hugeicons/core-free-icons/ArrowMoveDownRightIcon\";\nimport ArrowReloadHorizontalIcon from \"@hugeicons/core-free-icons/ArrowReloadHorizontalIcon\";\nimport ArrowRight02Icon from \"@hugeicons/core-free-icons/ArrowRight02Icon\";\nimport ArrowTurnBackwardIcon from \"@hugeicons/core-free-icons/ArrowTurnBackwardIcon\";\nimport ArrowTurnForwardIcon from \"@hugeicons/core-free-icons/ArrowTurnForwardIcon\";\nimport ArrowUp01Icon from \"@hugeicons/core-free-icons/ArrowUp01Icon\";\nimport ArrowUp02Icon from \"@hugeicons/core-free-icons/ArrowUp02Icon\";\nimport ArrowUpDoubleIcon from \"@hugeicons/core-free-icons/ArrowUpDoubleIcon\";\nimport ArrowUpDownIcon from \"@hugeicons/core-free-icons/ArrowUpDownIcon\";\nimport ArrowUpRight01Icon from \"@hugeicons/core-free-icons/ArrowUpRight01Icon\";\nimport AttachmentIcon from \"@hugeicons/core-free-icons/AttachmentIcon\";\nimport BellDotIcon from \"@hugeicons/core-free-icons/BellDotIcon\";\nimport Book02Icon from \"@hugeicons/core-free-icons/Book02Icon\";\nimport BrainIcon from \"@hugeicons/core-free-icons/BrainIcon\";\nimport BrowserIcon from \"@hugeicons/core-free-icons/BrowserIcon\";\nimport Calendar03Icon from \"@hugeicons/core-free-icons/Calendar03Icon\";\nimport CalendarCheckOut02Icon from \"@hugeicons/core-free-icons/CalendarCheckOut02Icon\";\nimport ChartColumnIcon from \"@hugeicons/core-free-icons/ChartColumnIcon\";\nimport CircleArrowShrink01Icon from \"@hugeicons/core-free-icons/CircleArrowShrink01Icon\";\nimport CleanIcon from \"@hugeicons/core-free-icons/CleanIcon\";\nimport Clock01Icon from \"@hugeicons/core-free-icons/Clock01Icon\";\nimport ClockArrowDownIcon from \"@hugeicons/core-free-icons/ClockArrowDownIcon\";\nimport ClockArrowUpIcon from \"@hugeicons/core-free-icons/ClockArrowUpIcon\";\nimport CloudIcon from \"@hugeicons/core-free-icons/CloudIcon\";\nimport CloudOffIcon from \"@hugeicons/core-free-icons/CloudOffIcon\";\nimport Coffee02Icon from \"@hugeicons/core-free-icons/Coffee02Icon\";\nimport CollapseIcon from \"@hugeicons/core-free-icons/CollapseIcon\";\nimport DashedLine02Icon from \"@hugeicons/core-free-icons/DashedLine02Icon\";\nimport DateTimeIcon from \"@hugeicons/core-free-icons/DateTimeIcon\";\nimport DiscordIcon from \"@hugeicons/core-free-icons/DiscordIcon\";\nimport DragDropHorizontalIcon from \"@hugeicons/core-free-icons/DragDropHorizontalIcon\";\nimport DragDropVerticalIcon from \"@hugeicons/core-free-icons/DragDropVerticalIcon\";\nimport Edit04Icon from \"@hugeicons/core-free-icons/Edit04Icon\";\nimport ElectricPlugsIcon from \"@hugeicons/core-free-icons/ElectricPlugsIcon\";\nimport ExpandIcon from \"@hugeicons/core-free-icons/ExpandIcon\";\nimport File01Icon from \"@hugeicons/core-free-icons/File01Icon\";\nimport FileAttachmentIcon from \"@hugeicons/core-free-icons/FileAttachmentIcon\";\nimport FileEmpty02Icon from \"@hugeicons/core-free-icons/FileEmpty02Icon\";\nimport FileQuestionMarkIcon from \"@hugeicons/core-free-icons/FileQuestionMarkIcon\";\nimport Folder02Icon from \"@hugeicons/core-free-icons/Folder02Icon\";\nimport FolderEditIcon from \"@hugeicons/core-free-icons/FolderEditIcon\";\nimport FolderRemoveIcon from \"@hugeicons/core-free-icons/FolderRemoveIcon\";\nimport GitBranchIcon from \"@hugeicons/core-free-icons/GitBranchIcon\";\nimport GitForkIcon from \"@hugeicons/core-free-icons/GitForkIcon\";\nimport GitMergeIcon from \"@hugeicons/core-free-icons/GitMergeIcon\";\nimport GitPullRequestArrow from \"@hugeicons/core-free-icons/GitPullRequestArrow\";\nimport GitPullRequestClosedIcon from \"@hugeicons/core-free-icons/GitPullRequestClosedIcon\";\nimport GitPullRequestDraftIcon from \"@hugeicons/core-free-icons/GitPullRequestDraftIcon\";\nimport GitPullRequestIcon from \"@hugeicons/core-free-icons/GitPullRequestIcon\";\nimport GithubIcon from \"@hugeicons/core-free-icons/GithubIcon\";\nimport GridViewIcon from \"@hugeicons/core-free-icons/GridViewIcon\";\nimport InternetIcon from \"@hugeicons/core-free-icons/InternetIcon\";\nimport LaptopIcon from \"@hugeicons/core-free-icons/LaptopIcon\";\nimport Layers01Icon from \"@hugeicons/core-free-icons/Layers01Icon\";\nimport LayoutTwoColumnIcon from \"@hugeicons/core-free-icons/LayoutTwoColumnIcon\";\nimport LayoutTwoRowIcon from \"@hugeicons/core-free-icons/LayoutTwoRowIcon\";\nimport LimitationIcon from \"@hugeicons/core-free-icons/LimitationIcon\";\nimport LinkSquare02Icon from \"@hugeicons/core-free-icons/LinkSquare02Icon\";\nimport ListViewIcon from \"@hugeicons/core-free-icons/ListViewIcon\";\nimport LockIcon from \"@hugeicons/core-free-icons/LockIcon\";\nimport Mail02Icon from \"@hugeicons/core-free-icons/Mail02Icon\";\nimport MailOpen01Icon from \"@hugeicons/core-free-icons/MailOpen01Icon\";\nimport Menu02Icon from \"@hugeicons/core-free-icons/Menu02Icon\";\nimport MessageAdd02Icon from \"@hugeicons/core-free-icons/MessageAdd02Icon\";\nimport Mic02Icon from \"@hugeicons/core-free-icons/Mic02Icon\";\nimport MinusSignIcon from \"@hugeicons/core-free-icons/MinusSignIcon\";\nimport MoveToIcon from \"@hugeicons/core-free-icons/MoveToIcon\";\nimport News01Icon from \"@hugeicons/core-free-icons/News01Icon\";\nimport PackageReceiveIcon from \"@hugeicons/core-free-icons/PackageReceiveIcon\";\nimport PauseIcon from \"@hugeicons/core-free-icons/PauseIcon\";\nimport PinIcon from \"@hugeicons/core-free-icons/PinIcon\";\nimport PinOffIcon from \"@hugeicons/core-free-icons/PinOffIcon\";\nimport PlayIcon from \"@hugeicons/core-free-icons/PlayIcon\";\nimport Plug02Icon from \"@hugeicons/core-free-icons/Plug02Icon\";\nimport PlusMinusSquare01Icon from \"@hugeicons/core-free-icons/PlusMinusSquare01Icon\";\nimport PlusSignIcon from \"@hugeicons/core-free-icons/PlusSignIcon\";\nimport PuzzleIcon from \"@hugeicons/core-free-icons/PuzzleIcon\";\nimport Refresh01Icon from \"@hugeicons/core-free-icons/Refresh01Icon\";\nimport RepeatIcon from \"@hugeicons/core-free-icons/RepeatIcon\";\nimport SecurityCheckIcon from \"@hugeicons/core-free-icons/SecurityCheckIcon\";\nimport SentIcon from \"@hugeicons/core-free-icons/SentIcon\";\nimport SidebarBottomIcon from \"@hugeicons/core-free-icons/SidebarBottomIcon\";\nimport SidebarRightIcon from \"@hugeicons/core-free-icons/SidebarRightIcon\";\nimport SmartPhone01Icon from \"@hugeicons/core-free-icons/SmartPhone01Icon\";\nimport Sorting01Icon from \"@hugeicons/core-free-icons/Sorting01Icon\";\nimport SortingAZ02Icon from \"@hugeicons/core-free-icons/SortingAZ02Icon\";\nimport SortingNineOneIcon from \"@hugeicons/core-free-icons/SortingNineOneIcon\";\nimport SortingOneNineIcon from \"@hugeicons/core-free-icons/SortingOneNineIcon\";\nimport SortingZA01Icon from \"@hugeicons/core-free-icons/SortingZA01Icon\";\nimport SquareIcon from \"@hugeicons/core-free-icons/SquareIcon\";\nimport SquareUnlock02Icon from \"@hugeicons/core-free-icons/SquareUnlock02Icon\";\nimport StarIcon from \"@hugeicons/core-free-icons/StarIcon\";\nimport TestTube01Icon from \"@hugeicons/core-free-icons/TestTube01Icon\";\nimport TextWrapIcon from \"@hugeicons/core-free-icons/TextWrapIcon\";\nimport TimeScheduleIcon from \"@hugeicons/core-free-icons/TimeScheduleIcon\";\nimport Unarchive03Icon from \"@hugeicons/core-free-icons/Unarchive03Icon\";\nimport UserIcon from \"@hugeicons/core-free-icons/UserIcon\";\nimport ViewIcon from \"@hugeicons/core-free-icons/ViewIcon\";\nimport ViewOffIcon from \"@hugeicons/core-free-icons/ViewOffIcon\";\nimport ZoomInAreaIcon from \"@hugeicons/core-free-icons/ZoomInAreaIcon\";\nimport ZoomOutAreaIcon from \"@hugeicons/core-free-icons/ZoomOutAreaIcon\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nconst DiscordLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nconst GithubLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrain01: AiBrain01Icon,\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowLeft: ArrowLeft02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n BellDot: BellDotIcon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n ClockArrowUp: ClockArrowUpIcon,\n ClockArrowDown: ClockArrowDownIcon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DiscordLogo: DiscordLogoIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n GithubLogo: GithubLogoIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n Limitation: LimitationIcon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minus: MinusSignIcon,\n Minimize2: CollapseIcon,\n MoveTo: MoveToIcon,\n NewTab: DashedLine02Icon,\n News01: News01Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plug02: Plug02Icon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n SortingAZ02: SortingAZ02Icon,\n SortingZA01: SortingZA01Icon,\n SortingOneNine: SortingOneNineIcon,\n SortingNineOne: SortingNineOneIcon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", + "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport AiBrain01Icon from \"@hugeicons/core-free-icons/AiBrain01Icon\";\nimport AiBrowserIcon from \"@hugeicons/core-free-icons/AiBrowserIcon\";\nimport AiContentGenerator01Icon from \"@hugeicons/core-free-icons/AiContentGenerator01Icon\";\nimport ArrowDown02Icon from \"@hugeicons/core-free-icons/ArrowDown02Icon\";\nimport ArrowDownDoubleIcon from \"@hugeicons/core-free-icons/ArrowDownDoubleIcon\";\nimport ArrowLeft02Icon from \"@hugeicons/core-free-icons/ArrowLeft02Icon\";\nimport ArrowMoveDownLeftIcon from \"@hugeicons/core-free-icons/ArrowMoveDownLeftIcon\";\nimport ArrowMoveDownRightIcon from \"@hugeicons/core-free-icons/ArrowMoveDownRightIcon\";\nimport ArrowReloadHorizontalIcon from \"@hugeicons/core-free-icons/ArrowReloadHorizontalIcon\";\nimport ArrowRight02Icon from \"@hugeicons/core-free-icons/ArrowRight02Icon\";\nimport ArrowTurnBackwardIcon from \"@hugeicons/core-free-icons/ArrowTurnBackwardIcon\";\nimport ArrowTurnForwardIcon from \"@hugeicons/core-free-icons/ArrowTurnForwardIcon\";\nimport ArrowUp01Icon from \"@hugeicons/core-free-icons/ArrowUp01Icon\";\nimport ArrowUp02Icon from \"@hugeicons/core-free-icons/ArrowUp02Icon\";\nimport ArrowUpDoubleIcon from \"@hugeicons/core-free-icons/ArrowUpDoubleIcon\";\nimport ArrowUpDownIcon from \"@hugeicons/core-free-icons/ArrowUpDownIcon\";\nimport ArrowUpRight01Icon from \"@hugeicons/core-free-icons/ArrowUpRight01Icon\";\nimport AttachmentIcon from \"@hugeicons/core-free-icons/AttachmentIcon\";\nimport BellDotIcon from \"@hugeicons/core-free-icons/BellDotIcon\";\nimport Book02Icon from \"@hugeicons/core-free-icons/Book02Icon\";\nimport BrainIcon from \"@hugeicons/core-free-icons/BrainIcon\";\nimport BrowserIcon from \"@hugeicons/core-free-icons/BrowserIcon\";\nimport Calendar03Icon from \"@hugeicons/core-free-icons/Calendar03Icon\";\nimport CalendarCheckOut02Icon from \"@hugeicons/core-free-icons/CalendarCheckOut02Icon\";\nimport ChartColumnIcon from \"@hugeicons/core-free-icons/ChartColumnIcon\";\nimport CircleArrowShrink01Icon from \"@hugeicons/core-free-icons/CircleArrowShrink01Icon\";\nimport CleanIcon from \"@hugeicons/core-free-icons/CleanIcon\";\nimport Clock01Icon from \"@hugeicons/core-free-icons/Clock01Icon\";\nimport ClockArrowDownIcon from \"@hugeicons/core-free-icons/ClockArrowDownIcon\";\nimport ClockArrowUpIcon from \"@hugeicons/core-free-icons/ClockArrowUpIcon\";\nimport CloudIcon from \"@hugeicons/core-free-icons/CloudIcon\";\nimport CloudOffIcon from \"@hugeicons/core-free-icons/CloudOffIcon\";\nimport Coffee02Icon from \"@hugeicons/core-free-icons/Coffee02Icon\";\nimport CollapseIcon from \"@hugeicons/core-free-icons/CollapseIcon\";\nimport DashedLine02Icon from \"@hugeicons/core-free-icons/DashedLine02Icon\";\nimport DateTimeIcon from \"@hugeicons/core-free-icons/DateTimeIcon\";\nimport DiscordIcon from \"@hugeicons/core-free-icons/DiscordIcon\";\nimport DragDropHorizontalIcon from \"@hugeicons/core-free-icons/DragDropHorizontalIcon\";\nimport DragDropVerticalIcon from \"@hugeicons/core-free-icons/DragDropVerticalIcon\";\nimport Edit04Icon from \"@hugeicons/core-free-icons/Edit04Icon\";\nimport ElectricPlugsIcon from \"@hugeicons/core-free-icons/ElectricPlugsIcon\";\nimport ExpandIcon from \"@hugeicons/core-free-icons/ExpandIcon\";\nimport File01Icon from \"@hugeicons/core-free-icons/File01Icon\";\nimport FileAttachmentIcon from \"@hugeicons/core-free-icons/FileAttachmentIcon\";\nimport FileEmpty02Icon from \"@hugeicons/core-free-icons/FileEmpty02Icon\";\nimport FileQuestionMarkIcon from \"@hugeicons/core-free-icons/FileQuestionMarkIcon\";\nimport Folder02Icon from \"@hugeicons/core-free-icons/Folder02Icon\";\nimport FolderEditIcon from \"@hugeicons/core-free-icons/FolderEditIcon\";\nimport FolderRemoveIcon from \"@hugeicons/core-free-icons/FolderRemoveIcon\";\nimport GitBranchIcon from \"@hugeicons/core-free-icons/GitBranchIcon\";\nimport GitForkIcon from \"@hugeicons/core-free-icons/GitForkIcon\";\nimport GitMergeIcon from \"@hugeicons/core-free-icons/GitMergeIcon\";\nimport GitPullRequestArrow from \"@hugeicons/core-free-icons/GitPullRequestIcon\";\nimport GitPullRequestClosedIcon from \"@hugeicons/core-free-icons/GitPullRequestClosedIcon\";\nimport GitPullRequestDraftIcon from \"@hugeicons/core-free-icons/GitPullRequestDraftIcon\";\nimport GitPullRequestIcon from \"@hugeicons/core-free-icons/GitPullRequestIcon\";\nimport GithubIcon from \"@hugeicons/core-free-icons/GithubIcon\";\nimport GridViewIcon from \"@hugeicons/core-free-icons/GridViewIcon\";\nimport InternetIcon from \"@hugeicons/core-free-icons/InternetIcon\";\nimport LaptopIcon from \"@hugeicons/core-free-icons/LaptopIcon\";\nimport Layers01Icon from \"@hugeicons/core-free-icons/Layers01Icon\";\nimport LayoutTwoColumnIcon from \"@hugeicons/core-free-icons/Layout2ColumnIcon\";\nimport LayoutTwoRowIcon from \"@hugeicons/core-free-icons/Layout2RowIcon\";\nimport LimitationIcon from \"@hugeicons/core-free-icons/LimitationIcon\";\nimport LinkSquare02Icon from \"@hugeicons/core-free-icons/LinkSquare02Icon\";\nimport ListViewIcon from \"@hugeicons/core-free-icons/ListViewIcon\";\nimport LockIcon from \"@hugeicons/core-free-icons/LockIcon\";\nimport Mail02Icon from \"@hugeicons/core-free-icons/Mail02Icon\";\nimport MailOpen01Icon from \"@hugeicons/core-free-icons/MailOpen01Icon\";\nimport Menu02Icon from \"@hugeicons/core-free-icons/Menu02Icon\";\nimport MessageAdd02Icon from \"@hugeicons/core-free-icons/MessageAdd02Icon\";\nimport Mic02Icon from \"@hugeicons/core-free-icons/Mic02Icon\";\nimport MinusSignIcon from \"@hugeicons/core-free-icons/MinusSignIcon\";\nimport MoveToIcon from \"@hugeicons/core-free-icons/MoveToIcon\";\nimport News01Icon from \"@hugeicons/core-free-icons/News01Icon\";\nimport PackageReceiveIcon from \"@hugeicons/core-free-icons/PackageReceiveIcon\";\nimport PauseIcon from \"@hugeicons/core-free-icons/PauseIcon\";\nimport PinIcon from \"@hugeicons/core-free-icons/PinIcon\";\nimport PinOffIcon from \"@hugeicons/core-free-icons/PinOffIcon\";\nimport PlayIcon from \"@hugeicons/core-free-icons/PlayIcon\";\nimport Plug02Icon from \"@hugeicons/core-free-icons/Plug02Icon\";\nimport PlusMinusSquare01Icon from \"@hugeicons/core-free-icons/PlusMinusSquare01Icon\";\nimport PlusSignIcon from \"@hugeicons/core-free-icons/PlusSignIcon\";\nimport PuzzleIcon from \"@hugeicons/core-free-icons/PuzzleIcon\";\nimport Refresh01Icon from \"@hugeicons/core-free-icons/Refresh01Icon\";\nimport RepeatIcon from \"@hugeicons/core-free-icons/RepeatIcon\";\nimport SecurityCheckIcon from \"@hugeicons/core-free-icons/SecurityCheckIcon\";\nimport SentIcon from \"@hugeicons/core-free-icons/SentIcon\";\nimport SidebarBottomIcon from \"@hugeicons/core-free-icons/SidebarBottomIcon\";\nimport SidebarRightIcon from \"@hugeicons/core-free-icons/SidebarRightIcon\";\nimport SmartPhone01Icon from \"@hugeicons/core-free-icons/SmartPhone01Icon\";\nimport Sorting01Icon from \"@hugeicons/core-free-icons/Sorting01Icon\";\nimport SortingAZ02Icon from \"@hugeicons/core-free-icons/SortingAZ02Icon\";\nimport SortingNineOneIcon from \"@hugeicons/core-free-icons/SortingNineOneIcon\";\nimport SortingOneNineIcon from \"@hugeicons/core-free-icons/SortingOneNineIcon\";\nimport SortingZA01Icon from \"@hugeicons/core-free-icons/SortingZA01Icon\";\nimport SquareIcon from \"@hugeicons/core-free-icons/SquareIcon\";\nimport SquareUnlock02Icon from \"@hugeicons/core-free-icons/SquareUnlock02Icon\";\nimport StarIcon from \"@hugeicons/core-free-icons/StarIcon\";\nimport TestTube01Icon from \"@hugeicons/core-free-icons/TestTube01Icon\";\nimport TextWrapIcon from \"@hugeicons/core-free-icons/TextWrapIcon\";\nimport TimeScheduleIcon from \"@hugeicons/core-free-icons/TimeScheduleIcon\";\nimport Unarchive03Icon from \"@hugeicons/core-free-icons/Unarchive03Icon\";\nimport UserIcon from \"@hugeicons/core-free-icons/UserIcon\";\nimport ViewIcon from \"@hugeicons/core-free-icons/ViewIcon\";\nimport ViewOffIcon from \"@hugeicons/core-free-icons/ViewOffIcon\";\nimport ZoomInAreaIcon from \"@hugeicons/core-free-icons/ZoomInAreaIcon\";\nimport ZoomOutAreaIcon from \"@hugeicons/core-free-icons/ZoomOutAreaIcon\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nconst DiscordLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nconst GithubLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrain01: AiBrain01Icon,\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowLeft: ArrowLeft02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n BellDot: BellDotIcon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n ClockArrowUp: ClockArrowUpIcon,\n ClockArrowDown: ClockArrowDownIcon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DiscordLogo: DiscordLogoIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n GithubLogo: GithubLogoIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n Limitation: LimitationIcon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minus: MinusSignIcon,\n Minimize2: CollapseIcon,\n MoveTo: MoveToIcon,\n NewTab: DashedLine02Icon,\n News01: News01Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plug02: Plug02Icon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n SortingAZ02: SortingAZ02Icon,\n SortingZA01: SortingZA01Icon,\n SortingOneNine: SortingOneNineIcon,\n SortingNineOne: SortingNineOneIcon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", "type": "registry:ui", "target": "components/ui/icon-extended.tsx" } diff --git a/packages/shared-ui/src/components/ui/icon-extended.tsx b/packages/shared-ui/src/components/ui/icon-extended.tsx index f3dd94d6498..10a3ba3c622 100644 --- a/packages/shared-ui/src/components/ui/icon-extended.tsx +++ b/packages/shared-ui/src/components/ui/icon-extended.tsx @@ -51,7 +51,7 @@ import FolderRemoveIcon from "@hugeicons/core-free-icons/FolderRemoveIcon"; import GitBranchIcon from "@hugeicons/core-free-icons/GitBranchIcon"; import GitForkIcon from "@hugeicons/core-free-icons/GitForkIcon"; import GitMergeIcon from "@hugeicons/core-free-icons/GitMergeIcon"; -import GitPullRequestArrow from "@hugeicons/core-free-icons/GitPullRequestArrow"; +import GitPullRequestArrow from "@hugeicons/core-free-icons/GitPullRequestIcon"; import GitPullRequestClosedIcon from "@hugeicons/core-free-icons/GitPullRequestClosedIcon"; import GitPullRequestDraftIcon from "@hugeicons/core-free-icons/GitPullRequestDraftIcon"; import GitPullRequestIcon from "@hugeicons/core-free-icons/GitPullRequestIcon"; @@ -60,8 +60,8 @@ import GridViewIcon from "@hugeicons/core-free-icons/GridViewIcon"; import InternetIcon from "@hugeicons/core-free-icons/InternetIcon"; import LaptopIcon from "@hugeicons/core-free-icons/LaptopIcon"; import Layers01Icon from "@hugeicons/core-free-icons/Layers01Icon"; -import LayoutTwoColumnIcon from "@hugeicons/core-free-icons/LayoutTwoColumnIcon"; -import LayoutTwoRowIcon from "@hugeicons/core-free-icons/LayoutTwoRowIcon"; +import LayoutTwoColumnIcon from "@hugeicons/core-free-icons/Layout2ColumnIcon"; +import LayoutTwoRowIcon from "@hugeicons/core-free-icons/Layout2RowIcon"; import LimitationIcon from "@hugeicons/core-free-icons/LimitationIcon"; import LinkSquare02Icon from "@hugeicons/core-free-icons/LinkSquare02Icon"; import ListViewIcon from "@hugeicons/core-free-icons/ListViewIcon"; From 3e14e789ff4d9d12fccc15c0732e60ebd9923931 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:13:03 +0000 Subject: [PATCH 71/79] Simplify live plugin search and align toolbar heights --- .../src/components/plugin/PluginsOverview.tsx | 1 - .../management/PluginBrowseControls.test.tsx | 31 ++++++++++---- .../management/PluginBrowseControls.tsx | 4 +- .../src/components/ui/resource/toolbar.tsx | 41 +++++++++++++++---- 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 5fd24717fdc..dbe26793ebf 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -235,7 +235,6 @@ export function PluginsOverview({ bandClassName={TOOLS_PAGE_BAND_CLASSES} toolbar={ { expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); }); - it("keeps expanded search open while tabbing to its submit button and restores input focus after submission", () => { + it("keeps expanded search open while tabbing to close and preserves the live query when closing", () => { mockToolbarWidth(320); render(); const search = screen.getByRole("textbox", { name: "Search plugins" }); + expect(search.getAttribute("placeholder")).toBe("Search..."); act(() => search.focus()); fireEvent.change(search, { target: { value: "Notes" } }); - const submit = screen.getByRole("button", { name: "Search" }); - act(() => submit.focus()); - expect(document.activeElement).toBe(submit); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "query=Notes", + ); + expect(screen.queryByRole("button", { name: "Search" })).toBeNull(); + const close = screen.getByRole("button", { name: "Close search" }); + act(() => close.focus()); + expect(document.activeElement).toBe(close); expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); - fireEvent.click(submit); + fireEvent.click(close); expect(document.activeElement).toBe(search); expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); expect(screen.getByLabelText("Parameters").textContent).toBe( "query=Notes&category=security&source=user&sort=name&direction=desc", ); fireEvent.click(search); - expect(screen.getByRole("button", { name: "Search" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Close search" })).toBeTruthy(); fireEvent.keyDown(search, { key: "Escape" }); expect(document.activeElement).toBe(search); - expect(screen.queryByRole("button", { name: "Search" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Close search" })).toBeNull(); }); - it("keeps wide search beside its controls when focused", () => { + it("clears wide search without removing filters or moving focus away from the input", () => { mockToolbarWidth(800); render(); - act(() => screen.getByRole("textbox", { name: "Search plugins" }).focus()); + const search = screen.getByRole("textbox", { name: "Search plugins" }); + act(() => search.focus()); + fireEvent.change(search, { target: { value: "Notes" } }); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(document.activeElement).toBe(search); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "category=security&source=user&sort=name&direction=desc", + ); + expect(screen.queryByRole("button", { name: "Clear search" })).toBeNull(); expect(screen.getByRole("button", { name: /^Sort:/u })).toBeTruthy(); expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); }); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index b8d62361c8c..b3f1734b599 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -95,7 +95,6 @@ export function PluginCollectionToolbar({ searchPlaceholder = "Search plugins", action, sourceFilter, - className, }: { searchPlaceholder?: string; action?: ReactNode; @@ -104,7 +103,6 @@ export function PluginCollectionToolbar({ selectedValues: readonly string[]; onChange: (values: string[]) => void; }; - className?: string; query: string; selectedCategories: readonly string[]; categoryOptions: readonly PluginBrowseCategoryOption[]; @@ -208,7 +206,7 @@ export function PluginCollectionToolbar({ ] satisfies PluginControlPage[]; return ( -
+
onSearchChange(event.target.value)} placeholder={ - searchCondensed && !searchExpanded ? "Search" : searchPlaceholder + searchCondensed && !searchExpanded + ? "Search..." + : searchPlaceholder } aria-label={searchLabel ?? searchPlaceholder} enterKeyHint={expandSearchOnFocus ? "search" : undefined} @@ -202,14 +204,39 @@ export function ResourceToolbar({ setSearchExpanded(false); } }} - className="h-8 truncate pl-8 focus:border-ring/60 focus:text-clip focus:ring-2 focus:ring-ring/20 focus-visible:ring-2 focus-visible:ring-ring/20" + className={cn( + "h-8 truncate pl-8 focus:border-ring/60 focus:text-clip focus:ring-2 focus:ring-ring/20 focus-visible:ring-2 focus-visible:ring-ring/20 max-md:pointer-coarse:h-8", + (searchExpanded || (!searchCondensed && searchValue)) && "pr-8", + )} /> + {searchExpanded || (!searchCondensed && searchValue) ? ( + + + + + + + {searchExpanded ? "Close search" : "Clear search"} + + + + ) : null}
- {searchExpanded ? ( - - ) : null} {controls ? (
Date: Sat, 19 Sep 2026 23:25:47 +0000 Subject: [PATCH 72/79] Keep labeled plugin controls within the narrowest toolbar --- .../src/components/ui/resource/toolbar.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 6ab3bd6648b..f5836c691d4 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -385,7 +385,9 @@ export const ResourceControlButton = forwardRef< size={text ? "sm" : "icon"} className={cn( "h-8 shrink-0 rounded-md text-muted-foreground", - text ? "gap-2 px-2 text-xs" : "size-8 p-0", + text + ? "gap-2 px-2 text-xs @max-[19rem]/resource-toolbar:px-1 @max-[19rem]/resource-toolbar:text-2xs" + : "size-8 p-0", RESOURCE_MENU_TRIGGER_RESTING_CLASS, (open || active) && RESOURCE_MENU_TRIGGER_ENGAGED_CLASS, className, @@ -894,7 +896,11 @@ export function ResourceCreateButton({ aria-label={label} type="button" size="sm" - className={cn("rounded-r-none", compactWhenNarrow && "pl-2 pr-1")} + className={cn( + "rounded-r-none", + compactWhenNarrow && + "pl-2 pr-1 @max-[19rem]/resource-toolbar:px-1 @max-[19rem]/resource-toolbar:text-2xs", + )} onClick={() => onCreate()} > From 45436e86b98a5264b59172bcd72d3ef46972f0c8 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:42:25 +0000 Subject: [PATCH 73/79] Clear the query when dismissing compact plugin search --- .../management/PluginBrowseControls.test.tsx | 16 ++++++++-------- .../src/components/ui/resource/toolbar.tsx | 10 +++------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 83f04f3703f..9faa62b7ae1 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -458,7 +458,7 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); }); - it("keeps expanded search open while tabbing to close and preserves the live query when closing", () => { + it("clears expanded search and restores controls without removing filters", () => { mockToolbarWidth(320); render(); const search = screen.getByRole("textbox", { name: "Search plugins" }); @@ -469,21 +469,21 @@ describe("PluginCollectionToolbar", () => { "query=Notes", ); expect(screen.queryByRole("button", { name: "Search" })).toBeNull(); - const close = screen.getByRole("button", { name: "Close search" }); - act(() => close.focus()); - expect(document.activeElement).toBe(close); + const clear = screen.getByRole("button", { name: "Clear search" }); + act(() => clear.focus()); + expect(document.activeElement).toBe(clear); expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); - fireEvent.click(close); + fireEvent.click(clear); expect(document.activeElement).toBe(search); expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); expect(screen.getByLabelText("Parameters").textContent).toBe( - "query=Notes&category=security&source=user&sort=name&direction=desc", + "category=security&source=user&sort=name&direction=desc", ); fireEvent.click(search); - expect(screen.getByRole("button", { name: "Close search" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Clear search" })).toBeTruthy(); fireEvent.keyDown(search, { key: "Escape" }); expect(document.activeElement).toBe(search); - expect(screen.queryByRole("button", { name: "Close search" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Clear search" })).toBeNull(); }); it("clears wide search without removing filters or moving focus away from the input", () => { diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index f5836c691d4..592d41005f3 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -217,12 +217,10 @@ export function ResourceToolbar({ type="button" variant="ghost" size="icon" - aria-label={ - searchExpanded ? "Close search" : "Clear search" - } + aria-label="Clear search" className="absolute inset-y-0 right-0 size-8 text-muted-foreground hover:text-foreground" onClick={() => { - if (!searchExpanded) onSearchChange(""); + onSearchChange(""); searchRef.current?.querySelector("input")?.focus(); setSearchExpanded(false); }} @@ -230,9 +228,7 @@ export function ResourceToolbar({ - - {searchExpanded ? "Close search" : "Clear search"} - + Clear search ) : null} From 443dc61bb5a1e7ca4995364dbc38c08de7f5156e Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:53:58 +0000 Subject: [PATCH 74/79] Use compact search buttons and consistent plugin control typography --- .../management/PluginBrowseControls.test.tsx | 81 ++++--- .../management/PluginBrowseControls.tsx | 3 +- .../src/components/ui/resource/toolbar.tsx | 213 ++++++++++-------- 3 files changed, 179 insertions(+), 118 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 9faa62b7ae1..7d69d7d551e 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -428,16 +428,21 @@ describe("PluginCollectionToolbar", () => { ).toBeTruthy(); }); - it("expands cramped search and restores controls on submit or blur without losing the query", () => { + it("opens compact search with the query selected and restores controls on submit or blur", () => { mockToolbarWidth(320); render(); - const search = screen.getByRole("textbox", { name: "Search plugins" }); - act(() => search.focus()); + const trigger = screen.getByRole("button", { name: "Search plugins" }); expect( - search - .closest("[data-resource-toolbar]") - ?.getAttribute("data-search-expanded"), - ).toBe("true"); + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + fireEvent.click(trigger); + const search = screen.getByRole("textbox", { + name: "Search plugins", + }); + expect(document.activeElement).toBe(search); + expect(search.selectionStart).toBe(0); + expect(search.selectionEnd).toBe("Memory".length); + expect(search.placeholder).toBe("Search plugins..."); expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); fireEvent.change(search, { target: { value: "Notes" } }); @@ -445,25 +450,25 @@ describe("PluginCollectionToolbar", () => { expect(document.activeElement).toBe(search); fireEvent.keyDown(search, { key: "Enter" }); expect( - search - .closest("[data-resource-toolbar]") - ?.hasAttribute("data-search-expanded"), - ).toBe(false); + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Search plugins" }), + ); expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); expect(screen.getByLabelText("Parameters").textContent).toBe( "query=Notes&category=security&source=user&sort=name&direction=desc", ); - act(() => search.focus()); - act(() => search.blur()); + fireEvent.click(screen.getByRole("button", { name: "Search plugins" })); + act(() => screen.getByRole("textbox", { name: "Search plugins" }).blur()); expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); }); - it("clears expanded search and restores controls without removing filters", () => { + it("clears compact search, dismisses the input, and retains the other selections", () => { mockToolbarWidth(320); render(); + fireEvent.click(screen.getByRole("button", { name: "Search plugins" })); const search = screen.getByRole("textbox", { name: "Search plugins" }); - expect(search.getAttribute("placeholder")).toBe("Search..."); - act(() => search.focus()); fireEvent.change(search, { target: { value: "Notes" } }); expect(screen.getByLabelText("Parameters").textContent).toContain( "query=Notes", @@ -474,26 +479,37 @@ describe("PluginCollectionToolbar", () => { expect(document.activeElement).toBe(clear); expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); fireEvent.click(clear); - expect(document.activeElement).toBe(search); + expect( + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Search plugins" }), + ); expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); expect(screen.getByLabelText("Parameters").textContent).toBe( "category=security&source=user&sort=name&direction=desc", ); - fireEvent.click(search); - expect(screen.getByRole("button", { name: "Clear search" })).toBeTruthy(); - fireEvent.keyDown(search, { key: "Escape" }); - expect(document.activeElement).toBe(search); - expect(screen.queryByRole("button", { name: "Clear search" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Search plugins" })); + const reopened = screen.getByRole("textbox", { name: "Search plugins" }); + expect(reopened.getAttribute("value")).toBe(""); + fireEvent.keyDown(reopened, { key: "Escape" }); + expect( + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Search plugins" }), + ); }); - it("clears wide search without removing filters or moving focus away from the input", () => { + it("clears and blurs wide search without removing filters", () => { mockToolbarWidth(800); render(); const search = screen.getByRole("textbox", { name: "Search plugins" }); act(() => search.focus()); fireEvent.change(search, { target: { value: "Notes" } }); fireEvent.click(screen.getByRole("button", { name: "Clear search" })); - expect(document.activeElement).toBe(search); + expect(document.activeElement).not.toBe(search); + expect(search.getAttribute("value")).toBe(""); expect(screen.getByLabelText("Parameters").textContent).toBe( "category=security&source=user&sort=name&direction=desc", ); @@ -502,6 +518,17 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); }); + it("uses the Search button on mobile even when a lone Sort control leaves room", () => { + viewport.compact = true; + mockToolbarWidth(400); + render(); + expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); + expect( + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + expect(screen.getByRole("button", { name: /^Sort:/u })).toBeTruthy(); + }); + it.each([ { installed: true, @@ -641,14 +668,14 @@ describe("PluginCollectionToolbar", () => { it("moves focused controls into the combined menu and back without losing search or selection", () => { const resize = mockToolbarWidth(600); render(); + fireEvent.change(screen.getByRole("textbox", { name: "Search plugins" }), { + target: { value: "Notes" }, + }); screen.getByRole("button", { name: /^Source:/u }).focus(); resize(320); expect(document.activeElement).toBe( screen.getByRole("button", { name: "Filter & sort" }), ); - fireEvent.change(screen.getByRole("textbox", { name: "Search plugins" }), { - target: { value: "Notes" }, - }); resize(600); expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); expect(screen.getByRole("button", { name: /^Source: 1/u })).toBeTruthy(); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index b3f1734b599..442806a5d78 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -212,7 +212,8 @@ export function PluginCollectionToolbar({ expandSearchOnFocus action={action} searchValue={query} - searchPlaceholder={searchPlaceholder} + searchLabel={searchPlaceholder} + searchPlaceholder="Search plugins..." onSearchChange={(value) => changeSearchParams((next) => { if (value === "") next.delete("query"); diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 592d41005f3..5a22da3b785 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -19,6 +19,7 @@ import { } from "../dropdown-menu"; import { Icon, type IconName } from "../icon"; import { Input } from "../input"; +import { useIsCompactViewport } from "../hooks/use-compact-viewport"; import { Tooltip, TooltipContent, @@ -52,6 +53,10 @@ export function ResourceToolbar({ const controlsRef = useRef(null); const individualControlsRef = useRef(null); const searchRef = useRef(null); + const searchInputRef = useRef(null); + const searchButtonRef = useRef(null); + const restoreSearchFocus = useRef(false); + const isCompactViewport = useIsCompactViewport(); const actionRef = useRef(null); const [combined, setCombined] = useState(false); const [searchCondensed, setSearchCondensed] = useState(false); @@ -60,6 +65,23 @@ export function ResourceToolbar({ const combinedRef = useRef(false); const hasCombinedControls = Boolean(combinedControls); const showCombined = compact && hasCombinedControls && combined; + const showSearchButton = searchCondensed && !searchExpanded; + + const collapseSearch = () => { + restoreSearchFocus.current = searchCondensed; + searchInputRef.current?.blur(); + setSearchExpanded(false); + }; + + useLayoutEffect(() => { + if (searchExpanded) { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } else if (restoreSearchFocus.current) { + searchButtonRef.current?.focus(); + restoreSearchFocus.current = false; + } + }, [searchExpanded]); useLayoutEffect(() => { const toolbar = toolbarRef.current; @@ -93,11 +115,12 @@ export function ResourceToolbar({ : individualControls.getBoundingClientRect().width; setSearchCondensed( expandSearchOnFocus && - width - - controlsWidth - - actionWidth - - gap * (actionRef.current ? 2 : 1) < - searchWidth, + (isCompactViewport || + width - + controlsWidth - + actionWidth - + gap * (actionRef.current ? 2 : 1) < + searchWidth), ); if (combinedRef.current === next) return; restoreControlFocus.current = Boolean( @@ -129,7 +152,13 @@ export function ResourceToolbar({ menuObserver.disconnect(); cancelAnimationFrame(frame); }; - }, [compact, expandSearchOnFocus, hasCombinedControls, showCombined]); + }, [ + compact, + expandSearchOnFocus, + hasCombinedControls, + isCompactViewport, + showCombined, + ]); useLayoutEffect(() => { if (!restoreControlFocus.current) return; @@ -149,6 +178,7 @@ export function ResourceToolbar({ className={cn( "flex w-full min-w-0 items-center gap-2", compact ? "@container/resource-toolbar flex-nowrap" : "flex-wrap", + showSearchButton && "gap-1", )} >
{ event.preventDefault(); - searchRef.current?.querySelector("input")?.focus(); - setSearchExpanded(false); + collapseSearch(); }} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) @@ -169,70 +198,72 @@ export function ResourceToolbar({ compact ? "min-w-0 flex-1 basis-40" : "w-full min-w-0 sm:w-auto sm:flex-1", - expandSearchOnFocus && "min-w-20", + showSearchButton && "max-w-8", )} > -
- setSearchExpanded(true)} /> - onSearchChange(event.target.value)} - placeholder={ - searchCondensed && !searchExpanded - ? "Search..." - : searchPlaceholder - } - aria-label={searchLabel ?? searchPlaceholder} - enterKeyHint={expandSearchOnFocus ? "search" : undefined} - onFocus={() => { - if (searchCondensed) setSearchExpanded(true); - }} - onClick={() => { - if (searchCondensed) setSearchExpanded(true); - }} - onKeyDown={(event) => { - if (event.nativeEvent.isComposing) return; - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.form?.requestSubmit(); - } else if (event.key === "Escape" && searchExpanded) { - event.preventDefault(); - setSearchExpanded(false); - } - }} - className={cn( - "h-8 truncate pl-8 focus:border-ring/60 focus:text-clip focus:ring-2 focus:ring-ring/20 focus-visible:ring-2 focus-visible:ring-ring/20 max-md:pointer-coarse:h-8", - (searchExpanded || (!searchCondensed && searchValue)) && "pr-8", - )} - /> - {searchExpanded || (!searchCondensed && searchValue) ? ( - - - - - - Clear search - - - ) : null} -
+ ) : ( +
+ + onSearchChange(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchLabel ?? searchPlaceholder} + enterKeyHint={expandSearchOnFocus ? "search" : undefined} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } else if (event.key === "Escape" && searchExpanded) { + event.preventDefault(); + collapseSearch(); + } + }} + className={cn( + "h-8 truncate pl-8 focus:border-ring/60 focus:text-clip focus:ring-2 focus:ring-ring/20 focus-visible:ring-2 focus-visible:ring-ring/20 max-md:pointer-coarse:h-8", + expandSearchOnFocus && "text-xs max-md:pointer-coarse:text-xs", + (searchExpanded || (!searchCondensed && searchValue)) && "pr-8", + )} + /> + {searchExpanded || (!searchCondensed && searchValue) ? ( + + + + + + Clear search + + + ) : null} +
+ )}
{controls ? (
button]:flex-1" + : "w-max", compact ? "gap-2" : "gap-1.5", )} > @@ -265,7 +304,14 @@ export function ResourceToolbar({
{showCombined ? ( -
{combinedControls}
+
button]:w-full" : undefined + } + > + {combinedControls} +
) : null}
) : null} @@ -382,7 +428,7 @@ export const ResourceControlButton = forwardRef< className={cn( "h-8 shrink-0 rounded-md text-muted-foreground", text - ? "gap-2 px-2 text-xs @max-[19rem]/resource-toolbar:px-1 @max-[19rem]/resource-toolbar:text-2xs" + ? "gap-2 px-2 text-xs @max-[19rem]/resource-toolbar:gap-1 @max-[19rem]/resource-toolbar:px-1" : "size-8 p-0", RESOURCE_MENU_TRIGGER_RESTING_CLASS, (open || active) && RESOURCE_MENU_TRIGGER_ENGAGED_CLASS, @@ -390,16 +436,7 @@ export const ResourceControlButton = forwardRef< )} aria-label={label} > - {icon ? ( - - ) : null} + {icon ? : null} {text ? {text} : null} {count !== undefined && count > 0 ? ( onCreate()} > - + {label} ); From 5dd88c8b4e1413de167ae219856b9e0a0177fd11 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:03:32 +0000 Subject: [PATCH 75/79] Preserve focus when submitting unexpanded collection search --- packages/shared-ui/src/components/ui/resource/toolbar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 5a22da3b785..d8d611a13e5 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -187,7 +187,8 @@ export function ResourceToolbar({ aria-label={searchLabel ?? searchPlaceholder} onSubmit={(event) => { event.preventDefault(); - collapseSearch(); + if (searchExpanded) collapseSearch(); + else searchInputRef.current?.focus(); }} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) From 1c2f8ea639c0dd15b6e62ca4eb8d85798275612c Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:04:16 +0000 Subject: [PATCH 76/79] Give remaining plugin toolbar width to search --- .../management/PluginBrowseControls.test.tsx | 26 +++++++++-- .../src/components/ui/resource/toolbar.tsx | 43 ++++++------------- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 7d69d7d551e..474e70c5633 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -518,17 +518,35 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); }); - it("uses the Search button on mobile even when a lone Sort control leaves room", () => { + it("keeps mobile search inline when a lone Sort control leaves its minimum width", () => { viewport.compact = true; - mockToolbarWidth(400); + const resize = mockToolbarWidth(400); render(); + expect( + screen.getByRole("textbox", { name: "Search plugins" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Search plugins" })).toBeNull(); + resize(260); expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); + resize(264); expect( - screen.queryByRole("textbox", { name: "Search plugins" }), - ).toBeNull(); + screen.getByRole("textbox", { name: "Search plugins" }) + .value, + ).toBe("Memory"); expect(screen.getByRole("button", { name: /^Sort:/u })).toBeTruthy(); }); + it("keeps mobile search inline when combining filters frees enough width", () => { + viewport.compact = true; + mockToolbarWidth(354); + render(); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); + expect( + screen.getByRole("textbox", { name: "Search plugins" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Search plugins" })).toBeNull(); + }); + it.each([ { installed: true, diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index d8d611a13e5..dcb875aaba1 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -19,7 +19,6 @@ import { } from "../dropdown-menu"; import { Icon, type IconName } from "../icon"; import { Input } from "../input"; -import { useIsCompactViewport } from "../hooks/use-compact-viewport"; import { Tooltip, TooltipContent, @@ -56,7 +55,6 @@ export function ResourceToolbar({ const searchInputRef = useRef(null); const searchButtonRef = useRef(null); const restoreSearchFocus = useRef(false); - const isCompactViewport = useIsCompactViewport(); const actionRef = useRef(null); const [combined, setCombined] = useState(false); const [searchCondensed, setSearchCondensed] = useState(false); @@ -115,12 +113,11 @@ export function ResourceToolbar({ : individualControls.getBoundingClientRect().width; setSearchCondensed( expandSearchOnFocus && - (isCompactViewport || - width - - controlsWidth - - actionWidth - - gap * (actionRef.current ? 2 : 1) < - searchWidth), + width - + controlsWidth - + actionWidth - + gap * (actionRef.current ? 2 : 1) < + searchWidth, ); if (combinedRef.current === next) return; restoreControlFocus.current = Boolean( @@ -156,7 +153,6 @@ export function ResourceToolbar({ compact, expandSearchOnFocus, hasCombinedControls, - isCompactViewport, showCombined, ]); @@ -177,8 +173,9 @@ export function ResourceToolbar({ data-search-expanded={searchExpanded || undefined} className={cn( "flex w-full min-w-0 items-center gap-2", - compact ? "@container/resource-toolbar flex-nowrap" : "flex-wrap", - showSearchButton && "gap-1", + compact + ? "@container/resource-toolbar flex-nowrap max-md:gap-1" + : "flex-wrap", )} >
{showSearchButton ? ( @@ -209,6 +206,7 @@ export function ResourceToolbar({ tooltip={searchValue ? `Search: ${searchValue}` : searchPlaceholder} icon="Search" active={searchValue !== ""} + className="w-full" onClick={() => setSearchExpanded(true)} /> ) : ( @@ -272,19 +270,14 @@ export function ResourceToolbar({ inert={searchExpanded || undefined} aria-hidden={searchExpanded || undefined} className={cn( - "flex items-center", - showSearchButton ? "min-w-0 flex-1" : "shrink-0", + "flex shrink-0 items-center", compact ? "gap-2" : "gap-1.5", searchExpanded && "invisible absolute pointer-events-none", )} >
button]:flex-1" - : "w-max", + "flex w-max items-center", compact ? "gap-2" : "gap-1.5", )} > @@ -305,12 +295,7 @@ export function ResourceToolbar({
{showCombined ? ( -
button]:w-full" : undefined - } - > +
{combinedControls}
) : null} From aa27b41723d3e5eac6e0c848f99235c47329462e Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:50:54 +0000 Subject: [PATCH 77/79] Keep compact plugin controls square and mobile gutters even --- .../management/PluginBrowseControls.test.tsx | 21 +++++++++++++++---- .../management/PluginBrowseControls.tsx | 1 - .../src/components/ui/resource/collection.tsx | 10 ++++----- .../src/components/ui/resource/toolbar.tsx | 6 ++---- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index 474e70c5633..b66f1890c88 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -265,7 +265,7 @@ function mockToolbarWidth(initial: number, publishedWidth = 96) { 32, ); if (this.hasAttribute("data-resource-combined-controls")) - return new DOMRect(0, 0, 112, 32); + return new DOMRect(0, 0, 32, 32); if (this.hasAttribute("data-resource-toolbar-action")) return new DOMRect(0, 0, 120, 32); return original.call(this); @@ -321,7 +321,12 @@ describe("PluginCollectionToolbar", () => { it("describes all applied selections in the combined trigger tooltip", async () => { mockToolbarWidth(320); render(); - act(() => screen.getByRole("button", { name: "Filter & sort" }).focus()); + const trigger = screen.getByRole("button", { name: "Filter & sort" }); + expect(trigger.textContent).toBe(""); + expect( + trigger.querySelector('[data-icon="FilterHorizontal"]'), + ).not.toBeNull(); + act(() => trigger.focus()); expect((await screen.findByRole("tooltip")).textContent).toBe( "Category: Security; Source: Local; Sort: Name · Z–A", ); @@ -538,13 +543,21 @@ describe("PluginCollectionToolbar", () => { it("keeps mobile search inline when combining filters frees enough width", () => { viewport.compact = true; - mockToolbarWidth(354); - render(); + const resize = mockToolbarWidth(354); + render(); expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); expect( screen.getByRole("textbox", { name: "Search plugins" }), ).toBeTruthy(); expect(screen.queryByRole("button", { name: "Search plugins" })).toBeNull(); + resize(320); + expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); + resize(328); + expect( + screen.getByRole("textbox", { name: "Search plugins" }) + .value, + ).toBe("Memory"); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); }); it.each([ diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 442806a5d78..d1c77c0ff29 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -305,7 +305,6 @@ function PluginControlsMenu({ ({ return (
{} -
+
{description}
{hasModes || actions !== undefined ? ( -
+
{} {toolbar ? ( -
+
{toolbar}
) : null} @@ -172,7 +172,7 @@ export function ResourceCollectionViewport({ viewportRef={viewportRef} viewportProps={{ id: scrollId, - className: cn("overscroll-contain pr-3", contentClassName), + className: cn("overscroll-contain md:pr-3", contentClassName), "data-resource-collection-scroll": true, }} > @@ -180,7 +180,7 @@ export function ResourceCollectionViewport({ {footer ? (
{footer}
diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index dcb875aaba1..a0e269803e0 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -196,7 +196,7 @@ export function ResourceToolbar({ compact ? "min-w-0 flex-1 basis-40" : "w-full min-w-0 sm:w-auto sm:flex-1", - showSearchButton && "min-w-8", + showSearchButton && "min-w-8 max-w-8 grow-0 shrink-0", )} > {showSearchButton ? ( @@ -206,7 +206,6 @@ export function ResourceToolbar({ tooltip={searchValue ? `Search: ${searchValue}` : searchPlaceholder} icon="Search" active={searchValue !== ""} - className="w-full" onClick={() => setSearchExpanded(true)} /> ) : ( @@ -308,8 +307,7 @@ export function ResourceToolbar({ inert={searchExpanded || undefined} aria-hidden={searchExpanded || undefined} className={cn( - "flex shrink-0 items-center gap-1.5", - !compact && "ml-auto", + "ml-auto flex shrink-0 items-center gap-1.5", searchExpanded && "invisible absolute pointer-events-none", )} > From e6d3210f4b3bbbec68fd801da656d0e931460c88 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:17:25 +0000 Subject: [PATCH 78/79] Pair compact filters with square search and preserve submitted queries --- .../management/PluginBrowseControls.test.tsx | 28 +++++++++++++++---- .../src/components/ui/resource/toolbar.tsx | 22 ++++++++------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx index b66f1890c88..1acc1547390 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.test.tsx @@ -541,18 +541,19 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByRole("button", { name: /^Sort:/u })).toBeTruthy(); }); - it("keeps mobile search inline when combining filters frees enough width", () => { + it("pairs combined controls with square search until separate controls fit", () => { viewport.compact = true; const resize = mockToolbarWidth(354); render(); expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); - expect( - screen.getByRole("textbox", { name: "Search plugins" }), - ).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Search plugins" })).toBeNull(); + expect(screen.queryByRole("textbox", { name: "Search plugins" })).toBeNull(); + expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); resize(320); expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); resize(328); + expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); + resize(800); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); expect( screen.getByRole("textbox", { name: "Search plugins" }) .value, @@ -560,6 +561,23 @@ describe("PluginCollectionToolbar", () => { expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); }); + it("dismisses inline mobile search on Enter while preserving the query", () => { + viewport.compact = true; + mockToolbarWidth(400); + render(); + const search = screen.getByRole("textbox", { + name: "Search plugins", + }); + act(() => search.focus()); + fireEvent.change(search, { target: { value: "Notes" } }); + fireEvent.keyDown(search, { key: "Enter" }); + expect(document.activeElement).not.toBe(search); + expect(search.value).toBe("Notes"); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "query=Notes", + ); + }); + it.each([ { installed: true, diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index a0e269803e0..63e477db2bb 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -19,6 +19,7 @@ import { } from "../dropdown-menu"; import { Icon, type IconName } from "../icon"; import { Input } from "../input"; +import { useIsCompactViewport } from "../hooks/use-compact-viewport"; import { Tooltip, TooltipContent, @@ -48,6 +49,7 @@ export function ResourceToolbar({ compact?: boolean; expandSearchOnFocus?: boolean; }) { + const isCompactViewport = useIsCompactViewport(); const toolbarRef = useRef(null); const controlsRef = useRef(null); const individualControlsRef = useRef(null); @@ -113,11 +115,12 @@ export function ResourceToolbar({ : individualControls.getBoundingClientRect().width; setSearchCondensed( expandSearchOnFocus && - width - - controlsWidth - - actionWidth - - gap * (actionRef.current ? 2 : 1) < - searchWidth, + (next || + width - + controlsWidth - + actionWidth - + gap * (actionRef.current ? 2 : 1) < + searchWidth), ); if (combinedRef.current === next) return; restoreControlFocus.current = Boolean( @@ -184,7 +187,8 @@ export function ResourceToolbar({ aria-label={searchLabel ?? searchPlaceholder} onSubmit={(event) => { event.preventDefault(); - if (searchExpanded) collapseSearch(); + if (searchExpanded || (expandSearchOnFocus && isCompactViewport)) + collapseSearch(); else searchInputRef.current?.focus(); }} onBlur={(event) => { @@ -915,8 +919,7 @@ export function ResourceCreateButton({ size="sm" className={cn( "rounded-r-none", - compactWhenNarrow && - "pl-2 pr-1 @max-[19rem]/resource-toolbar:gap-1 @max-[19rem]/resource-toolbar:px-1", + compactWhenNarrow && "pl-2 pr-1", )} onClick={() => onCreate()} > @@ -944,8 +947,7 @@ export function ResourceCreateButton({ aria-label={`${label} options`} className={cn( "rounded-l-none px-1.5", - compactWhenNarrow && - "pl-1 pr-2 @max-[19rem]/resource-toolbar:px-1", + compactWhenNarrow && "pl-1 pr-2", )} > From e0e4a975ecf7449ba3f2102a8a78bde1cd7f769b Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:51:02 +0000 Subject: [PATCH 79/79] Match combined plugin menu icons to standalone controls --- .../src/components/plugin/management/PluginBrowseControls.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index d1c77c0ff29..c7da9e57e1b 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -168,6 +168,7 @@ export function PluginCollectionToolbar({ { id: "category", label: "Category", + icon: "SlidersHorizontal", active: selectedCategories.length > 0, summary: categoryOptions @@ -183,6 +184,7 @@ export function PluginCollectionToolbar({ { id: "source", label: "Source", + icon: sourceProps.icon, active: sourceProps.selectedValues.length > 0, summary: sourceProps.options