diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index f1dcff2b8d2..185bfda22b5 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -13,6 +13,7 @@ import { RouteNavigationProvider } from "./components/ui/app-route-anchor"; import { RouteNavigationIndicator } from "./components/ui/route-navigation-indicator"; import { AppNavigationUrlHost } from "./lib/url-open-routing"; import { NativeShellReporter } from "./lib/native-shell"; +import { UiPreferencesSync } from "@/lib/ui-preferences/UiPreferencesSync"; import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; @@ -364,6 +365,7 @@ export function App() { + { expect(screen.getByTestId("sidebar-navigation-more-row")).not.toBeNull(); unmount(); - renderSidebarItems(); + renderSidebarItems({ + storedOrder: store.get(pluginNavPanelOrderAtom), + storedVisibleKeys: store.get(pluginNavVisiblePanelKeysAtom), + }); expect(panelRowNames(labels)).toEqual(["Two", "Three", "Four"]); expect(screen.queryByRole("button", { name: "One" })).toBeNull(); @@ -804,8 +807,9 @@ describe("PluginNavSidebarItems", () => { }); expect(visibleRowKeys()).toEqual(["__bb__/new-thread", "docs/main"]); - expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([ - "__bb__/new-thread", + expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual(["docs/main"]); + expect(store.get(pluginNavPanelOrderAtom)).toEqual([ + "tasks/main", "docs/main", ]); }); @@ -821,13 +825,11 @@ describe("PluginNavSidebarItems", () => { expect(visibleRowKeys()).toEqual(["__bb__/new-thread", "tasks/main"]); expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([ - "tasks/main", "__bb__/new-thread", ]); expect(store.get(pluginNavPanelOrderAtom)).toEqual([ "__bb__/new-thread", "docs/main", - "tasks/main", ]); }); diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx index 661c86ac85e..6e8411e793f 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx @@ -275,29 +275,19 @@ function PluginNavSidebarItemList({ [ordered, visibleKeys], ); - useEffect(() => { - if (haveSameOrder(storedOrder, normalizedOrder)) return; - setStoredOrder(normalizedOrder); - }, [normalizedOrder, setStoredOrder, storedOrder]); - - useEffect(() => { - if ( - storedVisibleKeys === null || - normalizedVisibleKeys === null || - haveSameOrder(storedVisibleKeys, normalizedVisibleKeys) - ) { - return; - } - setStoredVisibleKeys(normalizedVisibleKeys); - }, [normalizedVisibleKeys, setStoredVisibleKeys, storedVisibleKeys]); - const orderedKeys = useMemo( () => ordered.map(getPluginNavPanelKey), [ordered], ); + const persistNormalizedOrder = useCallback(() => { + if (haveSameOrder(storedOrder, normalizedOrder)) return; + setStoredOrder(normalizedOrder); + }, [normalizedOrder, setStoredOrder, storedOrder]); + const setPanelVisible = useCallback( (key: string, isVisible: boolean) => { + persistNormalizedOrder(); setStoredVisibleKeys( togglePluginNavPanelVisibility( normalizedVisibleKeys ?? visibleKeys, @@ -306,7 +296,12 @@ function PluginNavSidebarItemList({ ), ); }, - [normalizedVisibleKeys, setStoredVisibleKeys, visibleKeys], + [ + normalizedVisibleKeys, + persistNormalizedOrder, + setStoredVisibleKeys, + visibleKeys, + ], ); const handleDragEnd = useCallback( diff --git a/apps/app/src/components/plugin/pluginNavSidebarAtoms.test.ts b/apps/app/src/components/plugin/pluginNavSidebarAtoms.test.ts deleted file mode 100644 index e4fe1cb79f3..00000000000 --- a/apps/app/src/components/plugin/pluginNavSidebarAtoms.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -// @vitest-environment jsdom - -import { createStore } from "jotai"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -afterEach(() => { - window.localStorage.clear(); - vi.resetModules(); -}); - -describe("pluginNavPanelOrderAtom migration", () => { - it("converts legacy hidden keys into positional overflow order", async () => { - window.localStorage.setItem( - "bb.sidebar.pluginPanelOrder", - JSON.stringify(["docs/main", "tasks/main", "github/main"]), - ); - window.localStorage.setItem( - "bb.sidebar.hiddenPluginPanels", - JSON.stringify(["tasks/main", "docs/main"]), - ); - - const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } = - await import("./pluginNavSidebarAtoms"); - const store = createStore(); - - expect(store.get(pluginNavPanelOrderAtom)).toEqual([ - "github/main", - "docs/main", - "tasks/main", - ]); - expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([ - "github/main", - ]); - expect( - window.localStorage.getItem("bb.sidebar.hiddenPluginPanels"), - ).toBeNull(); - }); - - it("migrates visibility when it is read before order", async () => { - window.localStorage.setItem( - "bb.sidebar.pluginPanelOrder", - JSON.stringify(["docs/main", "tasks/main", "github/main"]), - ); - window.localStorage.setItem( - "bb.sidebar.hiddenPluginPanels", - JSON.stringify(["tasks/main"]), - ); - - const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } = - await import("./pluginNavSidebarAtoms"); - const store = createStore(); - - expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([ - "docs/main", - "github/main", - ]); - expect(store.get(pluginNavPanelOrderAtom)).toEqual([ - "docs/main", - "github/main", - "tasks/main", - ]); - }); - - it("migrates hidden Automations into the unified visibility preference", async () => { - window.localStorage.setItem( - "bb.sidebar.pluginPanelOrder", - JSON.stringify(["docs/main"]), - ); - window.localStorage.setItem( - "bb.sidebar.hiddenPluginPanels", - JSON.stringify(["docs/main", "automations/main"]), - ); - - const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } = - await import("./pluginNavSidebarAtoms"); - const store = createStore(); - - expect(store.get(pluginNavPanelOrderAtom)).toEqual([ - "docs/main", - "__bb__/automations", - ]); - expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([]); - expect( - window.localStorage.getItem("bb.sidebar.hiddenPluginPanels"), - ).toBeNull(); - }); - - it("maps legacy built-in order and visibility keys to unified keys", async () => { - window.localStorage.setItem( - "bb.sidebar.pluginPanelOrder", - JSON.stringify(["__builtin__/tools", "automations/main", "docs/main"]), - ); - window.localStorage.setItem( - "bb.sidebar.visiblePluginPanels", - JSON.stringify(["automations/main", "__builtin__/tools"]), - ); - - const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } = - await import("./pluginNavSidebarAtoms"); - const store = createStore(); - - expect(store.get(pluginNavPanelOrderAtom)).toEqual([ - "__bb__/extensions", - "__bb__/automations", - "docs/main", - ]); - expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([ - "__bb__/automations", - "__bb__/extensions", - ]); - }); - - it("keeps fresh visibility unset so defaults can follow available rows", async () => { - const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } = - await import("./pluginNavSidebarAtoms"); - const store = createStore(); - - expect(store.get(pluginNavPanelOrderAtom)).toEqual([]); - expect(store.get(pluginNavVisiblePanelKeysAtom)).toBeNull(); - }); - - it("does not overwrite an existing visibility preference during migration", async () => { - window.localStorage.setItem( - "bb.sidebar.pluginPanelOrder", - JSON.stringify(["docs/main", "tasks/main", "github/main"]), - ); - window.localStorage.setItem( - "bb.sidebar.visiblePluginPanels", - JSON.stringify(["tasks/main"]), - ); - window.localStorage.setItem( - "bb.sidebar.hiddenPluginPanels", - JSON.stringify(["docs/main"]), - ); - - const { pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom } = - await import("./pluginNavSidebarAtoms"); - const store = createStore(); - - expect(store.get(pluginNavPanelOrderAtom)).toEqual([ - "tasks/main", - "github/main", - "docs/main", - ]); - expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual(["tasks/main"]); - }); - - it("persists an explicit empty visible list", async () => { - const { pluginNavVisiblePanelKeysAtom } = await import( - "./pluginNavSidebarAtoms" - ); - const store = createStore(); - - store.set(pluginNavVisiblePanelKeysAtom, []); - - expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([]); - expect( - window.localStorage.getItem("bb.sidebar.visiblePluginPanels"), - ).toBe("[]"); - }); - - it("normalizes persisted visible keys without discarding unregistered keys", async () => { - window.localStorage.setItem( - "bb.sidebar.visiblePluginPanels", - JSON.stringify(["docs/main", "future/main", "docs/main"]), - ); - - const { pluginNavVisiblePanelKeysAtom } = await import( - "./pluginNavSidebarAtoms" - ); - const store = createStore(); - - expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([ - "docs/main", - "future/main", - ]); - }); -}); diff --git a/apps/app/src/components/plugin/pluginNavSidebarAtoms.ts b/apps/app/src/components/plugin/pluginNavSidebarAtoms.ts index 63657d21672..56de8068c2c 100644 --- a/apps/app/src/components/plugin/pluginNavSidebarAtoms.ts +++ b/apps/app/src/components/plugin/pluginNavSidebarAtoms.ts @@ -1,148 +1,9 @@ -import { atomWithStorage } from "jotai/utils"; -import { - createJsonLocalStorage, - type SyncStorage, -} from "@/lib/browser-storage"; -import { AUTOMATIONS_PLUGIN_ID } from "@/lib/route-paths"; -import { - BUILT_IN_SIDEBAR_NAVIGATION_KEYS, - migrateLegacyHiddenPluginNavPanelOrder, -} from "./pluginNavSidebarOrder"; +import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; -const PLUGIN_NAV_PANEL_ORDER_STORAGE_KEY = "bb.sidebar.pluginPanelOrder"; -const VISIBLE_PLUGIN_NAV_PANELS_STORAGE_KEY = - "bb.sidebar.visiblePluginPanels"; -const HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY = "bb.sidebar.hiddenPluginPanels"; -const LEGACY_EXTENSIONS_NAV_PANEL_KEY = "__builtin__/tools"; - -function toSidebarNavigationKey(key: string): string { - if (key === LEGACY_EXTENSIONS_NAV_PANEL_KEY) { - return BUILT_IN_SIDEBAR_NAVIGATION_KEYS.extensions; - } - if (key.startsWith(`${AUTOMATIONS_PLUGIN_ID}/`)) { - return BUILT_IN_SIDEBAR_NAVIGATION_KEYS.automations; - } - return key; -} - -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return [ - ...new Set( - value.filter((item): item is string => typeof item === "string"), - ), - ]; -} - -function normalizeSidebarNavigationKeys(value: unknown): string[] { - return [...new Set(normalizeStringArray(value).map(toSidebarNavigationKey))]; -} - -function normalizeVisiblePanelKeys(value: unknown): string[] | null { - return Array.isArray(value) ? normalizeSidebarNavigationKeys(value) : null; -} - -function migrateLegacyPluginNavPreferences( - storage: SyncStorage, - order: readonly string[], -): string[] { - const normalizedOrder = normalizeSidebarNavigationKeys(order); - const legacyHiddenValue = storage.getItem( - HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY, - null, - ); - const legacyHidden = normalizeSidebarNavigationKeys( - legacyHiddenValue, - ); - const migrated = migrateLegacyHiddenPluginNavPanelOrder( - normalizedOrder, - legacyHidden, - ); - const storedVisibleKeys = normalizeVisiblePanelKeys( - storage.getItem(VISIBLE_PLUGIN_NAV_PANELS_STORAGE_KEY, null), - ); - if (storedVisibleKeys === null && Array.isArray(legacyHiddenValue)) { - const hidden = new Set(legacyHidden); - storage.setItem( - VISIBLE_PLUGIN_NAV_PANELS_STORAGE_KEY, - migrated.filter((key) => !hidden.has(key)), - ); - } - storage.setItem(PLUGIN_NAV_PANEL_ORDER_STORAGE_KEY, migrated); - storage.removeItem(HIDDEN_PLUGIN_NAV_PANELS_STORAGE_KEY); - return migrated; -} - -function createPluginNavPanelOrderStorage(): SyncStorage { - const storage = createJsonLocalStorage(); - return { - getItem(key, initialValue) { - const order = normalizeSidebarNavigationKeys( - storage.getItem(key, initialValue), - ); - return migrateLegacyPluginNavPreferences(storage, order); - }, - setItem(key, value) { - storage.setItem(key, normalizeSidebarNavigationKeys(value)); - }, - removeItem(key) { - storage.removeItem(key); - }, - subscribe: (key, callback, initialValue) => - storage.subscribe?.( - key, - (value) => callback(normalizeSidebarNavigationKeys(value)), - initialValue, - ), - }; -} - -function createPluginNavVisiblePanelKeysStorage(): SyncStorage< - string[] | null -> { - const storage = createJsonLocalStorage(); - return { - getItem(key, initialValue) { - const storedVisibleKeys = normalizeVisiblePanelKeys( - storage.getItem(key, initialValue), - ); - if (storedVisibleKeys !== null) return storedVisibleKeys; - - const order = normalizeSidebarNavigationKeys( - storage.getItem(PLUGIN_NAV_PANEL_ORDER_STORAGE_KEY, []), - ); - migrateLegacyPluginNavPreferences(storage, order); - return normalizeVisiblePanelKeys(storage.getItem(key, initialValue)); - }, - setItem(key, value) { - if (value === null) { - storage.removeItem(key); - return; - } - storage.setItem(key, normalizeSidebarNavigationKeys(value)); - }, - removeItem(key) { - storage.removeItem(key); - }, - subscribe: (key, callback, initialValue) => - storage.subscribe?.( - key, - (value) => callback(normalizeVisiblePanelKeys(value)), - initialValue, - ), - }; -} - -export const pluginNavPanelOrderAtom = atomWithStorage( - PLUGIN_NAV_PANEL_ORDER_STORAGE_KEY, - [], - createPluginNavPanelOrderStorage(), - { getOnInit: true }, +export const pluginNavPanelOrderAtom = createSyncedPreferenceAtom( + "sidebar.pluginPanelOrder", ); -export const pluginNavVisiblePanelKeysAtom = atomWithStorage( - VISIBLE_PLUGIN_NAV_PANELS_STORAGE_KEY, - null, - createPluginNavVisiblePanelKeysStorage(), - { getOnInit: true }, +export const pluginNavVisiblePanelKeysAtom = createSyncedPreferenceAtom( + "sidebar.visiblePluginPanels", ); diff --git a/apps/app/src/components/plugin/pluginNavSidebarOrder.test.ts b/apps/app/src/components/plugin/pluginNavSidebarOrder.test.ts index 848f0567055..336265e449b 100644 --- a/apps/app/src/components/plugin/pluginNavSidebarOrder.test.ts +++ b/apps/app/src/components/plugin/pluginNavSidebarOrder.test.ts @@ -3,7 +3,6 @@ import { arrangePluginNavPanelPreferences, arrangePluginNavPanels, getPluginNavPanelKey, - migrateLegacyHiddenPluginNavPanelOrder, togglePluginNavPanelVisibility, } from "./pluginNavSidebarOrder"; @@ -211,23 +210,6 @@ describe("arrangePluginNavPanelPreferences", () => { }); }); -describe("legacy hidden-panel migration", () => { - it("moves hidden keys behind visible keys while preserving both orders", () => { - expect( - migrateLegacyHiddenPluginNavPanelOrder( - ["tasks/board", "docs/vault", "github/pulls", "docs/vault"], - ["tasks/board", "docs/vault"], - ), - ).toEqual(["github/pulls", "tasks/board", "docs/vault"]); - }); - - it("retains a hidden key missing from the stored order", () => { - expect( - migrateLegacyHiddenPluginNavPanelOrder(["github/pulls"], ["docs/vault"]), - ).toEqual(["github/pulls", "docs/vault"]); - }); -}); - describe("togglePluginNavPanelVisibility", () => { it("checks and unchecks panels without losing other keys", () => { const checked = togglePluginNavPanelVisibility( diff --git a/apps/app/src/components/plugin/pluginNavSidebarOrder.ts b/apps/app/src/components/plugin/pluginNavSidebarOrder.ts index 4ad1752b1ad..5cfdb3150ee 100644 --- a/apps/app/src/components/plugin/pluginNavSidebarOrder.ts +++ b/apps/app/src/components/plugin/pluginNavSidebarOrder.ts @@ -113,17 +113,3 @@ export function togglePluginNavPanelVisibility( } return normalized.filter((item) => item !== key); } - -export function migrateLegacyHiddenPluginNavPanelOrder( - order: readonly string[], - hiddenKeys: readonly string[], -): string[] { - const uniqueOrder = [ - ...new Set([...order, ...hiddenKeys].filter((key) => key.length > 0)), - ]; - const hidden = new Set(hiddenKeys); - return [ - ...uniqueOrder.filter((key) => !hidden.has(key)), - ...uniqueOrder.filter((key) => hidden.has(key)), - ]; -} diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index 280a04bc3bf..6c72d2d84f5 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -75,7 +75,6 @@ function ModeOrderProbe({ mode }: { mode: SidebarOrganizationMode }) { entitySectionIds: config.entitySectionIds, hasThreadsSection: config.hasThreadsSection, showPinnedSection: true, - isReady: true, }); return
{order.join(",")}
; @@ -159,7 +158,6 @@ function MachineModeProbe({ threads = [] }: { threads?: ThreadListEntry[] }) { draftThreadIds={new Set()} effectivePinnedThreadIds={new Set()} status="ready" - isReady showPinnedSection={false} pinnedSection={{ label: "Pinned", content: null }} threadsSection={{ label: "Threads" }} diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 92f13617a64..c0043fa04e9 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -119,6 +119,7 @@ import { type SidebarOrganizationMode, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; +import { useUiPreferencesReady } from "@/lib/ui-preferences/UiPreferencesSync"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -175,7 +176,8 @@ interface ProjectListSearchThreadsActionProps { } interface ProjectListActionButtonsProps - extends ProjectListNewThreadActionProps, + extends + ProjectListNewThreadActionProps, ProjectListSearchThreadsActionProps {} interface ProjectListShellProps { @@ -912,7 +914,6 @@ interface ProjectModeSectionsProps extends BuiltInSectionRenderState { compareThreads: ThreadComparator; draftThreadIds: ReadonlySet; effectivePinnedThreadIds: ReadonlySet; - isReady: boolean; onCreateProjectThread: (projectId: string) => void; onProjectSelect?: () => void; onToggleEnvironmentCollapsed: ToggleCollapsedId; @@ -934,7 +935,6 @@ function ProjectModeSections({ compareThreads, draftThreadIds, effectivePinnedThreadIds, - isReady, isSectionDisplayOptionsOpen, onCreateProjectThread, onProjectSelect, @@ -950,8 +950,7 @@ function ProjectModeSections({ threads, threadsSection, }: ProjectModeSectionsProps) { - const progressiveDisclosureEnabled = - useSidebarProgressiveDisclosureEnabled(); + const progressiveDisclosureEnabled = useSidebarProgressiveDisclosureEnabled(); const [collapsedProjectIdList, setCollapsedProjectIdList] = useAtom( collapsedProjectIdsAtom, ); @@ -1057,7 +1056,6 @@ function ProjectModeSections({ entitySectionIds: projectSectionIds, hasThreadsSection: personalThreads.length > 0 || projectRows.length === 0, showPinnedSection, - isReady, }); const reorderDisabled = order.length < 2; const builtInSections: BuiltInSidebarSectionOptionsById = { @@ -1141,7 +1139,6 @@ interface SectionModeSectionsProps extends BuiltInSectionRenderState { collapsedThreadIds: Set; compareThreads: ThreadComparator; sections: readonly SidebarSectionDefinition[]; - isReady: boolean; onCreateThreadInSection: (sectionId: string) => void; onProjectSelect?: () => void; onRemoveSection: (section: SidebarSectionDefinition) => void; @@ -1172,7 +1169,6 @@ function SectionModeSections({ compareThreads, effectivePinnedThreadIds, sections, - isReady, onCreateThreadInSection, onProjectSelect, onRemoveSection, @@ -1210,7 +1206,6 @@ function SectionModeSections({ mode: "chronological", entitySectionIds: threadSectionIds, showPinnedSection, - isReady, }); return ( @@ -1249,7 +1244,6 @@ interface MachineModeSectionsProps extends BuiltInSectionRenderState { compareThreads: ThreadComparator; draftThreadIds: ReadonlySet; effectivePinnedThreadIds: ReadonlySet; - isReady: boolean; onProjectSelect?: () => void; onToggleEnvironmentCollapsed: ToggleCollapsedId; onToggleThreadCollapsed: ToggleCollapsedId; @@ -1269,7 +1263,6 @@ export function MachineModeSections({ compareThreads, draftThreadIds, effectivePinnedThreadIds, - isReady, isSectionDisplayOptionsOpen, onProjectSelect, onToggleCollapsed, @@ -1283,8 +1276,7 @@ export function MachineModeSections({ threads, threadsSection, }: MachineModeSectionsProps) { - const progressiveDisclosureEnabled = - useSidebarProgressiveDisclosureEnabled(); + const progressiveDisclosureEnabled = useSidebarProgressiveDisclosureEnabled(); const { data: hosts } = useHosts(); const [collapsedMachineKeyList, setCollapsedMachineKeyList] = useAtom( sidebarCollapsedMachinesAtom, @@ -1349,7 +1341,6 @@ export function MachineModeSections({ entitySectionIds: machineSectionIds, hasThreadsSection: machineSections.length === 0, showPinnedSection, - isReady, }); const reorderDisabled = order.length < 2; const builtInSections: BuiltInSidebarSectionOptionsById = { @@ -1464,6 +1455,7 @@ function ProjectListComponent({ } return map; }, [threads]); + const uiPreferencesReady = useUiPreferencesReady(); const projectsState = useConnectionAwareQueryState({ hasResolvedData: projects !== undefined, isFetching: sidebarNavigationQuery.isFetching, @@ -1921,7 +1913,7 @@ function ProjectListComponent({ ); - if (projectsState.status === "loading") { + if (projectsState.status === "loading" || !uiPreferencesReady) { return ( @@ -1941,7 +1933,6 @@ function ProjectListComponent({ pinnedSidebarState.effectivePinnedThreadIds } status={projectsState.status} - isReady={Boolean(sidebarNavigation)} showPinnedSection={hasPinnedSection} pinnedSection={pinnedSection} threadsSection={threadsSection} @@ -1966,7 +1957,6 @@ function ProjectListComponent({ pinnedSidebarState.effectivePinnedThreadIds } status={projectsState.status} - isReady={Boolean(sidebarNavigation)} showPinnedSection={hasPinnedSection} sections={sections} pinnedSection={pinnedSection} @@ -2012,7 +2002,6 @@ function ProjectListComponent({ pinnedSidebarState.effectivePinnedThreadIds } status={projectsState.status} - isReady={Boolean(sidebarNavigation)} showPinnedSection={hasPinnedSection} pinnedSection={pinnedSection} threadsSection={threadsSection} diff --git a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx index dd8dc2e02a6..12b3f7c0fac 100644 --- a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx @@ -47,7 +47,6 @@ import { } from "@/lib/route-paths"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { - SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY, sidebarOrganizationModeAtom, type SidebarOrganizationMode, } from "./sidebarCollapsedAtoms"; @@ -437,39 +436,8 @@ function OrganizationSidebar({ useLayoutEffect(() => { setIsModeSeeded(false); - - let localStorage: Storage | null = null; - let persistedMode: string | null = null; - - if (typeof window !== "undefined") { - try { - localStorage = window.localStorage; - persistedMode = localStorage.getItem( - SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY, - ); - } catch { - localStorage = null; - } - } - const unsubscribe = store.sub(sidebarOrganizationModeAtom, noop); - - try { - store.set(sidebarOrganizationModeAtom, mode); - } finally { - if (localStorage) { - try { - if (persistedMode === null) { - localStorage.removeItem(SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY); - } else { - localStorage.setItem( - SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY, - persistedMode, - ); - } - } catch {} - } - } + store.set(sidebarOrganizationModeAtom, mode); setIsModeSeeded(true); diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts deleted file mode 100644 index a2fa21f621d..00000000000 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// @vitest-environment jsdom - -import { createStore } from "jotai"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -afterEach(() => { - window.localStorage.clear(); - vi.resetModules(); -}); - -describe("sidebar section preference migration", () => { - it("preserves manual order and collapsed groups from folder-era storage", async () => { - window.localStorage.setItem( - "bb.sidebar.folderSectionOrder", - JSON.stringify(["threads", "folder:release", "folders", "pinned"]), - ); - window.localStorage.setItem( - "bb.sidebar.collapsedFolders", - JSON.stringify(["project-a::fld_release"]), - ); - - const { - sidebarCollapsedThreadSectionsAtom, - sidebarManualSectionOrderAtom, - } = await import("./sidebarCollapsedAtoms"); - const store = createStore(); - - expect(store.get(sidebarManualSectionOrderAtom)).toEqual([ - "threads", - "section:release", - "sections", - "pinned", - ]); - expect(store.get(sidebarCollapsedThreadSectionsAtom)).toEqual([ - "project-a::fld_release", - ]); - expect(window.localStorage.getItem("bb.sidebar.manualSectionOrder")).toBe( - JSON.stringify(["threads", "section:release", "sections", "pinned"]), - ); - expect( - window.localStorage.getItem("bb.sidebar.collapsedThreadSections"), - ).toBe(JSON.stringify(["project-a::fld_release"])); - expect( - window.localStorage.getItem("bb.sidebar.folderSectionOrder"), - ).toBeNull(); - expect( - window.localStorage.getItem("bb.sidebar.collapsedFolders"), - ).toBeNull(); - }); -}); diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index ddf4cbbadd4..b94184269c5 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -8,21 +8,12 @@ import { createJsonLocalStorage, type SyncStorage, } from "@/lib/browser-storage"; +import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; const COLLAPSED_PROJECTS_STORAGE_KEY = "bb.sidebar.collapsedProjects"; const COLLAPSED_THREADS_STORAGE_KEY = "bb.sidebar.collapsedThreads"; const COLLAPSED_ENVIRONMENTS_STORAGE_KEY = "bb.sidebar.collapsedEnvironments"; const COLLAPSED_SIDEBAR_SECTIONS_STORAGE_KEY = "bb.sidebar.collapsedSections"; -const SIDEBAR_SECTION_ORDER_STORAGE_KEY = "bb.sidebar.sectionOrder"; -const SIDEBAR_MANUAL_SECTION_ORDER_STORAGE_KEY = - "bb.sidebar.manualSectionOrder"; -const LEGACY_SIDEBAR_FOLDER_SECTION_ORDER_STORAGE_KEY = - "bb.sidebar.folderSectionOrder"; -const SIDEBAR_MACHINE_SECTION_ORDER_STORAGE_KEY = - "bb.sidebar.machineSectionOrder"; -export const SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY = - "bb.sidebar.organizationMode"; -const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; const COLLAPSED_THREAD_SECTIONS_STORAGE_KEY = "bb.sidebar.collapsedThreadSections"; const LEGACY_COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; @@ -35,12 +26,6 @@ export type { export type { SidebarChronologicalSort, SidebarOrganizationMode }; -const DEFAULT_SIDEBAR_SECTION_ORDER: readonly string[] = [ - "pinned", - "projects", - "threads", -]; - function createLegacyMigratingStringArrayStorage( legacyKey: string, migrateItem: (item: string) => string, @@ -87,17 +72,6 @@ function createLegacyMigratingStringArrayStorage( }; } -const sidebarManualSectionOrderStorage = - createLegacyMigratingStringArrayStorage( - LEGACY_SIDEBAR_FOLDER_SECTION_ORDER_STORAGE_KEY, - (item) => - item === "folders" - ? "sections" - : item.startsWith("folder:") - ? `section:${item.slice("folder:".length)}` - : item, - ); - const collapsedThreadSectionsStorage = createLegacyMigratingStringArrayStorage( LEGACY_COLLAPSED_FOLDERS_STORAGE_KEY, (item) => item, @@ -133,42 +107,25 @@ export const collapsedSidebarSectionIdsAtom = atomWithStorage< { getOnInit: true }, ); -export const sidebarSectionOrderAtom = atomWithStorage( - SIDEBAR_SECTION_ORDER_STORAGE_KEY, - [...DEFAULT_SIDEBAR_SECTION_ORDER], - createJsonLocalStorage(), - { getOnInit: true }, +export const sidebarSectionOrderAtom = createSyncedPreferenceAtom( + "sidebar.sectionOrder", ); -export const sidebarManualSectionOrderAtom = atomWithStorage( - SIDEBAR_MANUAL_SECTION_ORDER_STORAGE_KEY, - ["pinned", "sections", "threads"], - sidebarManualSectionOrderStorage, - { getOnInit: true }, +export const sidebarManualSectionOrderAtom = createSyncedPreferenceAtom( + "sidebar.manualSectionOrder", ); -export const sidebarMachineSectionOrderAtom = atomWithStorage( - SIDEBAR_MACHINE_SECTION_ORDER_STORAGE_KEY, - ["pinned", "machines", "threads"], - createJsonLocalStorage(), - { getOnInit: true }, +export const sidebarMachineSectionOrderAtom = createSyncedPreferenceAtom( + "sidebar.machineSectionOrder", ); -export const sidebarOrganizationModeAtom = - atomWithStorage( - SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY, - "project", - createJsonLocalStorage(), - { getOnInit: true }, - ); - -export const sidebarChronologicalSortAtom = - atomWithStorage( - CHRONOLOGICAL_SORT_STORAGE_KEY, - "updated", - createJsonLocalStorage(), - { getOnInit: true }, - ); +export const sidebarOrganizationModeAtom = createSyncedPreferenceAtom( + "sidebar.organizationMode", +); + +export const sidebarChronologicalSortAtom = createSyncedPreferenceAtom( + "sidebar.chronologicalSort", +); export const sidebarCollapsedThreadSectionsAtom = atomWithStorage( COLLAPSED_THREAD_SECTIONS_STORAGE_KEY, diff --git a/apps/app/src/components/sidebar/sidebarNavigationProvider.ts b/apps/app/src/components/sidebar/sidebarNavigationProvider.ts index faab9ce1e99..a697e81fccc 100644 --- a/apps/app/src/components/sidebar/sidebarNavigationProvider.ts +++ b/apps/app/src/components/sidebar/sidebarNavigationProvider.ts @@ -1,18 +1,14 @@ import { useAtomValue } from "jotai"; -import { - createReplacementPreferenceAtom, - resolvePreferredReplacement, -} from "@/lib/plugin-replacement-preference"; +import { resolvePreferredReplacement } from "@/lib/plugin-replacement-preference"; +import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; import { usePluginSlots, type ExperimentalSidebarNavigationSlot, } from "@/lib/plugin-slots"; -const SIDEBAR_NAVIGATION_PROVIDER_STORAGE_KEY = "bb.sidebar.navigationProvider"; - -export const sidebarNavigationProviderAtom = createReplacementPreferenceAtom( - SIDEBAR_NAVIGATION_PROVIDER_STORAGE_KEY, +export const sidebarNavigationProviderAtom = createSyncedPreferenceAtom( + "sidebar.navigationProvider", ); export function useSidebarNavigationReplacement(): ResolvedReplacement { diff --git a/apps/app/src/components/sidebar/threadListProvider.ts b/apps/app/src/components/sidebar/threadListProvider.ts index e28393217f0..50432aed2da 100644 --- a/apps/app/src/components/sidebar/threadListProvider.ts +++ b/apps/app/src/components/sidebar/threadListProvider.ts @@ -1,15 +1,11 @@ import { useAtomValue } from "jotai"; -import { - createReplacementPreferenceAtom, - resolvePreferredReplacement, -} from "@/lib/plugin-replacement-preference"; +import { resolvePreferredReplacement } from "@/lib/plugin-replacement-preference"; +import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; import { usePluginSlots, type PluginThreadListSlot } from "@/lib/plugin-slots"; -const THREAD_LIST_PROVIDER_STORAGE_KEY = "bb.sidebar.threadListProvider"; - -export const threadListProviderAtom = createReplacementPreferenceAtom( - THREAD_LIST_PROVIDER_STORAGE_KEY, +export const threadListProviderAtom = createSyncedPreferenceAtom( + "sidebar.threadListProvider", ); export function useThreadListReplacement(): ResolvedReplacement { diff --git a/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts b/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts index f34573b1347..e532e73f2dd 100644 --- a/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts +++ b/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts @@ -1,18 +1,15 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import type { SidebarSectionId } from "./sidebarCollapsedAtoms"; import { normalizeSidebarSectionOrder, type LegacySidebarEntityAnchor, } from "@bb/client-core"; -import { haveSameOrder } from "@/lib/stored-order"; interface UsePersistedSidebarSectionOrderArgs { entitySectionIds: readonly SidebarSectionId[]; hasPinnedSection: boolean; hasThreadsSection?: boolean; - isReady: boolean; legacyEntityAnchor: LegacySidebarEntityAnchor; - setStoredOrder: (order: string[]) => void; storedOrder: readonly string[]; } @@ -20,12 +17,10 @@ export function usePersistedSidebarSectionOrder({ entitySectionIds, hasPinnedSection, hasThreadsSection, - isReady, legacyEntityAnchor, - setStoredOrder, storedOrder, }: UsePersistedSidebarSectionOrderArgs): SidebarSectionId[] { - const order = useMemo( + return useMemo( () => normalizeSidebarSectionOrder({ storedOrder, @@ -42,11 +37,4 @@ export function usePersistedSidebarSectionOrder({ storedOrder, ], ); - - useEffect(() => { - if (!isReady || haveSameOrder(storedOrder, order)) return; - setStoredOrder(order); - }, [isReady, order, setStoredOrder, storedOrder]); - - return order; } diff --git a/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts b/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts index b48689d64b7..1e913a1d65f 100644 --- a/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts +++ b/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts @@ -34,7 +34,6 @@ const MODE_SECTION_ORDER_CONFIG: Record< interface UseSidebarModeSectionOrderArgs { entitySectionIds: readonly SidebarSectionId[]; hasThreadsSection?: boolean; - isReady: boolean; mode: SidebarOrganizationMode; showPinnedSection: boolean; } @@ -48,7 +47,6 @@ interface UseSidebarModeSectionOrderResult { export function useSidebarModeSectionOrder({ entitySectionIds, hasThreadsSection, - isReady, mode, showPinnedSection, }: UseSidebarModeSectionOrderArgs): UseSidebarModeSectionOrderResult { @@ -56,12 +54,10 @@ export function useSidebarModeSectionOrder({ const [storedOrder, setStoredOrder] = useAtom(config.atom); const persistedOrder = usePersistedSidebarSectionOrder({ storedOrder, - setStoredOrder, entitySectionIds, legacyEntityAnchor: config.legacyEntityAnchor, hasPinnedSection: true, ...(hasThreadsSection === undefined ? {} : { hasThreadsSection }), - isReady, }); const order = useMemo( () => diff --git a/apps/app/src/lib/ui-preferences/UiPreferencesSync.test.tsx b/apps/app/src/lib/ui-preferences/UiPreferencesSync.test.tsx new file mode 100644 index 00000000000..83bd64e6ef8 --- /dev/null +++ b/apps/app/src/lib/ui-preferences/UiPreferencesSync.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultUiPreferences } from "@bb/domain"; +import { useUiPreferencesReady } from "./UiPreferencesSync"; + +const mocks = vi.hoisted(() => ({ list: vi.fn() })); + +vi.mock("@/lib/sdk", async () => { + const actual = await import("@bb/sdk/browser"); + return { + BbHttpError: actual.BbHttpError, + sdk: { system: { uiPreferences: { list: mocks.list } } }, + }; +}); + +vi.mock("@/hooks/useRealtimeSubscription", () => ({ + useSystemRealtimeSubscription: () => {}, +})); + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +describe("useUiPreferencesReady", () => { + let queryClient: QueryClient; + + beforeEach(() => { + mocks.list.mockReset(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("stays not ready until the preferences query resolves", async () => { + let resolve: ((value: unknown) => void) | null = null; + mocks.list.mockImplementation( + () => + new Promise((r) => { + resolve = r; + }), + ); + const { result } = renderHook(() => useUiPreferencesReady(), { + wrapper: createWrapper(queryClient), + }); + expect(result.current).toBe(false); + await act(async () => { + resolve!({ + preferences: Object.fromEntries( + Object.entries(defaultUiPreferences).map(([key, value]) => [ + key, + { revision: 0, value }, + ]), + ), + }); + }); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("becomes ready with defaults when the query fails", async () => { + mocks.list.mockRejectedValue(new Error("offline")); + const { result } = renderHook(() => useUiPreferencesReady(), { + wrapper: createWrapper(queryClient), + }); + expect(result.current).toBe(false); + await waitFor(() => expect(result.current).toBe(true)); + }); +}); diff --git a/apps/app/src/lib/ui-preferences/UiPreferencesSync.tsx b/apps/app/src/lib/ui-preferences/UiPreferencesSync.tsx new file mode 100644 index 00000000000..e267ae399dc --- /dev/null +++ b/apps/app/src/lib/ui-preferences/UiPreferencesSync.tsx @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useStore } from "jotai"; +import { useUiPreferences } from "@/hooks/queries/system-queries"; +import { + reconcileUiPreferences, + startUiPreferencesSync, +} from "./ui-preferences-sync"; + +export function useUiPreferencesReady(): boolean { + const { data, isError } = useUiPreferences(); + return data !== undefined || isError; +} + +export function UiPreferencesSync() { + const queryClient = useQueryClient(); + const store = useStore(); + const { data } = useUiPreferences(); + + useEffect( + () => startUiPreferencesSync({ queryClient, store }), + [queryClient, store], + ); + + useEffect(() => { + if (data !== undefined) reconcileUiPreferences(data); + }, [data]); + + return null; +} diff --git a/apps/app/src/lib/ui-preferences/legacy-local-preferences.test.ts b/apps/app/src/lib/ui-preferences/legacy-local-preferences.test.ts new file mode 100644 index 00000000000..1264241acbe --- /dev/null +++ b/apps/app/src/lib/ui-preferences/legacy-local-preferences.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vitest"; +import { + clearLegacyLocalUiPreference, + readLegacyLocalUiPreference, +} from "./legacy-local-preferences"; + +function seed(key: string, value: unknown): void { + window.localStorage.setItem(key, JSON.stringify(value)); +} + +describe("legacy local ui preferences", () => { + afterEach(() => { + window.localStorage.clear(); + }); + + it("returns undefined when nothing was stored and drops invalid values", () => { + expect( + readLegacyLocalUiPreference("sidebar.organizationMode"), + ).toBeUndefined(); + seed("bb.sidebar.organizationMode", "by-color"); + expect( + readLegacyLocalUiPreference("sidebar.organizationMode"), + ).toBeUndefined(); + window.localStorage.setItem("bb.sidebar.collapsedProjects", "{not json"); + expect( + readLegacyLocalUiPreference("sidebar.collapsedProjects"), + ).toBeUndefined(); + seed("bb.sidebar.collapsedThreads", ["thr_a", 2]); + expect( + readLegacyLocalUiPreference("sidebar.collapsedThreads"), + ).toBeUndefined(); + }); + + it("reads values from their old browser keys", () => { + seed("bb.sidebar.organizationMode", "machine"); + seed("bb.sidebar.collapsedThreads", ["thr_a", "thr_b"]); + seed("bb.sidebar.navigationProvider", "docs/main"); + seed("bb.sidebar.visiblePluginPanels", ["docs/main"]); + expect(readLegacyLocalUiPreference("sidebar.organizationMode")).toBe( + "machine", + ); + expect(readLegacyLocalUiPreference("sidebar.collapsedThreads")).toEqual([ + "thr_a", + "thr_b", + ]); + expect(readLegacyLocalUiPreference("sidebar.navigationProvider")).toBe( + "docs/main", + ); + expect(readLegacyLocalUiPreference("sidebar.visiblePluginPanels")).toEqual([ + "docs/main", + ]); + }); + + it("ignores retired folder-era and hidden-panel keys", () => { + seed("bb.sidebar.folderSectionOrder", ["pinned", "folders"]); + seed("bb.sidebar.collapsedFolders", ["proj_1::sec_1"]); + seed("bb.sidebar.hiddenPluginPanels", ["docs/main"]); + expect( + readLegacyLocalUiPreference("sidebar.manualSectionOrder"), + ).toBeUndefined(); + expect( + readLegacyLocalUiPreference("sidebar.collapsedThreadSections"), + ).toBeUndefined(); + expect( + readLegacyLocalUiPreference("sidebar.pluginPanelOrder"), + ).toBeUndefined(); + expect( + readLegacyLocalUiPreference("sidebar.visiblePluginPanels"), + ).toBeUndefined(); + }); + + it("clears the old key and its retired predecessors", () => { + seed("bb.sidebar.pluginPanelOrder", ["docs/main"]); + seed("bb.sidebar.hiddenPluginPanels", ["docs/main"]); + seed("bb.sidebar.collapsedThreadSections", ["proj_1::sec_1"]); + seed("bb.sidebar.collapsedFolders", ["proj_1::sec_1"]); + seed("bb.sidebar.manualSectionOrder", ["pinned"]); + seed("bb.sidebar.folderSectionOrder", ["pinned"]); + clearLegacyLocalUiPreference("sidebar.pluginPanelOrder"); + clearLegacyLocalUiPreference("sidebar.collapsedThreadSections"); + clearLegacyLocalUiPreference("sidebar.manualSectionOrder"); + for (const key of [ + "bb.sidebar.pluginPanelOrder", + "bb.sidebar.hiddenPluginPanels", + "bb.sidebar.collapsedThreadSections", + "bb.sidebar.collapsedFolders", + "bb.sidebar.manualSectionOrder", + "bb.sidebar.folderSectionOrder", + ]) { + expect(window.localStorage.getItem(key)).toBeNull(); + } + }); +}); diff --git a/apps/app/src/lib/ui-preferences/legacy-local-preferences.ts b/apps/app/src/lib/ui-preferences/legacy-local-preferences.ts new file mode 100644 index 00000000000..e791a5408bb --- /dev/null +++ b/apps/app/src/lib/ui-preferences/legacy-local-preferences.ts @@ -0,0 +1,45 @@ +import { + parseUiPreferenceValue, + type UiPreferenceKey, + type UiPreferenceValue, +} from "@bb/domain"; +import { withLocalStorage } from "@/lib/browser-storage"; + +const RETIRED_LOCAL_STORAGE_KEYS: Partial< + Record +> = { + "sidebar.manualSectionOrder": ["bb.sidebar.folderSectionOrder"], + "sidebar.collapsedThreadSections": ["bb.sidebar.collapsedFolders"], + "sidebar.pluginPanelOrder": ["bb.sidebar.hiddenPluginPanels"], +}; + +function legacyLocalStorageKey(key: UiPreferenceKey): string { + return `bb.${key}`; +} + +export function readLegacyLocalUiPreference( + key: Key, +): UiPreferenceValue | undefined { + const text = withLocalStorage( + (storage) => storage.getItem(legacyLocalStorageKey(key)), + null, + ); + if (text === null) return undefined; + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return undefined; + } + const parsed = parseUiPreferenceValue(key, raw); + return parsed.success ? parsed.value : undefined; +} + +export function clearLegacyLocalUiPreference(key: UiPreferenceKey): void { + withLocalStorage((storage) => { + storage.removeItem(legacyLocalStorageKey(key)); + for (const storageKey of RETIRED_LOCAL_STORAGE_KEYS[key] ?? []) { + storage.removeItem(storageKey); + } + }, undefined); +} diff --git a/apps/app/src/lib/ui-preferences/synced-preference-atom.ts b/apps/app/src/lib/ui-preferences/synced-preference-atom.ts new file mode 100644 index 00000000000..811f12bb54c --- /dev/null +++ b/apps/app/src/lib/ui-preferences/synced-preference-atom.ts @@ -0,0 +1,31 @@ +import { atom, type SetStateAction, type WritableAtom } from "jotai"; +import { + getUiPreferenceDefault, + type UiPreferenceKey, + type UiPreferenceValue, +} from "@bb/domain"; +import { + registerSyncedUiPreference, + scheduleUiPreferenceWrite, +} from "./ui-preferences-sync"; + +export type SyncedPreferenceAtom = WritableAtom< + UiPreferenceValue, + [SetStateAction>], + void +>; + +export function createSyncedPreferenceAtom( + key: Key, +): SyncedPreferenceAtom { + const valueAtom = atom>(getUiPreferenceDefault(key)); + registerSyncedUiPreference(key, { valueAtom }); + return atom( + (get) => get(valueAtom), + (get, set, update: SetStateAction>) => { + const previous = get(valueAtom); + set(valueAtom, typeof update === "function" ? update(previous) : update); + scheduleUiPreferenceWrite(key, update); + }, + ); +} diff --git a/apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts b/apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts new file mode 100644 index 00000000000..c7febce6dfb --- /dev/null +++ b/apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts @@ -0,0 +1,637 @@ +// @vitest-environment jsdom + +import { QueryClient } from "@tanstack/react-query"; +import { createStore } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BbHttpError } from "@bb/sdk/browser"; +import { + defaultUiPreferences, + type UiPreferenceKey, + type UiPreferenceValue, +} from "@bb/domain"; +import type { UiPreferencesResponse } from "@bb/server-contract"; +import { + getCachedUiPreferences, + setCachedUiPreferences, +} from "@/hooks/cache-owners/ui-preferences-cache-owner"; +import { createSyncedPreferenceAtom } from "./synced-preference-atom"; +import { + hasPendingUiPreferenceWrite, + reconcileUiPreferences, + resetUiPreferencesSyncForTest, + startUiPreferencesSync, + waitForUiPreferenceWrites, +} from "./ui-preferences-sync"; + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + set: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("@/lib/sdk", async () => { + const actual = await import("@bb/sdk/browser"); + return { + BbHttpError: actual.BbHttpError, + sdk: { + system: { + uiPreferences: { + list: mocks.list, + set: mocks.set, + }, + }, + }, + }; +}); + +vi.mock("@/components/ui/app-toast", () => ({ + appToast: { error: mocks.toastError }, +})); + +function serverResponse( + overrides: Partial<{ + [Key in UiPreferenceKey]: { + revision: number; + value: UiPreferenceValue; + }; + }> = {}, +): UiPreferencesResponse { + const preferences = Object.fromEntries( + Object.entries(defaultUiPreferences).map(([key, value]) => [ + key, + { revision: 0, value }, + ]), + ) as UiPreferencesResponse["preferences"]; + return { preferences: { ...preferences, ...overrides } }; +} + +function conflict(currentRevision: number): BbHttpError { + return new BbHttpError({ + body: { code: "ui_preference_conflict", details: { currentRevision } }, + code: "ui_preference_conflict", + message: "UI preference changed on another client", + status: 409, + }); +} + +function createHarness() { + const queryClient = new QueryClient(); + const store = createStore(); + const modeAtom = createSyncedPreferenceAtom("sidebar.organizationMode"); + const orderAtom = createSyncedPreferenceAtom("sidebar.sectionOrder"); + const collapsedAtom = createSyncedPreferenceAtom("sidebar.collapsedProjects"); + return { collapsedAtom, modeAtom, orderAtom, queryClient, store }; +} + +describe("ui preferences sync", () => { + beforeEach(() => { + window.localStorage.clear(); + mocks.list.mockReset(); + mocks.set.mockReset(); + mocks.toastError.mockReset(); + mocks.set.mockImplementation( + async (input: { + key: string; + value: unknown; + expectedRevision: number; + }) => ({ + key: input.key, + revision: input.expectedRevision + 1, + value: input.value, + }), + ); + }); + + afterEach(() => { + resetUiPreferencesSyncForTest(); + }); + + it("keeps writes local when no sync context has started", () => { + const { modeAtom, store } = createHarness(); + store.set(modeAtom, "machine"); + expect(store.get(modeAtom)).toBe("machine"); + expect(mocks.set).not.toHaveBeenCalled(); + expect(hasPendingUiPreferenceWrite("sidebar.organizationMode")).toBe(false); + }); + + it("adopts the server value over local state once it has a revision", () => { + const { modeAtom, queryClient, store } = createHarness(); + store.set(modeAtom, "chronological"); + startUiPreferencesSync({ queryClient, store }); + reconcileUiPreferences( + serverResponse({ + "sidebar.organizationMode": { revision: 2, value: "machine" }, + }), + ); + expect(store.get(modeAtom)).toBe("machine"); + expect(mocks.set).not.toHaveBeenCalled(); + }); + + it("uploads a legacy browser value once when the server has no revision yet", async () => { + window.localStorage.setItem( + "bb.sidebar.organizationMode", + '"chronological"', + ); + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + const response = serverResponse(); + setCachedUiPreferences(queryClient, response); + reconcileUiPreferences(response); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledWith({ + expectedRevision: 0, + key: "sidebar.organizationMode", + value: "chronological", + }); + expect( + getCachedUiPreferences(queryClient)?.preferences[ + "sidebar.organizationMode" + ], + ).toEqual({ revision: 1, value: "chronological" }); + expect(store.get(modeAtom)).toBe("chronological"); + expect( + window.localStorage.getItem("bb.sidebar.organizationMode"), + ).toBeNull(); + + reconcileUiPreferences(serverResponse()); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledTimes(1); + }); + + it("does not upload a legacy value that equals the default", async () => { + window.localStorage.setItem("bb.sidebar.organizationMode", '"project"'); + const { queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + reconcileUiPreferences(serverResponse()); + await waitForUiPreferenceWrites(); + expect(mocks.set).not.toHaveBeenCalled(); + expect( + window.localStorage.getItem("bb.sidebar.organizationMode"), + ).toBeNull(); + }); + + it("writes with the cached revision and records the new entry", async () => { + const { orderAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.sectionOrder": { + revision: 3, + value: ["pinned", "projects", "threads"], + }, + }), + ); + store.set(orderAtom, ["threads", "pinned", "projects"]); + expect(hasPendingUiPreferenceWrite("sidebar.sectionOrder")).toBe(true); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledWith({ + expectedRevision: 3, + key: "sidebar.sectionOrder", + value: ["threads", "pinned", "projects"], + }); + expect( + getCachedUiPreferences(queryClient)?.preferences["sidebar.sectionOrder"], + ).toEqual({ revision: 4, value: ["threads", "pinned", "projects"] }); + expect(mocks.list).not.toHaveBeenCalled(); + }); + + it("fetches the current revision when nothing is cached", async () => { + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + mocks.list.mockResolvedValueOnce( + serverResponse({ + "sidebar.organizationMode": { revision: 5, value: "project" }, + }), + ); + store.set(modeAtom, "machine"); + await waitForUiPreferenceWrites(); + expect(mocks.list).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledWith({ + expectedRevision: 5, + key: "sidebar.organizationMode", + value: "machine", + }); + }); + + it("re-applies a functional update on top of the server value after a conflict", async () => { + const { collapsedAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.collapsedProjects": { revision: 1, value: ["prj_a"] }, + }), + ); + reconcileUiPreferences(getCachedUiPreferences(queryClient)!); + mocks.set.mockRejectedValueOnce(conflict(2)); + mocks.list.mockResolvedValueOnce( + serverResponse({ + "sidebar.collapsedProjects": { + revision: 2, + value: ["prj_a", "prj_other"], + }, + }), + ); + store.set(collapsedAtom, (current) => [...current, "prj_b"]); + expect(store.get(collapsedAtom)).toEqual(["prj_a", "prj_b"]); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenNthCalledWith(1, { + expectedRevision: 1, + key: "sidebar.collapsedProjects", + value: ["prj_a", "prj_b"], + }); + expect(mocks.set).toHaveBeenNthCalledWith(2, { + expectedRevision: 2, + key: "sidebar.collapsedProjects", + value: ["prj_a", "prj_other", "prj_b"], + }); + expect(store.get(collapsedAtom)).toEqual(["prj_a", "prj_other", "prj_b"]); + expect( + getCachedUiPreferences(queryClient)?.preferences[ + "sidebar.collapsedProjects" + ], + ).toEqual({ revision: 3, value: ["prj_a", "prj_other", "prj_b"] }); + }); + + it("retries a plain value write with the fresh revision after a conflict", async () => { + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.organizationMode": { revision: 1, value: "project" }, + }), + ); + mocks.set.mockRejectedValueOnce(conflict(4)); + mocks.list.mockResolvedValueOnce( + serverResponse({ + "sidebar.organizationMode": { revision: 4, value: "chronological" }, + }), + ); + store.set(modeAtom, "machine"); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenLastCalledWith({ + expectedRevision: 4, + key: "sidebar.organizationMode", + value: "machine", + }); + expect(store.get(modeAtom)).toBe("machine"); + }); + + it("adopts the server value when the retry also conflicts", async () => { + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.organizationMode": { revision: 1, value: "project" }, + }), + ); + mocks.set + .mockRejectedValueOnce(conflict(2)) + .mockRejectedValueOnce(conflict(3)); + mocks.list + .mockResolvedValueOnce( + serverResponse({ + "sidebar.organizationMode": { revision: 2, value: "chronological" }, + }), + ) + .mockResolvedValueOnce( + serverResponse({ + "sidebar.organizationMode": { revision: 3, value: "project" }, + }), + ); + store.set(modeAtom, "machine"); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledTimes(2); + expect(store.get(modeAtom)).toBe("project"); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); + + it("coalesces writes issued while one is in flight and skips reconcile meanwhile", async () => { + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.organizationMode": { revision: 1, value: "project" }, + }), + ); + let releaseFirst: (() => void) | null = null; + mocks.set.mockImplementationOnce( + (input: { value: unknown; expectedRevision: number; key: string }) => + new Promise((resolve) => { + releaseFirst = () => + resolve({ + key: input.key, + revision: input.expectedRevision + 1, + value: input.value, + }); + }), + ); + store.set(modeAtom, "machine"); + store.set(modeAtom, "project"); + store.set(modeAtom, "chronological"); + reconcileUiPreferences( + serverResponse({ + "sidebar.organizationMode": { revision: 9, value: "project" }, + }), + ); + expect(store.get(modeAtom)).toBe("chronological"); + await vi.waitFor(() => expect(mocks.set).toHaveBeenCalledTimes(1)); + releaseFirst!(); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledTimes(2); + expect(mocks.set).toHaveBeenLastCalledWith({ + expectedRevision: 2, + key: "sidebar.organizationMode", + value: "chronological", + }); + expect(store.get(modeAtom)).toBe("chronological"); + }); + + it("composes functional updates queued behind an in-flight write into one request", async () => { + const { collapsedAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.collapsedProjects": { revision: 1, value: [] }, + }), + ); + store.set(collapsedAtom, (current) => [...current, "prj_a"]); + store.set(collapsedAtom, (current) => [...current, "prj_b"]); + store.set(collapsedAtom, (current) => + current.filter((id) => id !== "prj_a"), + ); + expect(store.get(collapsedAtom)).toEqual(["prj_b"]); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledTimes(2); + expect(mocks.set).toHaveBeenNthCalledWith(1, { + expectedRevision: 1, + key: "sidebar.collapsedProjects", + value: ["prj_a"], + }); + expect(mocks.set).toHaveBeenNthCalledWith(2, { + expectedRevision: 2, + key: "sidebar.collapsedProjects", + value: ["prj_b"], + }); + expect(store.get(collapsedAtom)).toEqual(["prj_b"]); + }); + + it("skips a write whose value already matches the server", async () => { + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.organizationMode": { revision: 1, value: "machine" }, + }), + ); + store.set(modeAtom, "machine"); + await waitForUiPreferenceWrites(); + expect(mocks.set).not.toHaveBeenCalled(); + }); + + it("toasts once and invalidates the query on a non-conflict failure", async () => { + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.organizationMode": { revision: 1, value: "project" }, + }), + ); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + mocks.set.mockRejectedValueOnce(new Error("offline")); + store.set(modeAtom, "machine"); + await waitForUiPreferenceWrites(); + mocks.set.mockRejectedValueOnce(new Error("offline")); + store.set(modeAtom, "chronological"); + await waitForUiPreferenceWrites(); + expect(mocks.toastError).toHaveBeenCalledTimes(1); + expect(invalidate).toHaveBeenCalledTimes(2); + expect(store.get(modeAtom)).toBe("chronological"); + }); + + it("never retries a migration against a newer revision", async () => { + window.localStorage.setItem( + "bb.sidebar.organizationMode", + '"chronological"', + ); + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + const response = serverResponse(); + setCachedUiPreferences(queryClient, response); + mocks.set.mockRejectedValueOnce(conflict(1)); + mocks.list.mockResolvedValueOnce( + serverResponse({ + "sidebar.organizationMode": { revision: 1, value: "machine" }, + }), + ); + reconcileUiPreferences(response); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledWith({ + expectedRevision: 0, + key: "sidebar.organizationMode", + value: "chronological", + }); + expect(store.get(modeAtom)).toBe("machine"); + }); + + it("does not let a delayed write response roll back a newer cached revision", async () => { + const { modeAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.organizationMode": { revision: 1, value: "project" }, + }), + ); + let release: (() => void) | null = null; + mocks.set.mockImplementationOnce( + (input: { value: unknown; expectedRevision: number; key: string }) => + new Promise((resolve) => { + release = () => + resolve({ + key: input.key, + revision: input.expectedRevision + 1, + value: input.value, + }); + }), + ); + store.set(modeAtom, "machine"); + await vi.waitFor(() => expect(mocks.set).toHaveBeenCalledTimes(1)); + const newer = serverResponse({ + "sidebar.organizationMode": { revision: 3, value: "chronological" }, + }); + setCachedUiPreferences(queryClient, newer); + reconcileUiPreferences(newer); + expect(store.get(modeAtom)).toBe("machine"); + release!(); + await waitForUiPreferenceWrites(); + expect( + getCachedUiPreferences(queryClient)?.preferences[ + "sidebar.organizationMode" + ], + ).toEqual({ revision: 3, value: "chronological" }); + expect(store.get(modeAtom)).toBe("chronological"); + }); + + it("re-applies every queued functional update when their write conflicts", async () => { + const { collapsedAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.collapsedProjects": { revision: 1, value: [] }, + }), + ); + let releaseFirst: (() => void) | null = null; + mocks.set + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = () => + resolve({ + key: "sidebar.collapsedProjects", + revision: 2, + value: ["prj_a"], + }); + }), + ) + .mockRejectedValueOnce(conflict(3)); + mocks.list.mockResolvedValueOnce( + serverResponse({ + "sidebar.collapsedProjects": { + revision: 3, + value: ["prj_a", "remote"], + }, + }), + ); + store.set(collapsedAtom, (current) => [...current, "prj_a"]); + await vi.waitFor(() => expect(mocks.set).toHaveBeenCalledTimes(1)); + store.set(collapsedAtom, (current) => [...current, "prj_b"]); + store.set(collapsedAtom, (current) => [...current, "prj_c"]); + releaseFirst!(); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledTimes(3); + expect(mocks.set).toHaveBeenNthCalledWith(2, { + expectedRevision: 2, + key: "sidebar.collapsedProjects", + value: ["prj_a", "prj_b", "prj_c"], + }); + expect(mocks.set).toHaveBeenNthCalledWith(3, { + expectedRevision: 3, + key: "sidebar.collapsedProjects", + value: ["prj_a", "remote", "prj_b", "prj_c"], + }); + expect(store.get(collapsedAtom)).toEqual([ + "prj_a", + "remote", + "prj_b", + "prj_c", + ]); + }); + + it("evaluates a functional update against the server value when local state is stale", async () => { + const { collapsedAtom, queryClient, store } = createHarness(); + store.set(collapsedAtom, ["stale_local"]); + startUiPreferencesSync({ queryClient, store }); + mocks.list.mockResolvedValueOnce( + serverResponse({ + "sidebar.collapsedProjects": { revision: 4, value: ["remote"] }, + }), + ); + store.set(collapsedAtom, (current) => [...current, "prj_a"]); + expect(store.get(collapsedAtom)).toEqual(["stale_local", "prj_a"]); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenCalledWith({ + expectedRevision: 4, + key: "sidebar.collapsedProjects", + value: ["remote", "prj_a"], + }); + expect(store.get(collapsedAtom)).toEqual(["remote", "prj_a"]); + }); + + it("applies a queued functional update on top of a conflicting in-flight write", async () => { + const { orderAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.sectionOrder": { revision: 1, value: ["remote"] }, + }), + ); + let rejectFirst: (() => void) | null = null; + mocks.set.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = () => reject(conflict(2)); + }), + ); + mocks.list.mockResolvedValueOnce( + serverResponse({ + "sidebar.sectionOrder": { revision: 2, value: ["remote", "other"] }, + }), + ); + store.set(orderAtom, (current) => [...current, "a"]); + await vi.waitFor(() => expect(mocks.set).toHaveBeenCalledTimes(1)); + store.set(orderAtom, (current) => [...current, "b"]); + rejectFirst!(); + await waitForUiPreferenceWrites(); + expect(mocks.set).toHaveBeenNthCalledWith(2, { + expectedRevision: 2, + key: "sidebar.sectionOrder", + value: ["remote", "other", "a"], + }); + expect(mocks.set).toHaveBeenNthCalledWith(3, { + expectedRevision: 3, + key: "sidebar.sectionOrder", + value: ["remote", "other", "a", "b"], + }); + expect( + getCachedUiPreferences(queryClient)?.preferences["sidebar.sectionOrder"], + ).toEqual({ revision: 4, value: ["remote", "other", "a", "b"] }); + expect(store.get(orderAtom)).toEqual(["remote", "other", "a", "b"]); + }); + + it("reconciles the mirror to the cached entry once the pending write settles", async () => { + const { collapsedAtom, queryClient, store } = createHarness(); + startUiPreferencesSync({ queryClient, store }); + setCachedUiPreferences( + queryClient, + serverResponse({ + "sidebar.collapsedProjects": { revision: 1, value: [] }, + }), + ); + let release: (() => void) | null = null; + mocks.set.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => + resolve({ + key: "sidebar.collapsedProjects", + revision: 2, + value: [], + }); + }), + ); + store.set(collapsedAtom, (current) => [...current, "deleted"]); + await vi.waitFor(() => expect(mocks.set).toHaveBeenCalledTimes(1)); + const broadcast = serverResponse({ + "sidebar.collapsedProjects": { revision: 2, value: [] }, + }); + setCachedUiPreferences(queryClient, broadcast); + reconcileUiPreferences(broadcast); + expect(store.get(collapsedAtom)).toEqual(["deleted"]); + release!(); + await waitForUiPreferenceWrites(); + expect(store.get(collapsedAtom)).toEqual([]); + expect(mocks.list).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/lib/ui-preferences/ui-preferences-sync.ts b/apps/app/src/lib/ui-preferences/ui-preferences-sync.ts new file mode 100644 index 00000000000..1ade80eb6af --- /dev/null +++ b/apps/app/src/lib/ui-preferences/ui-preferences-sync.ts @@ -0,0 +1,306 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { SetStateAction, WritableAtom } from "jotai"; +import { getDefaultStore } from "jotai"; +import { + getUiPreferenceDefault, + type UiPreferenceEntry, + type UiPreferenceKey, + type UiPreferenceValue, +} from "@bb/domain"; +import type { UiPreferencesResponse } from "@bb/server-contract"; +import { appToast } from "@/components/ui/app-toast"; +import { + getCachedUiPreferences, + invalidateCachedUiPreferences, + setCachedUiPreferences, +} from "@/hooks/cache-owners/ui-preferences-cache-owner"; +import { BbHttpError, sdk } from "../sdk"; +import { + clearLegacyLocalUiPreference, + readLegacyLocalUiPreference, +} from "./legacy-local-preferences"; + +type JotaiStore = ReturnType; + +type ValueAtom = WritableAtom< + UiPreferenceValue, + [UiPreferenceValue], + void +>; + +interface RegisteredPreference { + valueAtom: ValueAtom; +} + +interface PreferenceOperation { + source: "migration" | "user"; + update: SetStateAction>; +} + +interface PreferenceSyncState { + inFlight: Promise | null; + migrationAttempted: boolean; + pending: PreferenceOperation[] | null; +} + +interface UiPreferencesSyncContext { + queryClient: QueryClient; + store: JotaiStore; +} + +const MAX_WRITE_ATTEMPTS = 2; + +const registry = new Map< + UiPreferenceKey, + RegisteredPreference +>(); +const syncStates = new Map< + UiPreferenceKey, + PreferenceSyncState +>(); +let context: UiPreferencesSyncContext | null = null; +let syncFailureNotified = false; + +function getSyncState( + key: Key, +): PreferenceSyncState { + let state = syncStates.get(key); + if (state === undefined) { + state = { inFlight: null, migrationAttempted: false, pending: null }; + syncStates.set(key, state); + } + return state as PreferenceSyncState; +} + +function areUiPreferenceValuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function applyOperations( + operations: readonly PreferenceOperation[], + base: UiPreferenceValue, +): UiPreferenceValue { + return operations.reduce( + (value, { update }) => + typeof update === "function" ? update(value) : update, + base, + ); +} + +function composeOperation( + operations: readonly PreferenceOperation[], + operation: PreferenceOperation, +): PreferenceOperation[] { + return typeof operation.update === "function" + ? [...operations, operation] + : [operation]; +} + +export function registerSyncedUiPreference( + key: Key, + registration: RegisteredPreference, +): void { + registry.set(key, registration as RegisteredPreference); +} + +export function startUiPreferencesSync( + nextContext: UiPreferencesSyncContext, +): () => void { + context = nextContext; + const cached = getCachedUiPreferences(nextContext.queryClient); + if (cached !== undefined) reconcileUiPreferences(cached); + return () => { + if (context === nextContext) context = null; + }; +} + +export function hasPendingUiPreferenceWrite(key: UiPreferenceKey): boolean { + const state = syncStates.get(key); + return ( + state !== undefined && (state.pending !== null || state.inFlight !== null) + ); +} + +export function reconcileUiPreferences(response: UiPreferencesResponse): void { + if (context === null) return; + for (const [key, { valueAtom }] of registry) { + reconcileUiPreference(context, key, valueAtom, response); + } +} + +function reconcileUiPreference( + activeContext: UiPreferencesSyncContext, + key: Key, + valueAtom: ValueAtom, + response: UiPreferencesResponse, +): void { + const state = getSyncState(key); + if (state.pending !== null || state.inFlight !== null) return; + const entry = response.preferences[key]; + if (entry.revision === 0 && !state.migrationAttempted) { + state.migrationAttempted = true; + const legacy = readLegacyLocalUiPreference(key); + clearLegacyLocalUiPreference(key); + if ( + legacy !== undefined && + !areUiPreferenceValuesEqual(legacy, getUiPreferenceDefault(key)) + ) { + activeContext.store.set(valueAtom, legacy); + state.pending = [{ source: "migration", update: legacy }]; + void flushUiPreference(key); + return; + } + } + if (entry.revision === 0) return; + clearLegacyLocalUiPreference(key); + if ( + areUiPreferenceValuesEqual(activeContext.store.get(valueAtom), entry.value) + ) { + return; + } + activeContext.store.set(valueAtom, entry.value); +} + +export function scheduleUiPreferenceWrite( + key: Key, + update: SetStateAction>, +): void { + if (context === null) return; + const state = getSyncState(key); + state.pending = composeOperation(state.pending ?? [], { + source: "user", + update, + }); + if (state.inFlight === null) void flushUiPreference(key); +} + +async function readCurrentUiPreferences( + queryClient: QueryClient, +): Promise { + const cached = getCachedUiPreferences(queryClient); + if (cached !== undefined) return cached; + return refetchUiPreferences(queryClient); +} + +async function refetchUiPreferences( + queryClient: QueryClient, +): Promise { + const response = await sdk.system.uiPreferences.list(); + setCachedUiPreferences(queryClient, response); + return response; +} + +function isUiPreferenceConflict(error: unknown): boolean { + return error instanceof BbHttpError && error.status === 409; +} + +function recordServerEntry( + queryClient: QueryClient, + key: Key, + entry: UiPreferenceEntry, +): void { + const cached = getCachedUiPreferences(queryClient); + if ( + cached === undefined || + cached.preferences[key].revision >= entry.revision + ) { + return; + } + setCachedUiPreferences(queryClient, { + preferences: { ...cached.preferences, [key]: entry }, + }); +} + +async function writeUiPreference( + queryClient: QueryClient, + key: Key, + operations: readonly PreferenceOperation[], +): Promise { + let base = (await readCurrentUiPreferences(queryClient)).preferences[key]; + for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) { + const applicable = + base.revision === 0 + ? operations + : operations.filter((operation) => operation.source === "user"); + const value = applyOperations(applicable, base.value); + if (areUiPreferenceValuesEqual(value, base.value)) return; + try { + const response = await sdk.system.uiPreferences.set({ + expectedRevision: base.revision, + key, + value, + }); + recordServerEntry(queryClient, key, { + revision: response.revision, + value: response.value, + }); + return; + } catch (error) { + if (!isUiPreferenceConflict(error)) throw error; + } + base = (await refetchUiPreferences(queryClient)).preferences[key]; + } +} + +function notifySyncFailure(error: unknown): void { + if (syncFailureNotified) return; + syncFailureNotified = true; + appToast.error("Couldn’t sync sidebar preferences", { + description: + error instanceof Error ? error.message : "Changes stay on this device.", + }); +} + +async function flushUiPreference( + key: Key, +): Promise { + const activeContext = context; + const state = getSyncState(key); + const operations = state.pending; + if ( + activeContext === null || + operations === null || + state.inFlight !== null + ) { + return; + } + state.pending = null; + let failed = false; + const run = writeUiPreference(activeContext.queryClient, key, operations) + .catch((error: unknown) => { + failed = true; + notifySyncFailure(error); + invalidateCachedUiPreferences(activeContext.queryClient); + }) + .finally(() => { + state.inFlight = null; + if (state.pending !== null) { + void flushUiPreference(key); + return; + } + if (failed) return; + const cached = getCachedUiPreferences(activeContext.queryClient); + if (cached !== undefined) reconcileUiPreferences(cached); + }); + state.inFlight = run; + await run; +} + +export async function waitForUiPreferenceWrites(): Promise { + let settled = false; + while (!settled) { + settled = true; + for (const state of syncStates.values()) { + if (state.inFlight !== null) { + settled = false; + await state.inFlight; + } + } + } +} + +export function resetUiPreferencesSyncForTest(): void { + syncStates.clear(); + context = null; + syncFailureNotified = false; +} diff --git a/docs/configuration.md b/docs/configuration.md index 276c3b4bc0b..205b6de34fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -604,13 +604,11 @@ a transient failure. ## Sidebar preferences -Sidebar layout preferences have a keyed registry on the server that the CLI -and SDK read and write. Each key has a typed schema, a default, and a revision -that increments on every write. Writes name the revision they expect and -receive `409 ui_preference_conflict` when another client wrote first, so a -stale writer cannot silently clobber a newer value. The app still keeps its -own copy of these values in the browser; a follow-up makes the sidebar read -and write the server registry. +Sidebar layout preferences are stored on the server in a keyed registry so +every window, device, and the CLI read the same value. Each key has a typed +schema, a default, and a revision that increments on every write. Writes name +the revision they expect and receive `409 ui_preference_conflict` when another +client wrote first, so a stale window cannot silently clobber a newer value. | Key | Value | | --------------------------------- | ------------------------------------------------------------ | @@ -649,6 +647,15 @@ expectedRevision })`, and `.reset({ key })` over `GET /preferences/ui`, `PUT /preferences/ui/:key`, and `DELETE /preferences/ui/:key`. Every write broadcasts a `ui-preferences-changed` system change to connected clients. +The sidebar waits for these values alongside the project list, so it never +paints a default layout that then snaps to the saved one. The first client to +reach a server that has never stored a key uploads the value it finds in the +old browser storage once, then deletes that copy, so an existing layout +survives the upgrade; a second device that loses that race adopts the server +value. A change on one device reaches every other connected window through the +`ui-preferences-changed` broadcast without a reload. Collapsed rows and +sections still live in the browser and move to the server in a follow-up. + Sidebar width and open state stay in the browser because they depend on the window size. diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 0afbd186f8a..e9cd138ccd3 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -216,11 +216,12 @@ metadata and machine-readable results. Server-backed sidebar preferences -The server keeps a keyed, revisioned registry of sidebar layout preferences -that the CLI and SDK read and write: organization mode, chronological sort, +Sidebar layout lives on the server in a keyed, revisioned registry so every +window, device, and the CLI share it: organization mode, chronological sort, section orders, collapsed rows and sections, navigation entry order and -visibility, and the navigation and thread-list provider pickers. The app still -keeps its own browser copy until a follow-up wires the sidebar to it. +visibility, and the navigation and thread-list provider pickers. The sidebar +waits for them alongside the project list, and an upgrade uploads the old +browser-stored layout once. bb settings ui list [--json] bb settings ui get [--json]