diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 515287d16..4444a1dc1 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -203,7 +203,7 @@ describe("PluginsOverview", () => { fireEvent.click(screen.getByRole("tab", { name: "Browse" })); expect(await screen.findByText("GitHub")).toBeTruthy(); - expect(screen.getByRole("radio", { name: "Developer tools" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Category" })).toBeTruthy(); expect(screen.queryByText("BB Official plugins")).toBeNull(); expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy(); }); @@ -233,29 +233,28 @@ describe("PluginsOverview", () => { expect(await screen.findByText("Automations")).toBeTruthy(); expect(screen.getByText("Docs")).toBeTruthy(); - expect( - screen.queryByRole("radiogroup", { - name: "Filter plugins by category", - }), - ).toBeNull(); - expect(screen.queryByText("Category")).toBeNull(); + // Installed offers Type, not Category. + expect(screen.queryByRole("button", { name: "Category" })).toBeNull(); + expect(screen.getByRole("button", { name: "Type" })).toBeTruthy(); fireEvent.click(screen.getByRole("tab", { name: "Browse" })); - expect( - await screen.findByRole("radiogroup", { - name: "Filter plugins by category", - }), - ).toBeTruthy(); - expect(screen.queryByText("Category")).toBeNull(); - fireEvent.click(screen.getByRole("radio", { name: "Context & knowledge" })); + // Wait for the catalog so the Category menu has options to offer. + await screen.findByText("GitHub"); + const categoryTrigger = screen.getByRole("button", { name: "Category" }); + expect(screen.queryByRole("button", { name: "Type" })).toBeNull(); + fireEvent.pointerDown(categoryTrigger); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Context & knowledge" }), + ); + fireEvent.keyDown(document, { key: "Escape" }); expect(screen.getByText("Docs")).toBeTruthy(); - expect(screen.queryByText("Automations")).toBeNull(); + expect(screen.queryByText("GitHub")).toBeNull(); }); - it("keeps category pills visually secondary to the collection tabs", async () => { + it("keeps Browse filters in the toolbar rather than a separate pill band", async () => { installFetch(); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); - render( + const { container } = render( @@ -264,19 +263,19 @@ describe("PluginsOverview", () => { ); await screen.findByText("GitHub"); - const filters = screen.getByRole("radiogroup", { - name: "Filter plugins by category", - }); - const all = screen.getByRole("radio", { - name: "Show all plugin categories", - }); - expect(filters.className).toContain("py-2"); - expect(filters.className).toContain("gap-2"); - expect(all.className).toContain("cursor-pointer"); - expect(all.className).toContain("hover:border-foreground/20"); - expect(all.className).toContain("hover:shadow-xs"); - expect(all.className).toContain("data-[state=on]:bg-secondary/70"); - expect(all.className).not.toContain("data-[state=on]:bg-state-active"); + // The old pill row is gone, so Browse keeps one flush content band. + expect( + screen.queryByRole("radiogroup", { + name: "Filter plugins by category", + }), + ).toBeNull(); + const controls = container.querySelector( + "[data-resource-collection-viewport] > .shrink-0", + ) as HTMLElement; + const category = screen.getByRole("button", { name: "Category" }); + const sort = screen.getByRole("button", { name: /^Sort:/ }); + expect(controls.contains(category)).toBe(true); + expect(controls.contains(sort)).toBe(true); expect(screen.getByRole("tab", { name: "Browse" }).className).toContain( "bg-accent", ); @@ -594,6 +593,79 @@ describe("PluginsOverview", () => { ]); }); + it("filters installed plugins by type, treating builtin and catalog as bb Official", async () => { + installFetch([ + { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, + { + ...AUTOMATIONS_PLUGIN, + id: "catalog-one", + name: "Catalog One", + provenance: "catalog", + catalogEntryId: "catalog-one", + }, + { + ...AUTOMATIONS_PLUGIN, + id: "direct-one", + name: "Direct One", + provenance: "direct", + }, + ]); + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + , + ); + + await screen.findByText("Direct One"); + const rowIds = () => + [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( + (row) => row.getAttribute("data-testid"), + ); + + // Nothing selected is the default and shows every type. + 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); + // There is no explicit "All" row: an empty selection means all types. + expect(screen.queryByRole("menuitemcheckbox", { name: "All" })).toBeNull(); + + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "bb Official" }), + ); + await waitFor(() => { + expect(rowIds()).toEqual([ + "plugin-row-builtin-one", + "plugin-row-catalog-one", + ]); + }); + + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "bb Official" }), + ); + await waitFor(() => { + expect(rowIds()).toEqual(["plugin-row-direct-one"]); + }); + + // Clearing the last selection returns to unfiltered, not to empty. + 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 disabled plugins installed regardless of provenance", async () => { installFetch([ AUTOMATIONS_PLUGIN, diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index df2409110..d1e82afea 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -9,6 +9,7 @@ import { ResourceCollectionPage, ResourceCollectionViewport, ResourceListState, + ResourceMultiSelectMenu, ResourceSortMenu, ResourceToolbar, type ResourceCollectionMode, @@ -23,7 +24,10 @@ import { } from "@/components/plugin/management/AddPluginDialog"; import { BrowsePluginsTab } from "@/components/plugin/management/BrowsePluginsTab"; import { InstalledPluginsTab } from "@/components/plugin/management/InstalledPluginsTab"; -import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; +import { + usePluginList, + type PluginProvenance, +} from "@/hooks/queries/plugin-settings-queries"; import { getPluginDetailRoutePath, getRootComposeRoutePath, @@ -31,6 +35,29 @@ import { type PluginsCollectionMode = "installed" | "browse"; +/** Where an installed plugin came from, as the collection filter presents it. */ +type PluginTypeFilter = "bb-official" | "user"; + +const PLUGIN_TYPE_FILTERS: readonly PluginTypeFilter[] = [ + "bb-official", + "user", +]; + +const PLUGIN_TYPE_FILTER_OPTIONS = PLUGIN_TYPE_FILTERS.map((type) => ({ + id: type, + label: type === "bb-official" ? "bb Official" : "User", +})); + +function pluginTypeFilterId(provenance: PluginProvenance): PluginTypeFilter { + return provenance === "builtin" || provenance === "catalog" + ? "bb-official" + : "user"; +} + +function isPluginTypeFilter(value: string): value is PluginTypeFilter { + return value === "bb-official" || value === "user"; +} + function modeFromSearchParams(value: string | null): PluginsCollectionMode { if (value === "browse") return value; return "installed"; @@ -58,6 +85,8 @@ export function PluginsOverview() { const [installedSortDirection, setInstalledSortDirection] = useState< "asc" | "desc" >("asc"); + // Empty means unfiltered: the menu has no explicit "All" row. + const [typeFilters, setTypeFilters] = useState([]); const [addDialog, setAddDialog] = useState<{ open: boolean; initial: AddPluginInitial | null; @@ -79,6 +108,12 @@ export function PluginsOverview() { () => plugins .filter((plugin) => { + if ( + typeFilters.length > 0 && + !typeFilters.includes(pluginTypeFilterId(plugin.provenance)) + ) { + return false; + } if (normalizedInstalledQuery.length === 0) return true; return [ plugin.id, @@ -111,11 +146,15 @@ export function PluginsOverview() { } return left.id.localeCompare(right.id); }), - [installedSortDirection, normalizedInstalledQuery, plugins], + [installedSortDirection, normalizedInstalledQuery, plugins, typeFilters], ); const installedPagination = useResourcePagination(visiblePlugins, { pageSize: installedPageSize, - resetKey: [normalizedInstalledQuery, installedSortDirection].join("\u0000"), + resetKey: [ + normalizedInstalledQuery, + installedSortDirection, + [...typeFilters].sort().join(","), + ].join("\u0000"), }); const hasInstalledPagination = !listQuery.isError && @@ -178,18 +217,31 @@ export function PluginsOverview() { searchValue={installedQuery} searchPlaceholder="Search installed plugins" onSearchChange={setInstalledQuery} - containedControls controls={ - - setInstalledSortDirection((current) => - current === "asc" ? "desc" : "asc", - ) - } - /> + <> + + options.map((option) => option.label).join(", ") + } + onChange={(values) => + setTypeFilters(values.filter(isPluginTypeFilter)) + } + /> + + setInstalledSortDirection((current) => + current === "asc" ? "desc" : "asc", + ) + } + /> + } /> } @@ -218,7 +270,11 @@ export function PluginsOverview() { ) : plugins.length > 0 && visiblePlugins.length === 0 ? ( ) : ( diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index c17839573..27ded96a3 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -138,7 +138,7 @@ describe("BrowsePluginsTab", () => { ]); }); - it("renders every catalog entry once and filters the grid with category pills", async () => { + it("renders every catalog entry once and filters the grid by category", async () => { const entries = Array.from( { length: CATALOG_STATUS.pluginCount }, (_, index) => ({ @@ -166,31 +166,47 @@ describe("BrowsePluginsTab", () => { ); const { wrapper } = createQueryClientTestHarness(); - render( {}} onOpenPlugin={() => {}} />, { - wrapper, - }); + const { container } = render( + {}} onOpenPlugin={() => {}} />, + { wrapper }, + ); + + // An open Radix menu marks the grid aria-hidden, so count cards in the DOM. + const cardCount = () => + container.querySelectorAll('[aria-label^="Open Official "]').length; expect(await screen.findByText("Official 1")).toBeTruthy(); + expect(cardCount()).toBe(CATALOG_STATUS.pluginCount); + // Category is a toolbar multi-select, not a pill row, so the browse page + // keeps one flush content band. expect( - screen.getAllByRole("button", { name: /^Open Official \d+ details$/ }), - ).toHaveLength(CATALOG_STATUS.pluginCount); - expect( - screen.getByRole("radiogroup", { name: "Filter plugins by category" }), - ).toBeTruthy(); - expect( - screen.queryByRole("heading", { name: "Context & knowledge" }), + screen.queryByRole("radiogroup", { name: "Filter plugins by category" }), ).toBeNull(); - fireEvent.click(screen.getByRole("radio", { name: "Developer tools" })); - expect( - screen.getAllByRole("button", { name: /^Open Official \d+ details$/ }), - ).toHaveLength(6); - expect(screen.queryByText("Official 1")).toBeNull(); + const categoryTrigger = screen.getByRole("button", { name: "Category" }); + fireEvent.pointerDown(categoryTrigger); + // No explicit "All" row: an empty selection already means every category. + expect(screen.queryByRole("menuitemcheckbox", { name: "All" })).toBeNull(); + fireEvent.click( - screen.getByRole("radio", { name: "Show all plugin categories" }), + screen.getByRole("menuitemcheckbox", { name: "Developer tools" }), ); - expect( - screen.getAllByRole("button", { name: /^Open Official \d+ details$/ }), - ).toHaveLength(CATALOG_STATUS.pluginCount); + expect(cardCount()).toBe(6); + expect(container.textContent).not.toContain("Official 1 "); + + // Selections accumulate rather than replace. + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Context & knowledge" }), + ); + expect(cardCount()).toBe(CATALOG_STATUS.pluginCount); + + // Clearing every category returns to unfiltered, not empty. + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Developer tools" }), + ); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Context & knowledge" }), + ); + expect(cardCount()).toBe(CATALOG_STATUS.pluginCount); expect(screen.queryByText("BB Official plugins")).toBeNull(); }); @@ -252,10 +268,7 @@ describe("BrowsePluginsTab", () => { ); } expect(githubDescription.className).toContain("min-h-[2lh]"); - expect( - screen.getByRole("radio", { name: "Context & knowledge" }), - ).toBeTruthy(); - expect(screen.getByRole("radio", { name: "Developer tools" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Category" })).toBeTruthy(); expect(screen.queryByRole("heading", { level: 2 })).toBeNull(); expect(screen.getByRole("button", { name: "Install Memory" })).toBeTruthy(); expect(screen.queryByText("BB Official plugins")).toBeNull(); @@ -364,8 +377,14 @@ describe("BrowsePluginsTab", () => { ); expect(installed.querySelector('[data-icon="Download"]')).not.toBeNull(); expect(installed.querySelector('[data-icon="Check"]')).toBeNull(); - expect(installed.className).toContain("border-success/40"); - expect(installed.className).toContain("bg-success/15"); + // The installed state reads as a plain success-tinted glyph: no outline, + // no fill, at rest or on hover/focus. + expect(installed.className).toContain("border-transparent"); + expect(installed.className).toContain("bg-transparent"); + expect(installed.className).toContain("hover:border-transparent"); + expect(installed.className).toContain("hover:bg-transparent"); + expect(installed.className).toContain("focus-visible:border-transparent"); + expect(installed.className).toContain("focus-visible:bg-transparent"); expect(installed.className).toContain( "text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]", ); @@ -377,7 +396,6 @@ describe("BrowsePluginsTab", () => { "focus-visible:text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]", ); expect(installed.className).not.toContain("hover:text-foreground"); - expect(installed.className).toContain("hover:bg-success/25"); expect(screen.queryByRole("button", { name: "Install" })).toBeNull(); fireEvent.click(installed); expect( diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 3de704b23..9be37b0a7 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -7,6 +7,7 @@ import { ResourceCollectionViewport, ResourceInstallControl, ResourceListState, + ResourceMultiSelectMenu, ResourceSortMenu, ResourceToolbar, } from "@bb/shared-ui/resource-list"; @@ -26,7 +27,6 @@ import { } from "@/hooks/queries/plugin-catalog-queries"; import { removePlugin } from "@/hooks/queries/plugin-settings-queries"; import type { AddPluginInitial } from "./AddPluginDialog"; -import { PluginCategoryFilterPills } from "./PluginCategoryFilterPills"; import { PlaceholderBadge } from "./plugin-ui"; /** Browse BB's official plugins, bundled with the app. */ @@ -38,22 +38,31 @@ export function BrowsePluginsTab({ onOpenPlugin: (pluginId: string) => void; }) { const [query, setQuery] = useState(""); - const [category, setCategory] = useState(null); + // Empty means unfiltered, matching the Type filters on Installed and Skills. + const [categories, setCategories] = useState([]); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const [debouncedQuery] = useDebounceValue(query.trim(), 300); const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: true }); const entries = searchQuery.data ?? []; - const categories: string[] = []; + const availableCategories: string[] = []; for (const entry of entries) { - if (!categories.includes(entry.category)) categories.push(entry.category); + if (!availableCategories.includes(entry.category)) { + availableCategories.push(entry.category); + } } - if (category !== null && !categories.includes(category)) { - categories.push(category); + for (const selected of categories) { + if (!availableCategories.includes(selected)) { + availableCategories.push(selected); + } } + const categoryOptions = availableCategories.map((name) => ({ + id: name, + label: name, + })); const visibleEntries = ( - category === null + categories.length === 0 ? entries - : entries.filter((entry) => entry.category === category) + : entries.filter((entry) => categories.includes(entry.category)) ) .slice() .sort((left, right) => { @@ -71,18 +80,29 @@ export function BrowsePluginsTab({ searchValue={query} searchPlaceholder="Search plugins" onSearchChange={setQuery} - containedControls controls={ - - setSortDirection((current) => - current === "asc" ? "desc" : "asc", - ) - } - /> + <> + + options.map((option) => option.label).join(", ") + } + onChange={setCategories} + /> + + setSortDirection((current) => + current === "asc" ? "desc" : "asc", + ) + } + /> + } /> } @@ -113,11 +133,6 @@ export function BrowsePluginsTab({ /> ) : (
- {visibleEntries.length === 0 ? ( setConfirmingUninstall(true)} /> ) : ( diff --git a/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx b/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx deleted file mode 100644 index 76dc80891..000000000 --- a/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ToggleGroup, ToggleGroupItem } from "@bb/shared-ui/toggle-group"; - -const ALL_CATEGORIES = "all"; -const CATEGORY_FILTER_CLASS = - "h-7 min-w-0 cursor-pointer rounded-full border border-border bg-transparent px-3 text-xs text-muted-foreground shadow-none transition-[background-color,border-color,box-shadow,color] duration-150 hover:border-foreground/20 hover:bg-secondary/50 hover:text-foreground hover:shadow-xs data-[state=on]:border-transparent data-[state=on]:bg-secondary/70 data-[state=on]:text-secondary-foreground"; - -export function PluginCategoryFilterPills({ - categories, - value, - onValueChange, -}: { - categories: readonly string[]; - value: string | null; - onValueChange: (category: string | null) => void; -}) { - if (categories.length === 0) return null; - - return ( - { - if (next.length === 0) return; - onValueChange(next === ALL_CATEGORIES ? null : next); - }} - aria-label="Filter plugins by category" - className="flex-wrap justify-start gap-2 py-2" - > - - All - - {categories.map((category) => ( - - {category} - - ))} - - ); -} diff --git a/apps/app/src/components/tools/SkillsCollection.tsx b/apps/app/src/components/tools/SkillsCollection.tsx index 248767f2e..7c36d719e 100644 --- a/apps/app/src/components/tools/SkillsCollection.tsx +++ b/apps/app/src/components/tools/SkillsCollection.tsx @@ -79,7 +79,7 @@ function skillSourceFilterId( } function skillSourceFilterLabel(source: ResourceSkillSourceFilter): string { - return source === "bb-official" ? "bb official" : "Plugin"; + return source === "bb-official" ? "bb Official" : "Included in plugin"; } function isResourceSkillSourceFilter( @@ -281,9 +281,10 @@ export function SkillsOverview({ const [providerFilters, setProviderFilters] = useState< ResourceProviderFilter[] >(["bb"]); + // Empty means unfiltered: the menu has no explicit "All" row. const [sourceFilters, setSourceFilters] = useState< ResourceSkillSourceFilter[] - >(["bb-official"]); + >([]); const [sortMode, setSortMode] = useState("alpha"); const [sortDirection, setSortDirection] = useState("asc"); @@ -332,7 +333,10 @@ export function SkillsOverview({ const visibleSkills = useMemo(() => { const filtered = skills.filter((skill) => { const source = skillSourceFilterId(skill); - if (source !== null && !sourceFilters.includes(source)) { + if ( + sourceFilters.length > 0 && + (source === null || !sourceFilters.includes(source)) + ) { return false; } if ( @@ -476,8 +480,6 @@ export function SkillsOverview({ icon="PackageReceive" selectedValues={sourceFilters} options={sourceOptions} - allOptionLabel="All" - emptySelectionLabel="None" selectedLabel={(options) => options.map((option) => option.label).join(", ") } diff --git a/apps/app/src/components/tools/automation-overview.test.tsx b/apps/app/src/components/tools/automation-overview.test.tsx index de0f85a84..d31c970e5 100644 --- a/apps/app/src/components/tools/automation-overview.test.tsx +++ b/apps/app/src/components/tools/automation-overview.test.tsx @@ -6,6 +6,12 @@ import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact import { AutomationOverviewView } from "bb-plugin-automations/overview-view"; import type { AutomationsOverviewResponse } from "bb-plugin-automations/rpc-types"; +function iconNames(element: HTMLElement): string[] { + return [...element.querySelectorAll("[data-icon]")].map( + (icon) => icon.getAttribute("data-icon") ?? "", + ); +} + const INSTALLED_AUTOMATIONS: AutomationsOverviewResponse["automations"] = [ { automation: { @@ -121,7 +127,7 @@ describe("AutomationOverviewView", () => { expect(screen.getByRole("button", { name: "New automation" })).toBeTruthy(); }); - it("labels the Projects filter and prefixes its tooltip summary", async () => { + it("offers Projects and Status as groups inside one filter menu", async () => { render( { />, ); - const projectsTrigger = screen.getByRole("button", { name: "Projects" }); - fireEvent.focus(projectsTrigger); + // One trigger replaces the separate Projects and Status dropdowns. + expect(screen.queryByRole("button", { name: "Projects" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Status" })).toBeNull(); + const filtersTrigger = screen.getByRole("button", { name: "Filters" }); + fireEvent.focus(filtersTrigger); expect((await screen.findByRole("tooltip")).textContent).toBe( - "Projects: All", + "Filters: All", ); - fireEvent.blur(projectsTrigger); - fireEvent.pointerDown(projectsTrigger); - const projectsMenu = screen.getByRole("menu", { name: "Projects" }); - expect(projectsMenu.className).toContain("md:p-0.5"); - expect(projectsMenu.className).toContain("w-max"); + fireEvent.blur(filtersTrigger); + fireEvent.pointerDown(filtersTrigger); + + const filtersMenu = screen.getByRole("menu", { name: "Filters" }); + expect(filtersMenu.className).toContain("md:p-0.5"); + expect(filtersMenu.className).toContain("w-max"); + expect(screen.getByText("Projects")).toBeTruthy(); + expect(screen.getByText("Status")).toBeTruthy(); + const projectOption = screen.getByRole("menuitemcheckbox", { name: "bb" }); expect(projectOption.className).toContain("md:py-1"); - expect(projectOption.querySelector('[data-icon="Folder"]')).toBeTruthy(); + expect(projectOption.querySelector("[data-icon]")).toBeNull(); expect( projectOption.querySelector(".truncate")?.getAttribute("title"), ).toBe("bb"); - fireEvent.click(projectOption); - fireEvent.keyDown(document, { key: "Escape" }); + const activeOption = screen.getByRole("menuitemcheckbox", { + name: "Active", + }); + const pausedOption = screen.getByRole("menuitemcheckbox", { + name: "Paused", + }); + expect(activeOption.querySelector("[data-icon]")).toBeNull(); + expect(pausedOption.querySelector("[data-icon]")).toBeNull(); + }); + it("keeps project and status selections independent in the merged menu", () => { + render( + {}} + onOpenDetail={() => {}} + onEnabledChange={async () => {}} + onCreateViaChat={() => {}} + activeMode="installed" + onModeChange={() => {}} + />, + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Filters" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "bb" })); + expect( + screen.getByRole("menuitemcheckbox", { name: "bb" }).ariaChecked, + ).toBe("true"); + // Picking a Status option must not clear the Projects selection. + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Paused" })); expect( - screen.getByRole("button", { name: "Projects: 1 selected" }), + screen.getByRole("menuitemcheckbox", { name: "bb" }).ariaChecked, + ).toBe("true"); + expect( + screen.getByRole("menuitemcheckbox", { name: "Paused" }).ariaChecked, + ).toBe("true"); + expect( + screen.getByRole("menuitemcheckbox", { name: "Active" }).ariaChecked, + ).toBe("false"); + + fireEvent.keyDown(document, { key: "Escape" }); + expect( + screen.getByRole("button", { + name: "Filters: Projects: bb; Status: Paused", + }), ).toBeTruthy(); }); - it("labels the Status filter and prefixes its tooltip summary", async () => { - render( + it("gives filter and sort triggers the same resting, open, and engaged states", () => { + const { container } = render( { />, ); - const statusTrigger = screen.getByRole("button", { name: "Status" }); - fireEvent.focus(statusTrigger); - expect((await screen.findByRole("tooltip")).textContent).toBe( - "Status: All", - ); - fireEvent.blur(statusTrigger); - fireEvent.pointerDown(statusTrigger); - expect(screen.getByText("Status")).toBeTruthy(); - const activeOption = screen.getByRole("menuitemcheckbox", { - name: "Active", - }); - const pausedOption = screen.getByRole("menuitemcheckbox", { - name: "Paused", - }); - expect(activeOption.querySelector('[data-icon="Play"]')).toBeTruthy(); - expect(pausedOption.querySelector('[data-icon="Pause"]')).toBeTruthy(); + // Assert the treatment that is actually rendered. These triggers compose + // TooltipTrigger over DropdownMenuTrigger, so the tooltip's data-state wins + // on the shared element and any `data-[state=open]:` styling would be dead. + // The app-wide selection surface (CONTEXT_SELECTION_SURFACE_CLASS). + const ENGAGED = ["bg-state-active", "text-foreground"]; + const classesOf = (el: HTMLElement) => new Set(el.className.split(/\s+/)); + const isEngaged = (el: HTMLElement) => { + const classes = classesOf(el); + return ENGAGED.every((engagedClass) => classes.has(engagedClass)); + }; + // An open Radix menu marks the rest of the page aria-hidden, so query the + // triggers through the DOM rather than the accessibility tree. + const byLabel = (prefix: string) => { + const el = container.querySelector( + `button[aria-label^="${prefix}"]`, + ); + if (el === null) throw new Error(`no trigger labelled ${prefix}`); + return el; + }; + const filters = () => byLabel("Filters"); + const sort = () => byLabel("Sort:"); + + // At rest neither trigger carries a fill, so neither reads as pressed. + for (const trigger of [filters(), sort()]) { + expect(isEngaged(trigger)).toBe(false); + expect(classesOf(trigger).has("bg-state-active")).toBe(false); + } + + // Opening either menu engages that trigger and only that trigger. + fireEvent.pointerDown(filters()); + expect(isEngaged(filters())).toBe(true); + expect(isEngaged(sort())).toBe(false); + fireEvent.keyDown(document, { key: "Escape" }); + expect(isEngaged(filters())).toBe(false); + + fireEvent.pointerDown(sort()); + expect(isEngaged(sort())).toBe(true); + expect(isEngaged(filters())).toBe(false); + fireEvent.keyDown(document, { key: "Escape" }); + expect(isEngaged(sort())).toBe(false); + + // A filter holding a selection keeps the same treatment once closed. + fireEvent.pointerDown(filters()); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "bb" })); + fireEvent.keyDown(document, { key: "Escape" }); + expect(isEngaged(filters())).toBe(true); }); - it("uses compact, icon-labelled sort options and preserves disabled state", () => { + it("uses compact, icon-free sort options and preserves disabled state", () => { render( { const sortTrigger = screen.getByRole("button", { name: "Sort: Automation name, ascending", }); - expect(sortTrigger.querySelector('[data-icon="ArrowUp"]')).toBeTruthy(); + // Same sort glyph as the Plugins and Skills toolbars, in both directions. + expect(sortTrigger.querySelector('[data-icon="ArrowUpDown"]')).toBeTruthy(); fireEvent.pointerDown(sortTrigger); const projectOption = screen.getByRole("menuitemradio", { name: "Project", @@ -219,11 +304,13 @@ describe("AutomationOverviewView", () => { expect(projectOption.getAttribute("aria-disabled")).toBe("true"); expect(projectOption.getAttribute("aria-checked")).toBe("false"); expect(nameOption.getAttribute("aria-checked")).toBe("true"); - expect(projectOption.querySelector('[data-icon="Folder"]')).toBeTruthy(); - expect(nameOption.querySelector('[data-icon="Sort"]')).toBeTruthy(); + // Only the trailing direction arrow remains; no leading option icons. + expect(iconNames(projectOption)).toEqual(["ArrowUp"]); + expect(iconNames(nameOption)).toEqual(["ArrowUp"]); expect(nameOption.className).toContain("md:py-1"); fireEvent.click(nameOption); - expect(sortTrigger.querySelector('[data-icon="ArrowDown"]')).toBeTruthy(); + expect(sortTrigger.querySelector('[data-icon="ArrowUpDown"]')).toBeTruthy(); + expect(iconNames(nameOption)).toEqual(["ArrowDown"]); expect(sortTrigger.getAttribute("aria-label")).toBe( "Sort: Automation name, descending", ); diff --git a/apps/app/src/components/ui/control-weight.stories.tsx b/apps/app/src/components/ui/control-weight.stories.tsx new file mode 100644 index 000000000..4bae0fa7a --- /dev/null +++ b/apps/app/src/components/ui/control-weight.stories.tsx @@ -0,0 +1,129 @@ +import type { ReactNode } from "react"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { CONTEXT_SELECTION_SURFACE_CLASS } from "./context-selection"; + +export default { title: "Control weight" }; + +/** + * Resting/hover/focus/engaged/disabled for the two control families the + * toolbar rework touches. Ladle's own light/dark switch drives the two + * default palettes; custom palettes (Nord, Dracula, …) redefine the whole + * derived token set, so those are verified in the app with `bb theme set`, + * not by overriding --canvas/--ink on a wrapper here (derived tokens resolve + * at :root, so a descendant override would not recompute them). + */ + +// The app's one selection surface, shared with sidebar rows and tab pills. +const ENGAGED = `${CONTEXT_SELECTION_SURFACE_CLASS} text-foreground`; + +function IconBtn({ + className, + disabled, + label, +}: { + className?: string; + disabled?: boolean; + label: string; +}) { + return ( + + ); +} + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} + +/** The toolbar's recessed track, so engaged reads in its real context. */ +function Track({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Matrix({ name }: { name: string }) { + return ( +
+

{name}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +export function ControlWeightStates() { + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx index 595c4956a..e07bd052f 100644 --- a/apps/app/src/views/SkillsView.test.tsx +++ b/apps/app/src/views/SkillsView.test.tsx @@ -300,29 +300,28 @@ describe("SkillsOverview", () => { />, ); + // Nothing selected is the default and means every type is shown. expect(screen.getByText("official-skill")).toBeTruthy(); expect(screen.getByText("user-skill")).toBeTruthy(); - expect(screen.queryByText("automations")).toBeNull(); - const typeTrigger = screen.getByRole("button", { name: "bb official" }); + expect(screen.getByText("automations")).toBeTruthy(); + const typeTrigger = screen.getByRole("button", { name: "Type" }); fireEvent.focus(typeTrigger); - expect((await screen.findByRole("tooltip")).textContent).toBe( - "Type: bb official", - ); + expect((await screen.findByRole("tooltip")).textContent).toBe("Type: All"); fireEvent.blur(typeTrigger); fireEvent.pointerDown(typeTrigger); expect(screen.getByText("Type")).toBeTruthy(); - expect(screen.getByRole("menuitemcheckbox", { name: "All" })).toBeTruthy(); - expect( - screen - .getByRole("menuitemcheckbox", { name: "bb official" }) - .getAttribute("aria-checked"), - ).toBe("true"); - expect( - screen - .getByRole("menuitemcheckbox", { name: "Plugin" }) - .getAttribute("aria-checked"), - ).toBe("false"); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); + // The explicit "All" row is gone; an empty selection carries that meaning. + expect(screen.queryByRole("menuitemcheckbox", { name: "All" })).toBeNull(); + for (const name of ["bb Official", "Included in plugin"]) { + expect( + screen + .getByRole("menuitemcheckbox", { name }) + .getAttribute("aria-checked"), + ).toBe("false"); + } + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), + ); expect(await screen.findByText("automations")).toBeTruthy(); expect( @@ -330,12 +329,15 @@ describe("SkillsOverview", () => { "automations is included with Automations (bb plugin)", ).textContent, ).toBe("Included"); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); - expect(screen.queryByText("automations")).toBeNull(); - expect(screen.getByText("official-skill")).toBeTruthy(); + expect(screen.queryByText("official-skill")).toBeNull(); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), + ); + expect(await screen.findByText("official-skill")).toBeTruthy(); + expect(screen.getByText("automations")).toBeTruthy(); }); - it("toggles BB official independently from Plugin", async () => { + it("toggles bb Official independently from Included in plugin", async () => { renderDom( { />, ); - fireEvent.pointerDown(screen.getByRole("button", { name: "bb official" })); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); + fireEvent.pointerDown(screen.getByRole("button", { name: "Type" })); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "bb official" }), + screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), ); + // One source selected narrows to that source alone. expect(await screen.findByText("automations")).toBeTruthy(); expect(screen.queryByText("official-skill")).toBeNull(); + + // Adding the second source widens the selection rather than replacing it. + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "bb Official" }), + ); + expect(await screen.findByText("official-skill")).toBeTruthy(); + expect(screen.getByText("automations")).toBeTruthy(); + + // Clearing both returns to the unfiltered default. + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), + ); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "bb Official" }), + ); + expect(await screen.findByText("official-skill")).toBeTruthy(); + expect(screen.getByText("automations")).toBeTruthy(); + // The open menu hides the trigger from the a11y tree, so close it first. + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.getByRole("button", { name: "Type" })).toBeTruthy(); }); it("uses filter-neutral copy when a Type selection removes every skill", async () => { @@ -388,9 +410,9 @@ describe("SkillsOverview", () => { />, ); - fireEvent.pointerDown(screen.getByRole("button", { name: "bb official" })); + fireEvent.pointerDown(screen.getByRole("button", { name: "Type" })); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "bb official" }), + screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), ); expect( diff --git a/packages/plugin-registry/r/button.json b/packages/plugin-registry/r/button.json index 8873d6e21..72cd72136 100644 --- a/packages/plugin-registry/r/button.json +++ b/packages/plugin-registry/r/button.json @@ -15,7 +15,7 @@ "files": [ { "path": "registry/components/ui/button.tsx", - "content": "/* shadcn/ui-derived */\nimport * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"../../lib/utils\";\nimport { CONTROL_HOVER_TRANSITION } from \"./motion.js\";\n\nconst buttonVariants = cva(\n `inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ${CONTROL_HOVER_TRANSITION} focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,\n {\n variants: {\n variant: {\n default:\n \"bg-foreground text-background hover:bg-foreground/90\",\n destructive:\n \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n outline:\n \"border border-input bg-transparent hover:bg-state-hover hover:text-foreground\",\n secondary:\n \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n ghost:\n \"hover:bg-state-hover hover:text-foreground aria-pressed:bg-state-active aria-pressed:text-foreground aria-pressed:hover:bg-state-active data-[state=open]:bg-state-active data-[state=open]:text-foreground data-[state=open]:hover:bg-state-active\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default: \"h-9 px-4 py-2\",\n sm: \"h-8 rounded-md px-3 text-xs\",\n lg: \"h-10 rounded-md px-8\",\n icon: \"h-9 w-9\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n },\n);\n\nexport interface ButtonProps\n extends\n Omit, \"title\">,\n VariantProps {\n asChild?: boolean;\n}\n\nconst Button = React.forwardRef(\n ({ className, variant, size, asChild = false, ...props }, ref) => {\n const Comp = asChild ? Slot : \"button\";\n return (\n \n );\n },\n);\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\n", + "content": "/* shadcn/ui-derived */\nimport * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"../../lib/utils\";\nimport { CONTROL_HOVER_TRANSITION } from \"./motion.js\";\n\nconst buttonVariants = cva(\n `inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ${CONTROL_HOVER_TRANSITION} focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,\n {\n variants: {\n variant: {\n default: \"bg-foreground text-background hover:bg-foreground/90\",\n destructive:\n \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n outline:\n \"border border-input bg-transparent hover:bg-state-hover hover:text-foreground\",\n secondary:\n \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n ghost:\n \"hover:bg-state-hover hover:text-foreground aria-pressed:bg-state-active aria-pressed:text-foreground aria-pressed:hover:bg-state-active data-[state=open]:bg-state-active data-[state=open]:text-foreground data-[state=open]:hover:bg-state-active\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default: \"h-9 px-4 py-2\",\n sm: \"h-8 rounded-md px-3 text-xs\",\n lg: \"h-10 rounded-md px-8\",\n icon: \"h-9 w-9\",\n },\n },\n compoundVariants: [\n // An icon button carries no resting fill. Hover, focus-visible, and\n // pressed/open states may still paint one; the resting state must not.\n // Filled variants (default/destructive/secondary) are an explicit opt-in\n // by the caller and keep their fill.\n {\n size: \"icon\",\n variant: [\"ghost\", \"outline\", \"link\"],\n class: \"bg-transparent\",\n },\n ],\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n },\n);\n\nexport interface ButtonProps\n extends\n Omit, \"title\">,\n VariantProps {\n asChild?: boolean;\n}\n\nconst Button = React.forwardRef(\n ({ className, variant, size, asChild = false, ...props }, ref) => {\n const Comp = asChild ? Slot : \"button\";\n return (\n \n );\n },\n);\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\n", "type": "registry:ui", "target": "components/ui/button.tsx" } diff --git a/packages/shared-ui/src/components/ui/button.tsx b/packages/shared-ui/src/components/ui/button.tsx index f0a80b393..b717a3768 100644 --- a/packages/shared-ui/src/components/ui/button.tsx +++ b/packages/shared-ui/src/components/ui/button.tsx @@ -11,8 +11,7 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-foreground text-background hover:bg-foreground/90", + default: "bg-foreground text-background hover:bg-foreground/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: @@ -30,6 +29,17 @@ const buttonVariants = cva( icon: "h-9 w-9", }, }, + compoundVariants: [ + // An icon button carries no resting fill. Hover, focus-visible, and + // pressed/open states may still paint one; the resting state must not. + // Filled variants (default/destructive/secondary) are an explicit opt-in + // by the caller and keep their fill. + { + size: "icon", + variant: ["ghost", "outline", "link"], + class: "bg-transparent", + }, + ], defaultVariants: { variant: "default", size: "default", diff --git a/packages/shared-ui/src/components/ui/resource-list.tsx b/packages/shared-ui/src/components/ui/resource-list.tsx index e51ce1fc7..e094c6f7e 100644 --- a/packages/shared-ui/src/components/ui/resource-list.tsx +++ b/packages/shared-ui/src/components/ui/resource-list.tsx @@ -12,6 +12,8 @@ export { ResourceCreateButton, type ResourceCreateMenuAction, type ResourceCreateTemplate, + type ResourceFilterGroup, + ResourceFilterMenu, ResourceMultiSelectMenu, type ResourceOption, ResourceOptionMenu, diff --git a/packages/shared-ui/src/components/ui/resource/collection.tsx b/packages/shared-ui/src/components/ui/resource/collection.tsx index fd228e9e4..43db4b34a 100644 --- a/packages/shared-ui/src/components/ui/resource/collection.tsx +++ b/packages/shared-ui/src/components/ui/resource/collection.tsx @@ -43,8 +43,13 @@ export function ResourceCollectionPage({ const activePanelId = `${id}-${activeMode}-panel`; return (
- {description} -
+ {/* Every band in the collection carries the same pr-1 scrollbar gutter as + the results below, so description, tabs, toolbar, and rows all share + one content width. */} +
+ {description} +
+
{modes.map((mode) => { const active = mode.id === activeMode; @@ -139,7 +144,9 @@ export function ResourceCollectionViewport({ className={cn("flex h-full min-h-0 flex-col gap-3", className)} data-resource-collection-viewport > - {toolbar ?
{toolbar}
: null} + {/* pr-1 matches the scroll viewport's scrollbar gutter below, so the + toolbar, results, and footer all end on the same content edge. */} + {toolbar ?
{toolbar}
: null} {footer ? (
{footer} @@ -302,7 +309,6 @@ export function ResourceOverviewPage({ searchPlaceholder={installed.searchPlaceholder} searchLabel={installed.searchLabel} onSearchChange={installed.onSearchChange} - containedControls controls={installed.controls} action={installed.action} /> @@ -551,7 +557,7 @@ export function ResourceTemplateBrowseCard({ label={`${actionLabel}: ${title}`} tooltipLabel={actionLabel} icon="MessageCirclePlus" - className="size-7 bg-surface-recessed-soft-solid hover:bg-state-active focus-visible:bg-state-active" + className="size-7 hover:bg-state-hover focus-visible:bg-state-hover" onClick={onUse} /> } diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 50940f10a..705aefa3e 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { Fragment, useState, type ReactNode } from "react"; import { Button } from "../button"; import { DropdownMenu, @@ -25,7 +25,6 @@ export function ResourceToolbar({ searchLabel, onSearchChange, controls, - containedControls = false, controlsClassName, action, }: { @@ -34,7 +33,6 @@ export function ResourceToolbar({ searchLabel?: string; onSearchChange: (value: string) => void; controls?: ReactNode; - containedControls?: boolean; controlsClassName?: string; action?: ReactNode; }) { @@ -57,9 +55,7 @@ export function ResourceToolbar({ {controls ? (
button]:size-7 [&>button]:rounded-sm", + "flex shrink-0 items-center gap-1.5", controlsClassName, )} > @@ -72,11 +68,9 @@ export function ResourceToolbar({ } export function ResourceTabDescription({ children }: { children: ReactNode }) { - return ( -

- {children} -

- ); + // No inline inset or measure cap: the description shares the collection's + // content width with the tabs, toolbar, and results beneath it. + return

{children}

; } export interface ResourceOption { @@ -123,15 +117,47 @@ function ResourceOptionContent({ ); } +/** + * The engaged treatment shared by open and selected toolbar menu triggers. + * + * This is the app's one selection surface — the same `bg-state-active` + + * `text-foreground` pair used by selected sidebar rows, active tab pills, and + * focused split panes (see CONTEXT_SELECTION_SURFACE_CLASS in the app). Keeping + * toolbar filters on it means "selected" reads identically everywhere instead + * of this surface inventing its own language. + */ +const RESOURCE_MENU_TRIGGER_ENGAGED_CLASS = + "bg-state-active text-foreground hover:bg-state-active"; + +/** + * A toolbar key is a sibling of the search input beside it: same 32px box, + * same `--input` border, same radius, on the canvas surface. That keeps the + * row reading as one set of controls instead of a field plus a floating chip + * cluster. `--background` is `var(--canvas)`, so custom palettes get their own + * paper colour rather than a hardcoded white. + */ +const RESOURCE_MENU_TRIGGER_RESTING_CLASS = "border border-input bg-background"; + +/** + * Engagement is driven by React state, not `data-[state=open]`. + * + * These triggers compose `TooltipTrigger asChild > DropdownMenuTrigger asChild + * > Button`, and the tooltip's own `data-state` lands on the same element as + * the menu's — so the button reads `data-state="closed"` even while its menu is + * open. Any `data-[state=open]:` styling here is silently dead. Menus therefore + * report open state through `onOpenChange` and pass it in as `open`. + */ function ResourceMenuTrigger({ label, icon, active = false, + open = false, tooltip = label, }: { label: string; icon: IconName; active?: boolean; + open?: boolean; tooltip?: ReactNode; }) { return ( @@ -141,11 +167,12 @@ function ResourceMenuTrigger({
+ {promptFooter} ) : ( ); - const promptFooter = ( -
-
- {!personalProject ? ( - - } - className="shrink-0" - muted - /> - ) : null} - - } - muted - /> -
- {editing ? ( - { - const next = permissionModes.find((mode) => mode === value); - if (next !== undefined) setPermissionMode(next); - }} - className="h-6 shrink-0" - /> - ) : ( - - )} -
- ); - return (
{editing ? ( @@ -876,7 +874,6 @@ function AgentAutomationDefinition({ Couldn't load editing options. {optionsError}

) : null} - {editing ? promptFooter : null}
); } diff --git a/plugins/automations/overview-view.tsx b/plugins/automations/overview-view.tsx index 2a848dcc4..7f01a6dca 100644 --- a/plugins/automations/overview-view.tsx +++ b/plugins/automations/overview-view.tsx @@ -14,7 +14,7 @@ import { automationIconName, automationScheduleLabel, } from "./detail-view.js"; -import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { Icon } from "@bb/shared-ui/icon"; import { ResourcePagination, useResourcePagination, @@ -28,8 +28,8 @@ import { ResourceCreateButton, ResourceListPanel, ResourceListState, + ResourceFilterMenu, ResourceMeta, - ResourceMultiSelectMenu, ResourceRow, ResourceRowDetailChevron, ResourceSortMenu, @@ -49,15 +49,9 @@ import { AutomationMetadataItem } from "./metadata.js"; const PERSONAL_PROJECT_ID = "proj_personal"; -function automationMenuIcon(name: IconName) { - return ( - - ); -} - const AUTOMATION_STATUS_FILTER_OPTIONS = [ - { id: "active", label: "Active", leading: automationMenuIcon("Play") }, - { id: "paused", label: "Paused", leading: automationMenuIcon("Pause") }, + { id: "active", label: "Active" }, + { id: "paused", label: "Paused" }, ] as const; export const CREATE_AUTOMATION_PROMPT = "Create a new bb automation to "; @@ -323,11 +317,7 @@ export function AutomationOverviewView({ automationProjectLabel(entry.project), ); } - return [...options].map(([id, label]) => ({ - id, - label, - leading: automationMenuIcon("Folder"), - })); + return [...options].map(([id, label]) => ({ id, label })); }, [entries]); useEffect(() => { setProjectFilters((current) => @@ -497,25 +487,28 @@ export function AutomationOverviewView({ onSearchChange={setQuery} controls={ <> - - setProjectFilters(values as AutomationProjectFilter[]) - } - /> - - setStatusFilters(values as AutomationStatusFilter[]) - } + groups={[ + { + id: "projects", + label: "Projects", + options: projectOptions, + selectedValues: projectFilters, + onChange: (values) => + setProjectFilters( + values as AutomationProjectFilter[], + ), + }, + { + id: "status", + label: "Status", + options: AUTOMATION_STATUS_FILTER_OPTIONS, + selectedValues: statusFilters, + onChange: (values) => + setStatusFilters(values as AutomationStatusFilter[]), + }, + ]} />