Skip to content

Sync sidebar organization, ordering, and navigation preferences through the server - #3304

Merged
SawyerHood merged 1 commit into
bb/ui-preferences-serverfrom
bb/ui-preferences-client-sync
Sep 9, 2026
Merged

SawyerHood merged 1 commit into
bb/ui-preferences-serverfrom
bb/ui-preferences-client-sync

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

With the server registry in place (#3303), the app still reads and writes the sidebar organization mode, chronological sort, the three section orders, navigation entry order and visibility, and the navigation and thread-list provider pickers from localStorage only. Separately, usePersistedSidebarSectionOrder wrote the normalized order back on every mount where it differed, which is harmless for localStorage but would become a server PUT from every window whenever a project appears or disappears.

What changed

Layer 2 of 3 (stack #3306). Base: #3303.

  • apps/app/src/lib/ui-preferences/: createSyncedPreferenceAtom is a plain in-memory jotai atom per key. UiPreferencesSync (mounted in App) feeds the uiPreferences query into a controller that hydrates the atoms and writes through a per-key queue. Each queued edit is kept as an operation; functional updates compose in order and a plain value replaces the queue. The write path is one loop: read the base entry (cache or fetch), apply the operations to the server value at that revision, PUT with expectedRevision; on a 409 refetch and try once more with migration operations dropped; on a second 409 adopt the server value. Cache updates from write responses are monotonic in revision, and once a key's queue drains it goes through the same reconcile as query data, so a broadcast that arrived mid-write still lands. Writes issued while one is in flight compose into the next request. Non-conflict failures toast once and invalidate the query.
  • No localStorage mirror. The sidebar list now waits for the preferences query alongside the project list (useUiPreferencesReady in ProjectList), showing the same loading state, so it never paints a default layout that then snaps. A failed query counts as ready with defaults.
  • One-shot migration in legacy-local-preferences.ts: the first time a client sees a key at revision 0 it reads the old bb.sidebar.* browser value, validates it through the registry, uploads it with expectedRevision: 0, and deletes the old keys (including the retired folder-era and hidden-panel keys, which are cleared but no longer rewritten). A losing migration never retries. This file is the only piece meant to be deleted a release or two later.
  • Converted atoms: sidebarOrganizationModeAtom, sidebarChronologicalSortAtom, sidebarSectionOrderAtom, sidebarManualSectionOrderAtom, sidebarMachineSectionOrderAtom, pluginNavPanelOrderAtom, pluginNavVisiblePanelKeysAtom, sidebarNavigationProviderAtom, threadListProviderAtom. Consumers keep the same atom interface. All atomWithStorage wrappers, custom SyncStorage adapters, legacy-key rewrites, and cross-window storage event plumbing for these keys are deleted. A window that is hidden defers its refetch until it becomes visible, which is the app's existing fetch-suspension behavior; the old localStorage mirror had masked that for these keys.
  • usePersistedSidebarSectionOrder only derives the normalized order; PluginNavSidebarItems no longer writes normalized order or visibility back on mount and persists the normalized order with the next user action instead.
  • docs/configuration.md and the guide template describe the wait-for-preferences behavior and the one-time upload.

Review

One GPT-6 Astra review round requested changes; every finding is addressed in this revision with a regression test:

  1. Mounting the navigation strip could overwrite the server order with a normalized empty mirror: the write-back effects are gone.
  2. A losing migration retried against revision 1: migration operations are tagged and never retried.
  3. A delayed PUT response could roll the cache back below a newer broadcast: cache writes are revision-monotonic and the mirror reconciles to the cache when the queue drains.
  4. Coalescing kept only the last functional update for conflict replay: operations compose in order.
  5. Writes sent a mirror snapshot with an unrelated revision: operations are evaluated against the server value at the submitted revision.

How you verified

  • apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts, legacy-local-preferences.test.ts, and UiPreferencesSync.test.tsx (readiness while pending, on data, on error): local-only without context, server value adopted over local state, legacy upload at revision 0 and not for defaults, migration never retried, cached revision write, list fetch when nothing cached, functional re-apply after conflict, plain-value retry after conflict, adopt-server after double conflict, coalescing plus reconcile skip, every queued update kept through a conflict, stale local state evaluated against the server value, queued update applied after a conflicting in-flight write, delayed response cannot regress a newer cache, broadcast adopted after the pending write, skip-equal write, toast-once on failure.
  • PluginNavSidebarItems.test.tsx updated so mounting persists nothing and user actions persist the normalized order; the obsolete sidebarCollapsedAtoms.migration.test.ts and pluginNavSidebarAtoms.test.ts are replaced by the legacy-migration tests.
  • Full @bb/app test suite green. pnpm exec turbo run typecheck --filter='...[origin/main]' green.
  • Live in the dev app: a CLI or API write reaches every visible window without a reload; a chevron click in one window lands on the server and shows in another; seeding an old bb.sidebar.* key and reloading uploads it once and clears every old key; a hidden window catches up when brought to front.

AGENT GENERATED

🤖 Generated with Claude Code

@SawyerHood
SawyerHood force-pushed the bb/ui-preferences-client-sync branch from ba39d8d to 15a721c Compare September 8, 2026 23:32
@SawyerHood
SawyerHood force-pushed the bb/ui-preferences-client-sync branch from 15a721c to d13e4c8 Compare September 8, 2026 23:42
@SawyerHood
SawyerHood force-pushed the bb/ui-preferences-client-sync branch from d13e4c8 to fb9fd6e Compare September 8, 2026 23:48
@SawyerHood
SawyerHood force-pushed the bb/ui-preferences-client-sync branch from fb9fd6e to dbad21d Compare September 9, 2026 17:03
@SawyerHood
SawyerHood force-pushed the bb/ui-preferences-client-sync branch from dbad21d to 23b23b8 Compare September 9, 2026 17:12
@SawyerHood
SawyerHood marked this pull request as ready for review September 9, 2026 17:24
@SawyerHood
SawyerHood force-pushed the bb/ui-preferences-client-sync branch from 23b23b8 to 6bfab14 Compare September 9, 2026 17:40
…gh 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 <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/ui-preferences-client-sync branch from 6bfab14 to f28dd5e Compare September 9, 2026 18:31
@SawyerHood
SawyerHood merged commit f5b9fe7 into main Sep 9, 2026
21 of 30 checks passed
@SawyerHood
SawyerHood deleted the bb/ui-preferences-client-sync branch September 9, 2026 18:46
SawyerHood added a commit that referenced this pull request Sep 9, 2026
## Human comments

## What was wrong

Every sidebar preference (organization mode, sort, section orders,
collapsed rows, navigation entry order and visibility, provider pickers)
lives only in browser localStorage, so a second window, device, or the
CLI cannot see or change it, and there is no server surface to build
sync on. The existing `app_settings_values` store has no revision and
its `config-changed` broadcast invalidates thread timelines and provider
queries, which is far too expensive for collapse-toggle frequency.

## What changed

Layer 1 of 3 (stack #3306). Server foundation only; the app does not
read these values yet.

- `packages/domain/src/ui-preferences.ts`: typed registry of fifteen
`sidebar.*` keys with zod schema, default, and description;
`SidebarOrganizationMode` and `SidebarChronologicalSort` move here so
app, server, SDK, and CLI share one source.
- `packages/db`: new `ui_preferences` table (key, value_json, revision,
updated_at) with migration `0115_ui_preferences`; data layer with
`listStoredUiPreferences`, revision-checked `replaceStoredUiPreference`,
and unchecked `overwriteStoredUiPreference` for reset.
- `packages/server-contract` and `apps/server`: `GET /preferences/ui`,
`PUT /preferences/ui/:key` with `expectedRevision` and `409
ui_preference_conflict`, `DELETE /preferences/ui/:key` resets to the
default while advancing the revision. Unknown keys 404, schema failures
400. Stored values that no longer parse fall back to the default on
read.
- New `ui-preferences-changed` system change kind broadcast on every
write; app realtime registry dirties only the new `uiPreferences` query;
mobile registry compiles unchanged.
- SDK: `sdk.system.uiPreferences.list()`, `.set()`, `.reset()`. CLI: `bb
settings ui list|get|set|reset` (set reads the revision first and
retries once on conflict).
- App: `useUiPreferences` query, cache owner, and query key so later
layers can hydrate from it.
- Docs: new "Sidebar preferences" section in `docs/configuration.md`,
guide template, and the bb-cli skill.

No daemon wire change, so `HOST_DAEMON_PROTOCOL_VERSION` is unchanged.
The new domain types reach the plugin SDK's bundled types, so
`@get-bb/plugin-sdk` is bumped to 0.4.51 to satisfy the npm version
guard.

## Review

One GPT-6 Astra review round requested changes; both findings are
addressed: `SidebarOrganizationMode` and `SidebarChronologicalSort` are
now re-exported from `@bb/domain` in the app instead of being redefined,
and the docs, guide template, and bb-cli skill describe this layer as
the server/SDK/CLI foundation with the browser sync explicitly deferred
to #3304.

## How you verified

- New `packages/db/test/data/ui-preferences.test.ts` (revision 0 create,
stale-write conflict, overwrite advances revision).
- New `apps/server/test/public/public-ui-preferences.test.ts` (defaults
at revision 0, write and broadcast, 409 on stale write, 404 unknown key,
400 bad value, reset advances revision, unparseable stored value falls
back).
- Existing migrate tests updated to rewind the new table.
- `pnpm exec turbo run typecheck --filter='...[origin/main]'` (79 tasks
green) and `test` for `@bb/domain`, `@bb/db`, `@bb/server-contract`,
`@bb/sdk`, `@bb/cli`, `@bb/templates`, plus the `@bb/server` public
route suite and the app cache-owner and realtime tests.

> AGENT GENERATED

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Sep 9, 2026
## Human comments

## What was wrong

After #3304 the structural sidebar settings sync, but the six
collapsed-id lists (projects, threads, environments, built-in sections,
thread sections, machines) still live only in localStorage.

## What changed

Layer 3 of 3 (stack #3306). Base: #3304.

- `sidebarCollapsedAtoms.ts`: the six collapsed atoms become in-memory
synced preferences; their `atomWithStorage` wrappers and the folder-era
storage adapter are deleted. Every existing toggle site already uses
functional updates, so conflicting writes from two devices re-apply on
top of the server list instead of clobbering it, and toggles made while
a request is in flight compose into the next one.
`RootComposeMobileRecents.test.tsx` seeds the atom through a jotai store
instead of localStorage.
- Ids of deleted entities are not pruned server-side. The client already
drops a deleted project's id on the delete broadcast, a stale id renders
nothing, and the registry caps each list at 10,000 entries.

## Review

One GPT-6 Astra review round requested changes. All three findings were
in the layer 2 sync controller (writes rebased onto the submitted
revision, composed functional updates through a conflict, and
reconciling the authoritative response after pending writes settle).
They are fixed and tested in #3304. A later simplification pass removed
this layer's server-side pruning and write debounce as not worth their
code.

## How you verified

- Sidebar, cache-owner, and mobile-recents suites plus the full
`@bb/app`, `@bb/db`, and `@bb/server` public-route suites green; `pnpm
exec turbo run typecheck --filter='...[origin/main]'` green.
- Live in the dev app: clicking a project chevron in one window stores
the id on the server and a fresh window opens with the row collapsed; a
CLI write containing an unknown id is stored as sent.

> AGENT GENERATED

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant