Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 54 additions & 34 deletions apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,48 +190,68 @@ describe("ui preferences sync", () => {
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", '"project"');
it.each(["project", "chronological", "machine"] as const)(
"persists legacy %s organization even when it matches the default",
async (value) => {
window.localStorage.setItem(
"bb.sidebar.organizationMode",
JSON.stringify(value),
);
const { modeAtom, queryClient, store } = createHarness();
startUiPreferencesSync({ queryClient, store });
const response = serverResponse({
"sidebar.organizationMode": { revision: 0, value: "project" },
});
setCachedUiPreferences(queryClient, response);
reconcileUiPreferences(response);
await waitForUiPreferenceWrites();
expect(mocks.set).toHaveBeenCalledTimes(1);
expect(mocks.set).toHaveBeenCalledWith({
expectedRevision: 0,
key: "sidebar.organizationMode",
value,
});
expect(
getCachedUiPreferences(queryClient)?.preferences[
"sidebar.organizationMode"
],
).toEqual({ revision: 1, value });
expect(store.get(modeAtom)).toBe(value);
expect(
window.localStorage.getItem("bb.sidebar.organizationMode"),
).toBeNull();

reconcileUiPreferences(serverResponse());
await waitForUiPreferenceWrites();
expect(mocks.set).toHaveBeenCalledTimes(1);
expect(store.get(modeAtom)).toBe(value);
},
);

it("recovers the project view when an earlier migration discarded the default choice", async () => {
const { modeAtom, queryClient, store } = createHarness();
startUiPreferencesSync({ queryClient, store });
const response = serverResponse();
setCachedUiPreferences(queryClient, response);
reconcileUiPreferences(response);
reconcileUiPreferences(
serverResponse({
"sidebar.organizationMode": { revision: 0, value: "project" },
}),
);
await waitForUiPreferenceWrites();
expect(mocks.set).toHaveBeenCalledTimes(1);
expect(mocks.set).toHaveBeenCalledWith({
expectedRevision: 0,
key: "sidebar.organizationMode",
value: "project",
});
expect(
getCachedUiPreferences(queryClient)?.preferences[
"sidebar.organizationMode"
],
).toEqual({ revision: 1, value: "project" });
expect(store.get(modeAtom)).toBe("project");
expect(
window.localStorage.getItem("bb.sidebar.organizationMode"),
).toBeNull();

reconcileUiPreferences(serverResponse());
await waitForUiPreferenceWrites();
expect(mocks.set).toHaveBeenCalledTimes(1);
expect(mocks.set).not.toHaveBeenCalled();
});

it("does not upload a legacy value that equals the default", async () => {
window.localStorage.setItem(
"bb.sidebar.organizationMode",
'"chronological"',
);
const { queryClient, store } = createHarness();
it("persists an explicit choice of the current default", async () => {
const { modeAtom, queryClient, store } = createHarness();
startUiPreferencesSync({ queryClient, store });
reconcileUiPreferences(serverResponse());
setCachedUiPreferences(queryClient, serverResponse());
store.set(modeAtom, "chronological");
await waitForUiPreferenceWrites();
expect(mocks.set).not.toHaveBeenCalled();
expect(
window.localStorage.getItem("bb.sidebar.organizationMode"),
).toBeNull();
expect(mocks.set).toHaveBeenCalledWith({
expectedRevision: 0,
key: "sidebar.organizationMode",
value: "chronological",
});
});

it("writes with the cached revision and records the new entry", async () => {
Expand Down
26 changes: 15 additions & 11 deletions apps/app/src/lib/ui-preferences/ui-preferences-sync.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
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,
import type {
UiPreferenceEntry,
UiPreferenceKey,
UiPreferenceValue,
} from "@bb/domain";
import type { UiPreferencesResponse } from "@bb/server-contract";
import { appToast } from "@/components/ui/app-toast";
Expand Down Expand Up @@ -138,21 +137,21 @@ function reconcileUiPreference<Key extends UiPreferenceKey>(
if (state.pending !== null || state.inFlight !== null) return;
const entry = response.preferences[key];
if (entry === undefined) return;
const cachedEntry = getCachedUiPreferences(activeContext.queryClient)
?.preferences[key];
if (cachedEntry !== undefined && cachedEntry.revision > entry.revision)
return;
if (entry.revision === 0 && !state.migrationAttempted) {
state.migrationAttempted = true;
const legacy = readLegacyLocalUiPreference(key);
clearLegacyLocalUiPreference(key);
if (
legacy !== undefined &&
!areUiPreferenceValuesEqual(legacy, getUiPreferenceDefault(key))
) {
if (legacy !== undefined) {
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)
Expand Down Expand Up @@ -225,7 +224,12 @@ async function writeUiPreference<Key extends UiPreferenceKey>(
? operations
: operations.filter((operation) => operation.source === "user");
const value = applyOperations(applicable, base.value);
if (areUiPreferenceValuesEqual(value, base.value)) return;
if (
applicable.length === 0 ||
(base.revision > 0 && areUiPreferenceValuesEqual(value, base.value))
) {
return;
}
try {
const response = await sdk.system.uiPreferences.set({
expectedRevision: base.revision,
Expand Down
36 changes: 33 additions & 3 deletions apps/server/src/services/system/ui-preferences.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
getStoredUiPreferenceDefault,
listStoredUiPreferenceDefaults,
listStoredUiPreferences,
overwriteStoredUiPreference,
replaceStoredUiPreference,
Expand All @@ -24,13 +26,25 @@ function parseStoredJson(text: string): unknown {
}
}

function installationDefault<Key extends UiPreferenceKey>(
key: Key,
stored: string | undefined,
): UiPreferenceValue<Key> {
if (stored !== undefined) {
const parsed = parseUiPreferenceValue(key, parseStoredJson(stored));
if (parsed.success) return parsed.value;
}
return getUiPreferenceDefault(key);
}

function toEntry<Key extends UiPreferenceKey>(
key: Key,
stored: StoredUiPreference | undefined,
defaultValue: UiPreferenceValue<Key>,
): UiPreferenceEntry<Key> {
const defaultEntry: UiPreferenceEntry<Key> = {
revision: stored?.revision ?? 0,
value: getUiPreferenceDefault(key),
value: defaultValue,
};
if (stored === undefined) return defaultEntry;
const parsed = parseUiPreferenceValue(key, parseStoredJson(stored.valueJson));
Expand All @@ -40,12 +54,25 @@ function toEntry<Key extends UiPreferenceKey>(
}

export function readUiPreferences(deps: AppDeps): UiPreferenceEntries {
const defaults = new Map(
listStoredUiPreferenceDefaults(deps.db).map((row) => [
row.key,
row.valueJson,
]),
);
const stored = new Map<string, StoredUiPreference>();
for (const row of listStoredUiPreferences(deps.db)) {
if (isUiPreferenceKey(row.key)) stored.set(row.key, row);
}
return Object.fromEntries(
UI_PREFERENCE_KEYS.map((key) => [key, toEntry(key, stored.get(key))]),
UI_PREFERENCE_KEYS.map((key) => [
key,
toEntry(
key,
stored.get(key),
installationDefault(key, defaults.get(key)),
),
]),
) as UiPreferenceEntries;
}

Expand Down Expand Up @@ -76,7 +103,10 @@ export function resetUiPreference<Key extends UiPreferenceKey>(
deps: AppDeps,
key: Key,
): UiPreferenceEntry<Key> {
const value = getUiPreferenceDefault(key);
const value = installationDefault(
key,
getStoredUiPreferenceDefault(deps.db, key),
);
const { revision } = overwriteStoredUiPreference(deps.db, {
key,
valueJson: JSON.stringify(value),
Expand Down
46 changes: 44 additions & 2 deletions apps/server/test/public/public-ui-preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ describe("public ui preferences", () => {
});
});

it("defaults unset organization to Custom without persisting a choice", async () => {
it("defaults new installations to Custom without persisting a choice", async () => {
await withTestHarness(async (harness) => {
expect(await readJson(await listPreferences(harness))).toMatchObject({
preferences: {
Expand All @@ -215,10 +215,52 @@ describe("public ui preferences", () => {
});
});

it("exposes an installation fallback at revision zero and accepts legacy choices", async () => {
await withTestHarness(async (harness) => {
harness.db.$client.exec(
`INSERT INTO ui_preference_defaults VALUES ('sidebar.organizationMode', '"project"')`,
);
expect(await readJson(await listPreferences(harness))).toMatchObject({
preferences: {
"sidebar.organizationMode": { revision: 0, value: "project" },
},
});
expect(
(
await putPreference(harness, "sidebar.organizationMode", {
expectedRevision: 0,
value: "machine",
})
).status,
).toBe(200);
expect(
(
await putPreference(harness, "sidebar.organizationMode", {
expectedRevision: 0,
value: "chronological",
})
).status,
).toBe(409);
expect(await readJson(await listPreferences(harness))).toMatchObject({
preferences: {
"sidebar.organizationMode": { revision: 1, value: "machine" },
},
});
expect(
await readJson(
await resetPreference(harness, "sidebar.organizationMode"),
),
).toMatchObject({ revision: 2, value: "project" });
});
});

it.each(["project", "machine", "chronological"])(
"preserves saved %s organization",
"preserves saved %s organization over the installation fallback",
async (value) => {
await withTestHarness(async (harness) => {
harness.db.$client.exec(
`INSERT INTO ui_preference_defaults VALUES ('sidebar.organizationMode', '"project"')`,
);
harness.db.$client
.prepare(
"INSERT INTO ui_preferences (key, value_json, revision, updated_at) VALUES (?, ?, 3, 1)",
Expand Down
7 changes: 5 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,8 +719,11 @@ client wrote first, so a stale window cannot silently clobber a newer value.
| `sidebar.navigationProvider` | Plugin key, `__automatic__`, or `__builtin__` |
| `sidebar.threadListProvider` | Plugin key, `__automatic__`, or `__builtin__` |

Custom (`chronological`) is the default for `sidebar.organizationMode` when no
value is saved. Existing server and legacy browser choices are preserved.
New installations default to Custom (`chronological`) for `sidebar.organizationMode`.
Migrated installations with existing projects, threads, or UI preferences fall back
to By project (`project`). Explicit server choices take precedence over legacy
browser choices, which take precedence over this installation fallback. Reset
saves the installation fallback as an explicit choice.

`sidebar.threadGrouping.environment` decides whether two or more sibling threads
that share one worktree environment collapse into a single worktree row inside
Expand Down
10 changes: 10 additions & 0 deletions packages/db/drizzle/0129_sidebar_installation_defaults.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE `ui_preference_defaults` (
`key` text PRIMARY KEY NOT NULL,
`value_json` text NOT NULL
);
--> statement-breakpoint
INSERT INTO `ui_preference_defaults` (`key`, `value_json`)
SELECT 'sidebar.organizationMode', '"project"'
WHERE EXISTS (SELECT 1 FROM `ui_preferences`)
OR EXISTS (SELECT 1 FROM `projects` WHERE `kind` != 'personal')
OR EXISTS (SELECT 1 FROM `threads`);
Loading
Loading