diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx index d7d903075e..5105703137 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -9,6 +9,7 @@ import { import { cn } from "@bb/shared-ui/lib/utils"; import { THREAD_JUMP_APP_COMMAND_IDS } from "@bb/domain"; import { Link, useNavigate } from "react-router-dom"; +import { useAtomValue } from "jotai"; import { Icon } from "@bb/shared-ui/icon"; import { COARSE_POINTER_CHILD_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { OverflowFade } from "@/components/ui/overflow-fade.js"; @@ -66,6 +67,11 @@ import { } from "@/components/commands/AppCommandProvider"; import { useRouteState } from "@/hooks/useRouteState"; import { usePluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome"; +import { SidebarTopRegionCustomizeMenu } from "./SidebarTopRegionCustomizeMenu"; +import { + sidebarTopRegionItemPreferencesAtom, + type SidebarTopRegionItemId, +} from "./sidebarTopRegionItemPreferences"; const BUG_REPORT_NEW_ISSUE_URL = "https://github.com/get-bb/bb/issues/new"; const SIDEBAR_FOOTER_ACTION_CLASS = cn( @@ -179,6 +185,9 @@ export function AppSidebar({ ); const isAppCommandModifierHeld = useIsAppCommandModifierHeld(); const settingsShortcut = useAppCommandShortcut("settings.open"); + const topRegionItemPreferences = useAtomValue( + sidebarTopRegionItemPreferencesAtom, + ); const pluginNavPanels = usePluginNavPanelChrome(); const automationsNavPanel = pluginNavPanels.find( ({ chrome }) => chrome.pluginId === AUTOMATIONS_PLUGIN_ID, @@ -298,6 +307,33 @@ export function AppSidebar({ isCreatingProject={quickCreateProject.isCreating} /> ); + const topRegionItemNodes: Record = { + "new-thread": ( + + ), + extensions: toolsRoutePath ? ( + + ) : null, + automations: automationsNavPanel ? ( + + ) : null, + }; + const visibleTopRegionItems = topRegionItemPreferences.order.flatMap((id) => { + if (topRegionItemPreferences.hiddenIds.includes(id)) return []; + const node = topRegionItemNodes[id]; + return node === null ? [] : [{node}]; + }); const body = ( <> @@ -323,42 +359,28 @@ export function AppSidebar({ usesDesktopChrome && MACOS_WINDOW_DRAG_CLASS, )} > - + > + + + ) : null} - - {toolsRoutePath ? ( - - ) : null} - {automationsNavPanel ? ( - - ) : null} - - ), + "new-thread-extensions": + visibleTopRegionItems.length > 0 ? ( +
+ {visibleTopRegionItems} +
+ ) : null, "plugin-pages": hasTraditionalPluginPanels ? ( ) : null, diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index 17edcd1c21..418c119a14 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -60,7 +60,7 @@ function getModeOrderProbeConfig(mode: SidebarOrganizationMode): { switch (mode) { case "project": return { entitySectionIds: ["project:a"] }; - case "chronological": + case "manual": return { entitySectionIds: ["section:a"] }; case "machine": return { entitySectionIds: [], hasThreadsSection: true }; @@ -82,23 +82,21 @@ function ModeOrderProbe({ mode }: { mode: SidebarOrganizationMode }) { interface ActiveModeOrderProbeProps { mode: SidebarOrganizationMode; - renderChronological?: () => ReactNode; + renderManual?: () => ReactNode; renderMachine?: () => ReactNode; renderProject?: () => ReactNode; } function ActiveModeOrderProbe({ mode, - renderChronological = () => ( - - ), + renderManual = () => , renderMachine = () => , renderProject = () => , }: ActiveModeOrderProbeProps) { return ( @@ -183,8 +181,8 @@ function MachineModeProbe({ threads = [] }: { threads?: ThreadListEntry[] }) { collapsedThreadIds={new Set()} collapsedEnvironmentIds={new Set()} compareThreads={() => 0} - renderSectionDisplayOptions={() => null} - isSectionDisplayOptionsOpen={() => false} + displayOptions={ @@ -683,11 +679,16 @@ export function SidebarDisplayOptionsMenu({ const selectedSort: SidebarChronologicalSort = chronologicalSort === "none" ? "updated" : chronologicalSort; const isFiltered = + organizationMode !== "project" || + selectedSort !== "updated" || !isDefaultSidebarThreadLifecycleSelection(lifecycleSelection); - const lifecycleLabels: Record = { - active: "Active", - drafts: "Drafts", - archived: "Archived", + const lifecyclePresentation: Record< + SidebarThreadLifecycleState, + { icon: IconName; label: string } + > = { + active: { icon: "Circle", label: "Active" }, + drafts: { icon: "EditFile", label: "Drafts" }, + archived: { icon: "Archive", label: "Archived" }, }; return ( @@ -699,7 +700,7 @@ export function SidebarDisplayOptionsMenu({ : "Sidebar display options" } filtered={isFiltered} - iconName="SlidersHorizontal" + iconName="Filter" tooltip="Display options" /> @@ -718,6 +719,7 @@ export function SidebarDisplayOptionsMenu({ setOrganizationMode(option.mode); }} > + + + ); +} diff --git a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx index 50e59037d2..6c5d67c5ea 100644 --- a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx @@ -59,7 +59,7 @@ export function Overview() { diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts index a2fa21f621..22dae819e5 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts @@ -9,6 +9,22 @@ afterEach(() => { }); describe("sidebar section preference migration", () => { + it("renames the stack-only chronological organization value to manual", async () => { + window.localStorage.setItem( + "bb.sidebar.organizationMode", + JSON.stringify("chronological"), + ); + + const { sidebarOrganizationModeAtom } = + await import("./sidebarCollapsedAtoms"); + const store = createStore(); + + expect(store.get(sidebarOrganizationModeAtom)).toBe("manual"); + expect(window.localStorage.getItem("bb.sidebar.organizationMode")).toBe( + JSON.stringify("manual"), + ); + }); + it("preserves manual order and collapsed groups from folder-era storage", async () => { window.localStorage.setItem( "bb.sidebar.folderSectionOrder", diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 2899784eaf..f763074afc 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -29,10 +29,9 @@ export type { SidebarSectionId, } from "@bb/client-core"; -// "project" keeps the per-project grouping; "chronological" is the persisted -// value for the cross-project Sections view that replaced the old None view; -// "machine" groups threads by the host their environment runs on. -export type SidebarOrganizationMode = "project" | "chronological" | "machine"; +// "project" keeps per-project groups, "manual" shows the user's named +// sections plus the loose Threads bucket, and "machine" groups by host. +export type SidebarOrganizationMode = "project" | "manual" | "machine"; // Controls thread ordering in both grouped and ungrouped sidebar views. Time // sorts show newest first and alphabetical sorts A→Z. "none" is a legacy value // that the runtime normalizes back to "updated". @@ -157,11 +156,44 @@ export const sidebarMachineSectionOrderAtom = atomWithStorage( { getOnInit: true }, ); +function normalizeSidebarOrganizationMode( + value: unknown, +): SidebarOrganizationMode { + if (value === "manual" || value === "chronological") return "manual"; + if (value === "machine") return "machine"; + return "project"; +} + +const organizationModeJsonStorage = createJsonLocalStorage(); +const sidebarOrganizationModeStorage: SyncStorage = { + getItem(key, initialValue) { + const stored = organizationModeJsonStorage.getItem(key, initialValue); + const normalized = normalizeSidebarOrganizationMode(stored); + if (stored !== normalized) { + organizationModeJsonStorage.setItem(key, normalized); + } + return normalized; + }, + setItem(key, value) { + organizationModeJsonStorage.setItem( + key, + normalizeSidebarOrganizationMode(value), + ); + }, + removeItem: organizationModeJsonStorage.removeItem, + subscribe: (key, callback, initialValue) => + organizationModeJsonStorage.subscribe?.( + key, + (value) => callback(normalizeSidebarOrganizationMode(value)), + initialValue, + ), +}; + export const sidebarOrganizationModeAtom = atomWithStorage( SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY, "project", - createJsonLocalStorage(), + sidebarOrganizationModeStorage, { getOnInit: true }, ); 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..486e38fcda --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { createStore } from "jotai"; +import { afterEach, describe, expect, it } from "vitest"; +import { + DEFAULT_SIDEBAR_TOP_REGION_ITEM_PREFERENCES, + migrateLegacySidebarTopRegionItems, + normalizeSidebarTopRegionItemPreferences, + reorderSidebarTopRegionItems, + setSidebarTopRegionItemVisible, + sidebarTopRegionItemPreferencesAtom, +} from "./sidebarTopRegionItemPreferences"; + +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); + } + + removeItem(key: string): void { + this.values.delete(key); + } +} + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("top-region sidebar item preferences", () => { + it("defaults to all three host-owned items in approved order", () => { + const store = createStore(); + + expect(store.get(sidebarTopRegionItemPreferencesAtom)).toEqual({ + order: ["new-thread", "extensions", "automations"], + hiddenIds: [], + }); + }); + + it("normalizes duplicate, unknown, and missing ids without losing hidden choices", () => { + expect( + normalizeSidebarTopRegionItemPreferences({ + order: ["automations", "unknown", "automations"], + hiddenIds: ["extensions", "unknown", "extensions"], + }), + ).toEqual({ + order: ["automations", "new-thread", "extensions"], + hiddenIds: ["extensions"], + }); + }); + + it("migrates both booleans and legacy hidden built-in panels atomically", () => { + const storage = new MemoryStorage(); + storage.setItem("bb.sidebar.newThreadVisible", "false"); + storage.setItem("bb.sidebar.extensionsVisible", "true"); + storage.setItem( + "bb.sidebar.hiddenPluginPanels", + JSON.stringify(["docs/main", "__builtin__/tools", "automations/main"]), + ); + + expect(migrateLegacySidebarTopRegionItems(storage)).toEqual({ + order: ["new-thread", "extensions", "automations"], + hiddenIds: ["new-thread", "extensions", "automations"], + }); + expect(storage.getItem("bb.sidebar.hiddenPluginPanels")).toBe( + JSON.stringify(["docs/main"]), + ); + expect(storage.getItem("bb.sidebar.newThreadVisible")).toBeNull(); + expect(storage.getItem("bb.sidebar.extensionsVisible")).toBeNull(); + expect( + JSON.parse(storage.getItem("bb.sidebar.topRegionItems") ?? "{}"), + ).toEqual({ + order: ["new-thread", "extensions", "automations"], + hiddenIds: ["new-thread", "extensions", "automations"], + }); + }); + + it("preserves an existing combined preference and only consumes owned legacy keys", () => { + const storage = new MemoryStorage(); + storage.setItem( + "bb.sidebar.topRegionItems", + JSON.stringify({ + order: ["automations", "extensions", "new-thread"], + hiddenIds: ["extensions"], + }), + ); + storage.setItem( + "bb.sidebar.hiddenPluginPanels", + JSON.stringify(["automations/main", "docs/main"]), + ); + + expect(migrateLegacySidebarTopRegionItems(storage)).toEqual({ + order: ["automations", "extensions", "new-thread"], + hiddenIds: ["extensions"], + }); + expect(storage.getItem("bb.sidebar.hiddenPluginPanels")).toBe( + JSON.stringify(["docs/main"]), + ); + }); + + it("reorders live and can hide then restore every item", () => { + const reordered = reorderSidebarTopRegionItems( + DEFAULT_SIDEBAR_TOP_REGION_ITEM_PREFERENCES, + "automations", + "new-thread", + ); + expect(reordered.order).toEqual([ + "automations", + "new-thread", + "extensions", + ]); + + let next = reordered; + for (const id of reordered.order) { + next = setSidebarTopRegionItemVisible(next, id, false); + } + expect(next.hiddenIds).toEqual(["automations", "new-thread", "extensions"]); + next = setSidebarTopRegionItemVisible(next, "new-thread", true); + expect(next.hiddenIds).toEqual(["automations", "extensions"]); + }); +}); diff --git a/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.ts b/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.ts new file mode 100644 index 0000000000..0377ab42f7 --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarTopRegionItemPreferences.ts @@ -0,0 +1,234 @@ +import { atomWithStorage } from "jotai/utils"; +import { + createJsonLocalStorage, + type SyncStorage, +} from "@/lib/browser-storage"; +import { AUTOMATIONS_PLUGIN_ID } from "@/lib/route-paths"; + +export const SIDEBAR_TOP_REGION_ITEMS_STORAGE_KEY = "bb.sidebar.topRegionItems"; +export const LEGACY_NEW_THREAD_VISIBLE_STORAGE_KEY = + "bb.sidebar.newThreadVisible"; +export const LEGACY_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"; + +export const SIDEBAR_TOP_REGION_ITEM_IDS = [ + "new-thread", + "extensions", + "automations", +] as const; + +export type SidebarTopRegionItemId = + (typeof SIDEBAR_TOP_REGION_ITEM_IDS)[number]; + +export interface SidebarTopRegionItemPreferences { + order: SidebarTopRegionItemId[]; + hiddenIds: SidebarTopRegionItemId[]; +} + +export const DEFAULT_SIDEBAR_TOP_REGION_ITEM_PREFERENCES: SidebarTopRegionItemPreferences = + { + order: [...SIDEBAR_TOP_REGION_ITEM_IDS], + hiddenIds: [], + }; + +interface SidebarItemMigrationStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +function isSidebarTopRegionItemId( + value: unknown, +): value is SidebarTopRegionItemId { + return SIDEBAR_TOP_REGION_ITEM_IDS.some((id) => id === value); +} + +function normalizeIds(value: unknown): SidebarTopRegionItemId[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.filter(isSidebarTopRegionItemId))]; +} + +export function normalizeSidebarTopRegionItemPreferences( + value: unknown, +): SidebarTopRegionItemPreferences { + const candidate = + typeof value === "object" && value !== null + ? (value as { order?: unknown; hiddenIds?: unknown }) + : {}; + const presentOrder = normalizeIds(candidate.order); + const order = [ + ...presentOrder, + ...SIDEBAR_TOP_REGION_ITEM_IDS.filter((id) => !presentOrder.includes(id)), + ]; + const hiddenIds = normalizeIds(candidate.hiddenIds).filter((id) => + order.includes(id), + ); + return { order, hiddenIds }; +} + +function readJson(storage: SidebarItemMigrationStorage, key: string): unknown { + const value = storage.getItem(key); + if (value === null) return undefined; + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} + +function readStoredBoolean( + storage: SidebarItemMigrationStorage, + key: string, +): boolean | null { + const value = readJson(storage, key); + return typeof value === "boolean" ? value : null; +} + +function readLegacyHiddenKeys( + storage: SidebarItemMigrationStorage, +): unknown[] | null { + const value = readJson(storage, LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY); + return Array.isArray(value) ? value : null; +} + +function isLegacyAutomationsPanelKey(value: unknown): value is string { + return ( + typeof value === "string" && value.startsWith(`${AUTOMATIONS_PLUGIN_ID}/`) + ); +} + +function consumeOwnedLegacyHiddenKeys( + storage: SidebarItemMigrationStorage, + legacyHiddenKeys: readonly unknown[] | null, +): void { + if (legacyHiddenKeys === null) return; + const remaining = legacyHiddenKeys.filter( + (key) => + key !== LEGACY_EXTENSIONS_NAV_ROW_KEY && + !isLegacyAutomationsPanelKey(key), + ); + if (remaining.length === 0) { + storage.removeItem(LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY); + } else { + storage.setItem( + LEGACY_HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY, + JSON.stringify(remaining), + ); + } +} + +function consumeLegacyBooleanPreferences( + storage: SidebarItemMigrationStorage, +): void { + storage.removeItem(LEGACY_NEW_THREAD_VISIBLE_STORAGE_KEY); + storage.removeItem(LEGACY_EXTENSIONS_VISIBLE_STORAGE_KEY); +} + +/** + * Moves the stack's two booleans and the shipped plugin-hide choices into the + * one top-region preference. The combined value wins once it exists, while + * owned legacy keys are still consumed so the plugin-page migration cannot + * reintroduce Automations as a traditional row. + */ +export function migrateLegacySidebarTopRegionItems( + storage: SidebarItemMigrationStorage, +): SidebarTopRegionItemPreferences { + const legacyHiddenKeys = readLegacyHiddenKeys(storage); + const storedValue = readJson(storage, SIDEBAR_TOP_REGION_ITEMS_STORAGE_KEY); + if (storedValue !== undefined) { + const normalized = normalizeSidebarTopRegionItemPreferences(storedValue); + storage.setItem( + SIDEBAR_TOP_REGION_ITEMS_STORAGE_KEY, + JSON.stringify(normalized), + ); + consumeOwnedLegacyHiddenKeys(storage, legacyHiddenKeys); + consumeLegacyBooleanPreferences(storage); + return normalized; + } + + const hiddenIds: SidebarTopRegionItemId[] = []; + if ( + readStoredBoolean(storage, LEGACY_NEW_THREAD_VISIBLE_STORAGE_KEY) === false + ) { + hiddenIds.push("new-thread"); + } + if ( + readStoredBoolean(storage, LEGACY_EXTENSIONS_VISIBLE_STORAGE_KEY) === + false || + legacyHiddenKeys?.includes(LEGACY_EXTENSIONS_NAV_ROW_KEY) + ) { + hiddenIds.push("extensions"); + } + if (legacyHiddenKeys?.some(isLegacyAutomationsPanelKey)) { + hiddenIds.push("automations"); + } + + const migrated = normalizeSidebarTopRegionItemPreferences({ + order: SIDEBAR_TOP_REGION_ITEM_IDS, + hiddenIds, + }); + storage.setItem( + SIDEBAR_TOP_REGION_ITEMS_STORAGE_KEY, + JSON.stringify(migrated), + ); + consumeOwnedLegacyHiddenKeys(storage, legacyHiddenKeys); + consumeLegacyBooleanPreferences(storage); + return migrated; +} + +export function reorderSidebarTopRegionItems( + current: SidebarTopRegionItemPreferences, + activeId: SidebarTopRegionItemId, + overId: SidebarTopRegionItemId, +): SidebarTopRegionItemPreferences { + const activeIndex = current.order.indexOf(activeId); + const overIndex = current.order.indexOf(overId); + if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) { + return current; + } + const order = [...current.order]; + const [moved] = order.splice(activeIndex, 1); + if (moved === undefined) return current; + order.splice(overIndex, 0, moved); + return { ...current, order }; +} + +export function setSidebarTopRegionItemVisible( + current: SidebarTopRegionItemPreferences, + id: SidebarTopRegionItemId, + visible: boolean, +): SidebarTopRegionItemPreferences { + const hiddenIds = visible + ? current.hiddenIds.filter((candidate) => candidate !== id) + : [...new Set([...current.hiddenIds, id])]; + return { ...current, hiddenIds }; +} + +const jsonStorage = createJsonLocalStorage(); +const topRegionItemStorage: SyncStorage = { + getItem: (_key, initialValue) => { + if (typeof window === "undefined") return initialValue; + return migrateLegacySidebarTopRegionItems(window.localStorage); + }, + setItem: (key, value) => { + jsonStorage.setItem(key, normalizeSidebarTopRegionItemPreferences(value)); + }, + removeItem: jsonStorage.removeItem, + subscribe: (key, callback, initialValue) => + jsonStorage.subscribe?.( + key, + (value) => callback(normalizeSidebarTopRegionItemPreferences(value)), + initialValue, + ), +}; + +export const sidebarTopRegionItemPreferencesAtom = + atomWithStorage( + SIDEBAR_TOP_REGION_ITEMS_STORAGE_KEY, + DEFAULT_SIDEBAR_TOP_REGION_ITEM_PREFERENCES, + topRegionItemStorage, + { getOnInit: true }, + ); diff --git a/apps/app/src/components/sidebar/sortComparator.test.ts b/apps/app/src/components/sidebar/sortComparator.test.ts index 01ef7d774f..72732524d2 100644 --- a/apps/app/src/components/sidebar/sortComparator.test.ts +++ b/apps/app/src/components/sidebar/sortComparator.test.ts @@ -335,7 +335,7 @@ describe("getSelectedThreadSidebarExpansion", () => { it("expands the threads section for unsectioned project threads in sections mode", () => { expect( getSelectedThreadSidebarExpansion({ - organizationMode: "chronological", + organizationMode: "manual", isPinned: false, sidebarProjectId: "proj_app", selectedThread: thread({ sectionId: null, projectId: "proj_app" }), @@ -346,7 +346,7 @@ describe("getSelectedThreadSidebarExpansion", () => { it("expands the containing section for sectioned threads in sections mode", () => { expect( getSelectedThreadSidebarExpansion({ - organizationMode: "chronological", + organizationMode: "manual", isPinned: false, sidebarProjectId: "proj_app", selectedThread: thread({ @@ -384,7 +384,7 @@ describe("getSelectedThreadSidebarExpansion", () => { it("expands the pinned section for pinned threads", () => { expect( getSelectedThreadSidebarExpansion({ - organizationMode: "chronological", + organizationMode: "manual", isPinned: true, sidebarProjectId: "proj_app", selectedThread: thread({ sectionId: null, projectId: "proj_app" }), diff --git a/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts b/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts index c9db6a639d..ece6f1e5ce 100644 --- a/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts +++ b/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts @@ -21,7 +21,7 @@ const MODE_SECTION_ORDER_CONFIG: Record< atom: sidebarSectionOrderAtom, legacyEntityAnchor: "projects", }, - chronological: { + manual: { atom: sidebarManualSectionOrderAtom, legacyEntityAnchor: "sections", }, 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, 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", 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" } diff --git a/packages/shared-ui/src/components/ui/icon.tsx b/packages/shared-ui/src/components/ui/icon.tsx index 9187942e12..5a488ce33e 100644 --- a/packages/shared-ui/src/components/ui/icon.tsx +++ b/packages/shared-ui/src/components/ui/icon.tsx @@ -22,6 +22,7 @@ import { Delete02Icon, Download01Icon, Edit02Icon, + FilterIcon, FolderAddIcon, FolderExportIcon, FolderGitTwoIcon, @@ -127,6 +128,7 @@ const CORE_ICON_MAP = { Copy: Copy01Icon, Download: Download01Icon, Edit: Edit02Icon, + Filter: FilterIcon, Folder: FolderIcon, FolderExport: FolderExportIcon, FolderGit: FolderGitTwoIcon,