From f28dd5e1757d58948ff51b62fefe904e98bd0cc3 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 8 Sep 2026 23:17:40 +0000 Subject: [PATCH] Sync sidebar organization, ordering, and navigation preferences through the server Add a synced-preference atom that holds each UI preference in memory, hydrates from the server, and writes through a per-key queue with revision checks: queued edits compose as operations evaluated against the server value at the submitted revision, functional updates re-apply on conflict, cache updates stay revision-monotonic, and the atom reconciles to the cache when its queue drains. The sidebar waits for the preferences query alongside the project list instead of painting a default layout first. A one-shot migration uploads the value found in the old browser storage once when the server has none, then deletes that copy. Convert the organization mode, chronological sort, three section orders, navigation entry order and visibility, and both provider pickers, and stop persisting normalized orders on mount. Co-Authored-By: Claude Fable 5.1 --- apps/app/src/App.tsx | 2 + .../plugin/PluginNavSidebarItems.test.tsx | 12 +- .../plugin/PluginNavSidebarItems.tsx | 29 +- .../plugin/pluginNavSidebarAtoms.test.ts | 178 ----- .../plugin/pluginNavSidebarAtoms.ts | 149 +--- .../plugin/pluginNavSidebarOrder.test.ts | 18 - .../plugin/pluginNavSidebarOrder.ts | 14 - .../sidebar/ProjectList.modes.test.tsx | 2 - .../src/components/sidebar/ProjectList.tsx | 25 +- .../sidebar/SidebarOverview.stories.tsx | 34 +- .../sidebarCollapsedAtoms.migration.test.ts | 50 -- .../sidebar/sidebarCollapsedAtoms.ts | 71 +- .../sidebar/sidebarNavigationProvider.ts | 12 +- .../components/sidebar/threadListProvider.ts | 12 +- .../usePersistedSidebarSectionOrder.ts | 16 +- .../sidebar/useSidebarModeSectionOrder.ts | 4 - .../ui-preferences/UiPreferencesSync.test.tsx | 79 +++ .../lib/ui-preferences/UiPreferencesSync.tsx | 30 + .../legacy-local-preferences.test.ts | 95 +++ .../legacy-local-preferences.ts | 45 ++ .../ui-preferences/synced-preference-atom.ts | 31 + .../ui-preferences-sync.test.ts | 637 ++++++++++++++++++ .../lib/ui-preferences/ui-preferences-sync.ts | 306 +++++++++ docs/configuration.md | 21 +- .../src/templates/bb-guide-customization.md | 9 +- 25 files changed, 1300 insertions(+), 581 deletions(-) delete mode 100644 apps/app/src/components/plugin/pluginNavSidebarAtoms.test.ts delete mode 100644 apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts create mode 100644 apps/app/src/lib/ui-preferences/UiPreferencesSync.test.tsx create mode 100644 apps/app/src/lib/ui-preferences/UiPreferencesSync.tsx create mode 100644 apps/app/src/lib/ui-preferences/legacy-local-preferences.test.ts create mode 100644 apps/app/src/lib/ui-preferences/legacy-local-preferences.ts create mode 100644 apps/app/src/lib/ui-preferences/synced-preference-atom.ts create mode 100644 apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts create mode 100644 apps/app/src/lib/ui-preferences/ui-preferences-sync.ts 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]