From ecbf0524ef2e15bf50480eff6584a948ce76cda9 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 05:05:49 -0700 Subject: [PATCH 1/4] feat(app): add sidebar item visibility controls --- .../app/src/components/sidebar/AppSidebar.tsx | 9 +- .../src/components/sidebar/ProjectList.tsx | 169 ++++++++++------- .../SidebarDisplayOptionsMenu.test.tsx | 50 ++++- .../sidebar/SidebarThreadSearchPanel.test.tsx | 26 +++ .../sidebarTopRegionItemPreferences.test.ts | 171 ++++++++++++++++++ .../sidebarTopRegionItemPreferences.ts | 129 +++++++++++++ 6 files changed, 487 insertions(+), 67 deletions(-) create mode 100644 apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.test.ts create mode 100644 apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.ts diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx index ff9f55317f..ba7534e6dc 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -75,6 +75,10 @@ import { sidebarTopLevelSectionOrderAtom, type SidebarTopLevelSectionId, } from "./sidebarTopLevelSectionPreferences"; +import { + sidebarExtensionsVisibleAtom, + sidebarNewThreadVisibleAtom, +} from "./sidebarTopRegionItemPreferences"; const BUG_REPORT_NEW_ISSUE_URL = "https://github.com/get-bb/bb/issues/new"; const SIDEBAR_FOOTER_ACTION_CLASS = cn( @@ -194,6 +198,8 @@ export function AppSidebar({ const hiddenTopLevelSectionIds = useAtomValue( hiddenSidebarTopLevelSectionIdsAtom, ); + const showNewThread = useAtomValue(sidebarNewThreadVisibleAtom); + const showExtensions = useAtomValue(sidebarExtensionsVisibleAtom); const pluginNavPanels = usePluginNavPanelChrome(); const openSidebarForThreadSearch = useCallback(() => { @@ -430,6 +436,7 @@ export function AppSidebar({ newThreadSplit={newThreadSplit} onNewChat={handleNewChat} onSplit={isCompactViewport ? undefined : handleSplit} + showNewThread={showNewThread} threadSearch={{ activeDescendantId: threadSearch.activeDescendantId, inputRef: threadSearch.inputRef, @@ -440,7 +447,7 @@ export function AppSidebar({ query: threadSearch.query, }} /> - {toolsRoutePath ? ( + {toolsRoutePath && showExtensions ? ( void; onSplit?: () => void; + showNewThread?: boolean; threadSearch?: SidebarThreadSearchInputController; } @@ -729,6 +734,12 @@ export function SidebarDisplayOptionsMenu({ const [hiddenTopLevelSectionIds, setHiddenTopLevelSectionIds] = useAtom( hiddenSidebarTopLevelSectionIdsAtom, ); + const [showNewThread, setShowNewThread] = useAtom( + sidebarNewThreadVisibleAtom, + ); + const [showExtensions, setShowExtensions] = useAtom( + sidebarExtensionsVisibleAtom, + ); const [lifecycleSelection, setLifecycleSelection] = useAtom( sidebarThreadLifecycleSelectionAtom, ); @@ -905,6 +916,26 @@ export function SidebarDisplayOptionsMenu({ ); })} + + + Sidebar items + + + event.preventDefault()} + onCheckedChange={(shown) => setShowNewThread(shown === true)} + > + New thread + + event.preventDefault()} + onCheckedChange={(shown) => setShowExtensions(shown === true)} + > + Extensions + + ); @@ -995,6 +1026,7 @@ export function ProjectListActionButtons({ newThreadSplit, onNewChat, onSplit, + showNewThread = true, threadSearch, }: ProjectListActionButtonsProps) { const isNewChatDisabled = !onNewChat; @@ -1050,74 +1082,81 @@ export function ProjectListActionButtons({ ) : ( -
-
- - {onSplit ? ( -
{ + if (event.metaKey || event.ctrlKey) { + newThreadSplit?.openInSplit(); + return; + } + onNewChat?.(); + }} + disabled={isNewChatDisabled} + aria-label={ + newThreadShortcut + ? `New thread (${newThreadShortcut.label})` + : "New thread" + } + aria-keyshortcuts={newThreadShortcut?.ariaKeyshortcuts} > - -
- ) : null} -
+ New thread + {newThreadSplitIndicator.miniMap ? ( + + ) : null} + + + + {onSplit ? ( +
+ +
+ ) : null} +
+ ) : null} {threadSearch ? ( diff --git a/apps/app/src/components/sidebar/SidebarDisplayOptionsMenu.test.tsx b/apps/app/src/components/sidebar/SidebarDisplayOptionsMenu.test.tsx index 219fa2ce75..e61ebb4bce 100644 --- a/apps/app/src/components/sidebar/SidebarDisplayOptionsMenu.test.tsx +++ b/apps/app/src/components/sidebar/SidebarDisplayOptionsMenu.test.tsx @@ -18,6 +18,10 @@ import { sidebarTopLevelSectionOrderAtom, } from "./sidebarTopLevelSectionPreferences"; import { sidebarThreadLifecycleSelectionAtom } from "./sidebarThreadLifecycle"; +import { + sidebarExtensionsVisibleAtom, + sidebarNewThreadVisibleAtom, +} from "./sidebarTopRegionItemPreferences"; vi.mock("@/lib/sdk", () => ({ sdk: { @@ -49,6 +53,8 @@ function renderMenu({ "thread-list", ]); store.set(hiddenSidebarTopLevelSectionIdsAtom, []); + store.set(sidebarNewThreadVisibleAtom, true); + store.set(sidebarExtensionsVisibleAtom, true); render( @@ -96,7 +102,13 @@ describe("SidebarDisplayOptionsMenu lifecycle filter", () => { .getAllByRole("group") .map((group) => group.getAttribute("aria-label")) .filter(Boolean), - ).toEqual(["Organize", "Sort by", "Show", "Sidebar sections"]); + ).toEqual([ + "Organize", + "Sort by", + "Show", + "Sidebar sections", + "Sidebar items", + ]); expect(getShowItem("Active").textContent).toContain("8"); expect(getShowItem("Drafts").textContent).toContain("4"); expect(getShowItem("Archived").textContent).toContain("17"); @@ -155,6 +167,42 @@ describe("SidebarDisplayOptionsMenu lifecycle filter", () => { }); }); +describe("SidebarDisplayOptionsMenu fixed sidebar items", () => { + it("renders the item toggles last, defaults both on, and keeps the menu open while toggling", async () => { + const store = renderMenu(); + openMenu(); + + await screen.findByRole("group", { name: "Sidebar items" }); + expect( + screen + .getAllByRole("group") + .map((group) => group.getAttribute("aria-label")) + .filter(Boolean), + ).toEqual([ + "Organize", + "Sort by", + "Show", + "Sidebar sections", + "Sidebar items", + ]); + + const group = screen.getByRole("group", { name: "Sidebar items" }); + const newThread = within(group).getByRole("menuitemcheckbox", { + name: "New thread", + }); + const extensions = within(group).getByRole("menuitemcheckbox", { + name: "Extensions", + }); + expect(newThread.getAttribute("data-state")).toBe("checked"); + expect(extensions.getAttribute("data-state")).toBe("checked"); + + fireEvent.click(extensions); + expect(store.get(sidebarExtensionsVisibleAtom)).toBe(false); + expect(store.get(sidebarNewThreadVisibleAtom)).toBe(true); + expect(screen.getByRole("group", { name: "Sidebar items" })).toBeDefined(); + }); +}); + describe("SidebarDisplayOptionsMenu top-level sections", () => { it("lists all sections in order and never offers a Thread list hide control", async () => { renderMenu(); diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx index 7e9ccd7306..197576c7cf 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx @@ -437,6 +437,32 @@ describe("ProjectListActionButtons", () => { expect(screen.queryByRole("button", { name: "Split" })).toBeNull(); }); + it("removes only the New thread row while preserving the search escape hatch", () => { + const inputRef = createRef(); + + render( + , + ); + + expect(screen.queryByRole("button", { name: /^New thread/ })).toBeNull(); + expect(screen.queryByRole("button", { name: "Split" })).toBeNull(); + expect( + screen.getByRole("button", { name: /^Search threads/ }), + ).toBeDefined(); + }); + it("shows the compose pane position when New thread is open in a split", () => { const store = createStore(); store.set(splitLayoutAtom, { diff --git a/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.test.ts b/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.test.ts new file mode 100644 index 0000000000..394dd5741e --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment jsdom + +import { createStore } from "jotai"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const LEGACY_EXTENSIONS_NAV_ROW_KEY = "__builtin__/tools"; +const LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY = + "bb.sidebar.hiddenPluginPanels"; +const SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY = "bb.sidebar.extensionsVisible"; + +class MemoryStorage { + readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +function seedLegacyHiddenKeys( + storage: MemoryStorage, + keys: readonly unknown[], +): void { + storage.setItem( + LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY, + JSON.stringify(keys), + ); +} + +function runPhaseThreePluginPageMigration(storage: MemoryStorage): void { + const value = storage.getItem(LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY); + if (value === null) return; + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) return; + storage.setItem( + LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY, + JSON.stringify( + parsed.filter( + (key) => typeof key === "string" && key.startsWith("__builtin__/"), + ), + ), + ); +} + +afterEach(() => { + window.localStorage.clear(); + vi.resetModules(); +}); + +describe("top-region sidebar item preferences", () => { + it("defaults both fixed rows on for a fresh install", async () => { + const { sidebarExtensionsVisibleAtom, sidebarNewThreadVisibleAtom } = + await import("./sidebarTopRegionItemPreferences"); + const store = createStore(); + + expect(store.get(sidebarNewThreadVisibleAtom)).toBe(true); + expect(store.get(sidebarExtensionsVisibleAtom)).toBe(true); + }); + + it("synchronously keeps legacy-hidden Extensions off and clears only its key", async () => { + window.localStorage.setItem( + LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY, + JSON.stringify([ + "docs/main", + LEGACY_EXTENSIONS_NAV_ROW_KEY, + "github/main", + ]), + ); + + const { sidebarExtensionsVisibleAtom } = + await import("./sidebarTopRegionItemPreferences"); + const store = createStore(); + + expect(store.get(sidebarExtensionsVisibleAtom)).toBe(false); + expect( + window.localStorage.getItem(SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY), + ).toBe("false"); + expect( + JSON.parse( + window.localStorage.getItem( + LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY, + ) ?? "[]", + ), + ).toEqual(["docs/main", "github/main"]); + }); + + it.each(["toggles-first", "plugins-first"] as const)( + "composes with the plugin-page migration when %s", + async (order) => { + const { migrateLegacyHiddenExtensions } = + await import("./sidebarTopRegionItemPreferences"); + const storage = new MemoryStorage(); + seedLegacyHiddenKeys(storage, [ + "docs/main", + LEGACY_EXTENSIONS_NAV_ROW_KEY, + "github/main", + ]); + + if (order === "toggles-first") { + expect(migrateLegacyHiddenExtensions(storage)).toBe(false); + expect( + JSON.parse( + storage.getItem(LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY) ?? + "[]", + ), + ).toEqual(["docs/main", "github/main"]); + runPhaseThreePluginPageMigration(storage); + } else { + runPhaseThreePluginPageMigration(storage); + expect( + JSON.parse( + storage.getItem(LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY) ?? + "[]", + ), + ).toEqual([LEGACY_EXTENSIONS_NAV_ROW_KEY]); + expect(migrateLegacyHiddenExtensions(storage)).toBe(false); + } + + expect( + JSON.parse( + storage.getItem(LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY) ?? "[]", + ), + ).toEqual([]); + expect(storage.getItem(SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY)).toBe( + "false", + ); + }, + ); + + it("is idempotent and preserves an existing explicit preference", async () => { + const { migrateLegacyHiddenExtensions } = + await import("./sidebarTopRegionItemPreferences"); + const storage = new MemoryStorage(); + storage.setItem(SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY, "true"); + seedLegacyHiddenKeys(storage, [ + LEGACY_EXTENSIONS_NAV_ROW_KEY, + "docs/main", + LEGACY_EXTENSIONS_NAV_ROW_KEY, + ]); + + expect(migrateLegacyHiddenExtensions(storage)).toBe(true); + expect(migrateLegacyHiddenExtensions(storage)).toBe(true); + expect(storage.getItem(SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY)).toBe( + "true", + ); + expect( + JSON.parse( + storage.getItem(LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY) ?? "[]", + ), + ).toEqual(["docs/main"]); + }); + + it("leaves malformed legacy storage untouched instead of deleting unowned data", async () => { + const { migrateLegacyHiddenExtensions } = + await import("./sidebarTopRegionItemPreferences"); + const storage = new MemoryStorage(); + storage.setItem( + LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY, + "not valid json", + ); + + expect(migrateLegacyHiddenExtensions(storage)).toBe(true); + expect(storage.getItem(LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY)).toBe( + "not valid json", + ); + expect(storage.getItem(SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.ts b/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.ts new file mode 100644 index 0000000000..9b761f5dfc --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.ts @@ -0,0 +1,129 @@ +import { atomWithStorage } from "jotai/utils"; +import { + createJsonLocalStorage, + type SyncStorage, +} from "@/lib/browser-storage"; + +export const SIDEBAR_NEW_THREAD_VISIBLE_STORAGE_KEY = + "bb.sidebar.newThreadVisible"; +export const SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY = + "bb.sidebar.extensionsVisible"; +export const LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY = + "bb.sidebar.hiddenPluginPanels"; +export const LEGACY_EXTENSIONS_NAV_ROW_KEY = "__builtin__/tools"; + +interface SidebarItemMigrationStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +function readStoredBoolean( + storage: SidebarItemMigrationStorage, + key: string, +): boolean | null { + const value = storage.getItem(key); + if (value === null) return null; + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "boolean" ? parsed : null; + } catch { + return null; + } +} + +function readLegacyHiddenKeys( + storage: SidebarItemMigrationStorage, +): unknown[] | null { + const value = storage.getItem(LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY); + if (value === null) return null; + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Migrates only the host-owned Extensions row from the legacy plugin hide set. + * + * The migration deliberately has no completion marker. Removing the owned key + * is the marker, which makes partial reruns safe and lets this cleanup compose + * with the plugin-page migration whichever one observes the shared array first. + */ +export function migrateLegacyHiddenExtensions( + storage: SidebarItemMigrationStorage, +): boolean { + const storedPreference = readStoredBoolean( + storage, + SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY, + ); + const legacyHiddenKeys = readLegacyHiddenKeys(storage); + if ( + legacyHiddenKeys === null || + !legacyHiddenKeys.includes(LEGACY_EXTENSIONS_NAV_ROW_KEY) + ) { + return storedPreference ?? true; + } + + const extensionsVisible = storedPreference ?? false; + // Write the replacement preference before consuming the legacy key so a + // partially completed rerun cannot reset a previously hidden Extensions row. + if (storedPreference === null) { + storage.setItem( + SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY, + JSON.stringify(extensionsVisible), + ); + } + storage.setItem( + LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY, + JSON.stringify( + legacyHiddenKeys.filter((key) => key !== LEGACY_EXTENSIONS_NAV_ROW_KEY), + ), + ); + return extensionsVisible; +} + +const jsonBooleanStorage = createJsonLocalStorage(); +const normalizedBooleanStorage: SyncStorage = { + getItem: (key, initialValue) => { + const value = jsonBooleanStorage.getItem(key, initialValue); + return typeof value === "boolean" ? value : initialValue; + }, + setItem: (key, value) => { + jsonBooleanStorage.setItem(key, value); + }, + removeItem: (key) => { + jsonBooleanStorage.removeItem(key); + }, + subscribe: (key, callback, initialValue) => + jsonBooleanStorage.subscribe?.( + key, + (value) => callback(typeof value === "boolean" ? value : initialValue), + initialValue, + ), +}; + +const extensionsVisibilityStorage: SyncStorage = { + getItem: (_key, _initialValue) => { + if (typeof window === "undefined") return true; + return migrateLegacyHiddenExtensions(window.localStorage); + }, + setItem: normalizedBooleanStorage.setItem, + removeItem: normalizedBooleanStorage.removeItem, + subscribe: normalizedBooleanStorage.subscribe, +}; + +export const sidebarNewThreadVisibleAtom = atomWithStorage( + SIDEBAR_NEW_THREAD_VISIBLE_STORAGE_KEY, + true, + normalizedBooleanStorage, + { getOnInit: true }, +); + +export const sidebarExtensionsVisibleAtom = atomWithStorage( + SIDEBAR_EXTENSIONS_VISIBLE_STORAGE_KEY, + true, + extensionsVisibilityStorage, + { getOnInit: true }, +); From 4f1176057e5418cfc460718f30c63ca3b38aee83 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 27 Aug 2026 15:46:55 -0700 Subject: [PATCH 2/4] Regenerate plugin icon registry --- packages/plugin-registry/r/icon.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-registry/r/icon.json b/packages/plugin-registry/r/icon.json index d13777e9ca..0c159fcfc3 100644 --- a/packages/plugin-registry/r/icon.json +++ b/packages/plugin-registry/r/icon.json @@ -16,7 +16,7 @@ "files": [ { "path": "registry/components/ui/icon.tsx", - "content": "import type { CSSProperties } from \"react\";\nimport { HugeiconsIcon, type IconSvgElement } from \"@hugeicons/react\";\nimport {\n Alert02Icon,\n AlertCircleIcon,\n Archive03Icon,\n ArrowDown01Icon,\n ArrowLeft01Icon,\n ArrowRight01Icon,\n BotIcon,\n BubbleChatAddIcon,\n BubbleChatIcon,\n Bug01Icon,\n Cancel01Icon,\n CancelCircleIcon,\n CheckListIcon,\n CheckmarkCircle02Icon,\n CircleIcon,\n ComputerTerminal01Icon,\n Copy01Icon,\n DashedLineCircleIcon,\n Delete02Icon,\n Download01Icon,\n Edit02Icon,\n FolderAddIcon,\n FolderExportIcon,\n FolderGitTwoIcon,\n FolderIcon,\n HelpCircleIcon,\n InformationCircleIcon,\n Loading03Icon,\n MessageQuestionIcon,\n MoreHorizontalIcon,\n Search01Icon,\n Settings01Icon,\n SidebarLeftIcon,\n SlidersHorizontalIcon,\n SourceCodeIcon,\n Target02Icon,\n Tick02Icon,\n ToolboxIcon,\n ToolCaseIcon,\n UserAdd01Icon,\n WorkflowCircle03Icon,\n ZapIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { useSyncExternalStore } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n EXTENDED_ICON_NAMES,\n type ExtendedIconName,\n getExtendedIcons,\n subscribeExtendedIcons,\n} from \"./icon-registry\";\n\n// Custom \"new section\" glyph: the set's ListView rows with the middle and\n// bottom rows shortened so the plus owns the lower-right quadrant, matching\n// FolderAdd's non-overlapping plus placement (same plus geometry). Hugeicons\n// has no list-with-plus variant that keeps the ListView row shape, so this\n// inlines the artwork in the same element format the set uses.\nconst SectionAddStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M2 3.4C2 2.24173 2.24173 2 3.4 2H20.6C21.7583 2 22 2.24173 22 3.4V4.6C22 5.75827 21.7583 6 20.6 6H3.4C2.24173 6 2 5.75827 2 4.6V3.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M2 11.4C2 10.2417 2.24173 10 3.4 10H10.6C11.7583 10 12 10.2417 12 11.4V12.6C12 13.7583 11.7583 14 10.6 14H3.4C2.24173 14 2 13.7583 2 12.6V11.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M2 19.4C2 18.2417 2.24173 18 3.4 18H10.6C11.7583 18 12 18.2417 12 19.4V20.6C12 21.7583 11.7583 22 10.6 22H3.4C2.24173 22 2 21.7583 2 20.6V19.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18 13V21M22 17H14\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n];\n\n// Core map: the glyphs the app shell renders before or at first paint\n// (sidebar rows and controls, header, toasts, menus, plugin chrome). Keep it\n// small: everything here is on the boot path of every page load. Any other\n// named icon belongs in `./icon-extended`, which loads with the first route\n// that needs it.\nconst CORE_ICON_MAP = {\n AlertCircle: AlertCircleIcon,\n AlertTriangle: Alert02Icon,\n Archive: Archive03Icon,\n Bot: BotIcon,\n Bug: Bug01Icon,\n Check: Tick02Icon,\n ChevronDown: ArrowDown01Icon,\n ChevronLeft: ArrowLeft01Icon,\n ChevronRight: ArrowRight01Icon,\n Circle: CircleIcon,\n CircleCheck: CheckmarkCircle02Icon,\n CircleQuestion: HelpCircleIcon,\n CircleX: CancelCircleIcon,\n ClosePluginPane: Cancel01Icon,\n CloseThreadPane: Cancel01Icon,\n Code: SourceCodeIcon,\n ComputerTerminal01: ComputerTerminal01Icon,\n Copy: Copy01Icon,\n Download: Download01Icon,\n Edit: Edit02Icon,\n Folder: FolderIcon,\n FolderExport: FolderExportIcon,\n FolderGit: FolderGitTwoIcon,\n FolderPlus: FolderAddIcon,\n Info: InformationCircleIcon,\n ListTodo: CheckListIcon,\n Loading: Loading03Icon,\n MessageQuestion: MessageQuestionIcon,\n MessageCirclePlus: BubbleChatAddIcon,\n MessageSquarePlus: BubbleChatAddIcon,\n MessageSquare: BubbleChatIcon,\n MoreHorizontal: MoreHorizontalIcon,\n PanelLeft: SidebarLeftIcon,\n Search: Search01Icon,\n SectionAdd: SectionAddStrokeRoundedIcon,\n Settings: Settings01Icon,\n SlidersHorizontal: SlidersHorizontalIcon,\n Spinner: DashedLineCircleIcon,\n Target: Target02Icon,\n Terminal: ComputerTerminal01Icon,\n Toolbox: ToolboxIcon,\n ToolCase: ToolCaseIcon,\n Trash2: Delete02Icon,\n UserRoundPlus: UserAdd01Icon,\n Workflow: WorkflowCircle03Icon,\n X: Cancel01Icon,\n Zap: ZapIcon,\n} as const satisfies Record;\n\ntype CoreIconName = keyof typeof CORE_ICON_MAP;\n\nexport type IconName = CoreIconName | ExtendedIconName;\n\n// Object.keys loses the literal key type; the map's own keys are the source\n// of truth for CoreIconName, so this is the one place the cast is exact.\nconst CORE_ICON_NAMES = Object.keys(CORE_ICON_MAP) as readonly CoreIconName[];\n\n/** Every renderable icon name (core and extended), without loading artwork. */\nexport const ICON_NAMES: readonly IconName[] = [\n ...CORE_ICON_NAMES,\n ...EXTENDED_ICON_NAMES,\n];\n\n// Widened view of the core map so a union-typed name can be looked up\n// without a cast; extended names simply miss.\nconst CORE_ICON_LOOKUP: Readonly> =\n CORE_ICON_MAP;\n\nlet extendedIconsLoad: Promise | null = null;\n\n/**\n * Loads the extended glyph registry. Idempotent; a failed load (for example an\n * offline chunk fetch) is retried on the next call.\n */\nexport function preloadExtendedIcons(): Promise {\n if (getExtendedIcons() !== null) return Promise.resolve();\n extendedIconsLoad ??= import(\"./icon-extended\").then(\n () => undefined,\n (error: unknown) => {\n extendedIconsLoad = null;\n throw error;\n },\n );\n return extendedIconsLoad;\n}\n\nconst EMPTY_ICON: IconSvgElement = [];\n\nexport interface IconProps {\n name: IconName;\n className?: string;\n /** Inline style for data-driven accents (a bridge's per-theme tint). */\n style?: CSSProperties;\n \"aria-hidden\"?: boolean | \"true\" | \"false\";\n \"aria-label\"?: string;\n}\n\nexport function Icon({\n name,\n className,\n style,\n \"aria-hidden\": ariaHidden,\n \"aria-label\": ariaLabel,\n}: IconProps) {\n const coreIcon = CORE_ICON_LOOKUP[name];\n if (coreIcon !== undefined) {\n return (\n \n );\n }\n return (\n \n );\n}\n\n/**\n * Renders an extended-registry glyph. Until the registry has loaded it renders\n * the same-size empty svg (no layout shift), kicks off the load, and\n * re-renders once the artwork is registered.\n */\nfunction ExtendedIcon({\n name,\n className,\n style,\n \"aria-hidden\": ariaHidden,\n \"aria-label\": ariaLabel,\n}: IconProps) {\n // Widened like CORE_ICON_LOOKUP so the union-typed name needs no cast.\n const extendedIcons: Readonly<\n Record\n > | null = useSyncExternalStore(\n subscribeExtendedIcons,\n getExtendedIcons,\n getExtendedIcons,\n );\n const icon = extendedIcons?.[name];\n if (icon === undefined) {\n // Fire-and-forget: the store notifies subscribers when it lands, and\n // preloadExtendedIcons handles the retry on failure.\n void preloadExtendedIcons().catch(() => undefined);\n }\n return (\n \n );\n}\n", + "content": "import type { CSSProperties } from \"react\";\nimport { HugeiconsIcon, type IconSvgElement } from \"@hugeicons/react\";\nimport {\n Alert02Icon,\n AlertCircleIcon,\n Archive03Icon,\n ArrowDown01Icon,\n ArrowLeft01Icon,\n ArrowRight01Icon,\n BotIcon,\n BubbleChatAddIcon,\n BubbleChatIcon,\n Bug01Icon,\n Cancel01Icon,\n CancelCircleIcon,\n CheckListIcon,\n CheckmarkCircle02Icon,\n CircleIcon,\n ComputerTerminal01Icon,\n Copy01Icon,\n DashedLineCircleIcon,\n Delete02Icon,\n Download01Icon,\n Edit02Icon,\n FilterIcon,\n FolderAddIcon,\n FolderExportIcon,\n FolderGitTwoIcon,\n FolderIcon,\n HelpCircleIcon,\n InformationCircleIcon,\n Loading03Icon,\n MessageQuestionIcon,\n MoreHorizontalIcon,\n Search01Icon,\n Settings01Icon,\n SidebarLeftIcon,\n SlidersHorizontalIcon,\n SourceCodeIcon,\n Target02Icon,\n Tick02Icon,\n ToolboxIcon,\n ToolCaseIcon,\n UserAdd01Icon,\n WorkflowCircle03Icon,\n ZapIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { useSyncExternalStore } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n EXTENDED_ICON_NAMES,\n type ExtendedIconName,\n getExtendedIcons,\n subscribeExtendedIcons,\n} from \"./icon-registry\";\n\n// Custom \"new section\" glyph: the set's ListView rows with the middle and\n// bottom rows shortened so the plus owns the lower-right quadrant, matching\n// FolderAdd's non-overlapping plus placement (same plus geometry). Hugeicons\n// has no list-with-plus variant that keeps the ListView row shape, so this\n// inlines the artwork in the same element format the set uses.\nconst SectionAddStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M2 3.4C2 2.24173 2.24173 2 3.4 2H20.6C21.7583 2 22 2.24173 22 3.4V4.6C22 5.75827 21.7583 6 20.6 6H3.4C2.24173 6 2 5.75827 2 4.6V3.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M2 11.4C2 10.2417 2.24173 10 3.4 10H10.6C11.7583 10 12 10.2417 12 11.4V12.6C12 13.7583 11.7583 14 10.6 14H3.4C2.24173 14 2 13.7583 2 12.6V11.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M2 19.4C2 18.2417 2.24173 18 3.4 18H10.6C11.7583 18 12 18.2417 12 19.4V20.6C12 21.7583 11.7583 22 10.6 22H3.4C2.24173 22 2 21.7583 2 20.6V19.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18 13V21M22 17H14\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n];\n\n// Core map: the glyphs the app shell renders before or at first paint\n// (sidebar rows and controls, header, toasts, menus, plugin chrome). Keep it\n// small: everything here is on the boot path of every page load. Any other\n// named icon belongs in `./icon-extended`, which loads with the first route\n// that needs it.\nconst CORE_ICON_MAP = {\n AlertCircle: AlertCircleIcon,\n AlertTriangle: Alert02Icon,\n Archive: Archive03Icon,\n Bot: BotIcon,\n Bug: Bug01Icon,\n Check: Tick02Icon,\n ChevronDown: ArrowDown01Icon,\n ChevronLeft: ArrowLeft01Icon,\n ChevronRight: ArrowRight01Icon,\n Circle: CircleIcon,\n CircleCheck: CheckmarkCircle02Icon,\n CircleQuestion: HelpCircleIcon,\n CircleX: CancelCircleIcon,\n ClosePluginPane: Cancel01Icon,\n CloseThreadPane: Cancel01Icon,\n Code: SourceCodeIcon,\n ComputerTerminal01: ComputerTerminal01Icon,\n Copy: Copy01Icon,\n Download: Download01Icon,\n Edit: Edit02Icon,\n Filter: FilterIcon,\n Folder: FolderIcon,\n FolderExport: FolderExportIcon,\n FolderGit: FolderGitTwoIcon,\n FolderPlus: FolderAddIcon,\n Info: InformationCircleIcon,\n ListTodo: CheckListIcon,\n Loading: Loading03Icon,\n MessageQuestion: MessageQuestionIcon,\n MessageCirclePlus: BubbleChatAddIcon,\n MessageSquarePlus: BubbleChatAddIcon,\n MessageSquare: BubbleChatIcon,\n MoreHorizontal: MoreHorizontalIcon,\n PanelLeft: SidebarLeftIcon,\n Search: Search01Icon,\n SectionAdd: SectionAddStrokeRoundedIcon,\n Settings: Settings01Icon,\n SlidersHorizontal: SlidersHorizontalIcon,\n Spinner: DashedLineCircleIcon,\n Target: Target02Icon,\n Terminal: ComputerTerminal01Icon,\n Toolbox: ToolboxIcon,\n ToolCase: ToolCaseIcon,\n Trash2: Delete02Icon,\n UserRoundPlus: UserAdd01Icon,\n Workflow: WorkflowCircle03Icon,\n X: Cancel01Icon,\n Zap: ZapIcon,\n} as const satisfies Record;\n\ntype CoreIconName = keyof typeof CORE_ICON_MAP;\n\nexport type IconName = CoreIconName | ExtendedIconName;\n\n// Object.keys loses the literal key type; the map's own keys are the source\n// of truth for CoreIconName, so this is the one place the cast is exact.\nconst CORE_ICON_NAMES = Object.keys(CORE_ICON_MAP) as readonly CoreIconName[];\n\n/** Every renderable icon name (core and extended), without loading artwork. */\nexport const ICON_NAMES: readonly IconName[] = [\n ...CORE_ICON_NAMES,\n ...EXTENDED_ICON_NAMES,\n];\n\n// Widened view of the core map so a union-typed name can be looked up\n// without a cast; extended names simply miss.\nconst CORE_ICON_LOOKUP: Readonly> =\n CORE_ICON_MAP;\n\nlet extendedIconsLoad: Promise | null = null;\n\n/**\n * Loads the extended glyph registry. Idempotent; a failed load (for example an\n * offline chunk fetch) is retried on the next call.\n */\nexport function preloadExtendedIcons(): Promise {\n if (getExtendedIcons() !== null) return Promise.resolve();\n extendedIconsLoad ??= import(\"./icon-extended\").then(\n () => undefined,\n (error: unknown) => {\n extendedIconsLoad = null;\n throw error;\n },\n );\n return extendedIconsLoad;\n}\n\nconst EMPTY_ICON: IconSvgElement = [];\n\nexport interface IconProps {\n name: IconName;\n className?: string;\n /** Inline style for data-driven accents (a bridge's per-theme tint). */\n style?: CSSProperties;\n \"aria-hidden\"?: boolean | \"true\" | \"false\";\n \"aria-label\"?: string;\n}\n\nexport function Icon({\n name,\n className,\n style,\n \"aria-hidden\": ariaHidden,\n \"aria-label\": ariaLabel,\n}: IconProps) {\n const coreIcon = CORE_ICON_LOOKUP[name];\n if (coreIcon !== undefined) {\n return (\n \n );\n }\n return (\n \n );\n}\n\n/**\n * Renders an extended-registry glyph. Until the registry has loaded it renders\n * the same-size empty svg (no layout shift), kicks off the load, and\n * re-renders once the artwork is registered.\n */\nfunction ExtendedIcon({\n name,\n className,\n style,\n \"aria-hidden\": ariaHidden,\n \"aria-label\": ariaLabel,\n}: IconProps) {\n // Widened like CORE_ICON_LOOKUP so the union-typed name needs no cast.\n const extendedIcons: Readonly<\n Record\n > | null = useSyncExternalStore(\n subscribeExtendedIcons,\n getExtendedIcons,\n getExtendedIcons,\n );\n const icon = extendedIcons?.[name];\n if (icon === undefined) {\n // Fire-and-forget: the store notifies subscribers when it lands, and\n // preloadExtendedIcons handles the retry on failure.\n void preloadExtendedIcons().catch(() => undefined);\n }\n return (\n \n );\n}\n", "type": "registry:ui", "target": "components/ui/icon.tsx" } From 4822f823d72f5cc22017c918a34eeb507e1f3469 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 27 Aug 2026 15:54:40 -0700 Subject: [PATCH 3/4] Keep mobile icon map in sync --- apps/mobile/src/ui/icon-map.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mobile/src/ui/icon-map.ts b/apps/mobile/src/ui/icon-map.ts index d00dfdc5ef..c6e34c3b00 100644 --- a/apps/mobile/src/ui/icon-map.ts +++ b/apps/mobile/src/ui/icon-map.ts @@ -73,6 +73,7 @@ import { FileAttachmentIcon, FileEmpty02Icon, FileQuestionMarkIcon, + FilterIcon, Folder02Icon, FolderAddIcon, FolderEditIcon, @@ -345,6 +346,7 @@ const ICON_MAP = { FileAttachment: FileAttachmentIcon, FileQuestion: FileQuestionMarkIcon, FileText: File01Icon, + Filter: FilterIcon, Folder: FolderIcon, FolderEdit: FolderEditIcon, FolderExport: FolderExportIcon, From 66cf39b68e813c195e39ecdb022cb5ffbf0292c5 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 27 Aug 2026 16:03:19 -0700 Subject: [PATCH 4/4] Map filter icon on iOS --- apps/mobile/src/ui/sf-symbol-map.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/ui/sf-symbol-map.ts b/apps/mobile/src/ui/sf-symbol-map.ts index beb3bc946e..48f266dd02 100644 --- a/apps/mobile/src/ui/sf-symbol-map.ts +++ b/apps/mobile/src/ui/sf-symbol-map.ts @@ -98,6 +98,7 @@ export const SF_SYMBOL_MAP = { FileAttachment: "doc", FileQuestion: "questionmark.square.dashed", FileText: "doc.text", + Filter: "line.3.horizontal.decrease", Folder: "folder", FolderEdit: "folder.badge.gearshape", FolderExport: "square.and.arrow.up",