Compare commits

...

5 Commits

Author SHA1 Message Date
SabhyaC26 51c313950a test(e2e-ui): cover all three registry prefs in appearance reset spec
Extend test_appearance_reset_restores_defaults to also flip the workspace
panel default, so every preference on the declarative Appearance registry
(UI font size, terminal theme, workspace panel) is verified to reset live
— controls update without a reload and persisted keys are cleared.

Co-authored-by: Isaac
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-28 00:21:03 +00:00
SabhyaC26 2468866ae8 Merge remote-tracking branch 'origin/main' into pr3374-takeover
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-28 00:20:51 +00:00
SabhyaC26 09da89a62b style(web): apply prettier to appearance prefs follow-up
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 22:07:57 +00:00
SabhyaC26 df20c0bd0d fix(web): eager-register appearance prefs and add anti-rot tests
Close the import-order gap so Appearance reset cannot silently skip a
preference that lives in an unloaded chunk, and fail CI on half-migrated
keys that sit in both the registry and the legacy list.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 22:07:35 +00:00
SabhyaC26 f4edf6d625 feat(web): add declarative local preference layer
Stop the 7–14 file recipe for each Appearance setting by owning storage,
validation, subscription, and reset registration in one factory. Migrate
UI font size, workspace panel default, and terminal theme as proof while
keeping the same localStorage keys so existing user values survive.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 22:03:03 +00:00
18 changed files with 894 additions and 188 deletions
+26 -4
View File
@@ -29,38 +29,60 @@ def test_sidebar_font_size_card_is_removed(page: Page, seeded_session: tuple[str
def test_appearance_reset_restores_defaults(page: Page, seeded_session: tuple[str, str]) -> None:
"""Clicking Reset → confirm resets UI font size and terminal theme back to defaults."""
"""Clicking Reset → confirm resets every registry-owned preference to its default.
Covers all three preferences on the declarative Appearance registry (UI font
size, terminal theme, workspace panel default): the controls update live —
without a reload — and the persisted keys are cleared.
"""
base_url, _session_id = seeded_session
_open_appearance(page, base_url)
font_size_input = page.get_by_test_id("ui-font-size-input")
font_size_inc = page.get_by_test_id("ui-font-size-inc")
terminal_dark = page.get_by_test_id("terminal-theme-dark")
workspace_collapsed = page.get_by_test_id("workspace-panel-default-collapsed")
# Fresh context: the defaults are applied and nothing is persisted yet.
expect(font_size_input).to_have_value("16")
expect(page.get_by_test_id("terminal-theme-auto")).to_have_attribute("aria-checked", "true")
expect(page.get_by_test_id("workspace-panel-default-open")).to_have_attribute(
"aria-checked", "true"
)
stored_font_size = page.evaluate("() => window.localStorage.getItem('omnigent:ui-font-size')")
assert stored_font_size is None, "expected no persisted font size on a fresh load"
# Change two unrelated appearance preferences away from their defaults.
# Change each migrated appearance preference away from its default.
font_size_inc.click()
font_size_inc.click()
expect(font_size_input).to_have_value("18")
terminal_dark.click()
expect(page.get_by_test_id("terminal-theme-dark")).to_have_attribute("aria-checked", "true")
workspace_collapsed.click()
expect(workspace_collapsed).to_have_attribute("aria-checked", "true")
# Confirm both changes were persisted.
# Confirm the changes were persisted.
assert page.evaluate("() => window.localStorage.getItem('omnigent:ui-font-size')") == "18"
assert page.evaluate("() => window.localStorage.getItem('omnigent:terminal-theme')") == "dark"
assert (
page.evaluate("() => window.localStorage.getItem('omnigent:default-workspace-panel')")
== "collapsed"
)
# Reset, confirming through the dialog.
page.get_by_test_id("reset-appearance-button").click()
expect(page.get_by_role("dialog", name="Reset appearance?")).to_be_visible(timeout=30_000)
page.get_by_test_id("reset-appearance-confirm").click()
# Both choices are back to the product defaults.
# Every choice is back to the product default — live, no reload.
expect(font_size_input).to_have_value("16")
expect(page.get_by_test_id("terminal-theme-auto")).to_have_attribute("aria-checked", "true")
expect(page.get_by_test_id("workspace-panel-default-open")).to_have_attribute(
"aria-checked", "true"
)
assert page.evaluate("() => window.localStorage.getItem('omnigent:ui-font-size')") is None
assert page.evaluate("() => window.localStorage.getItem('omnigent:terminal-theme')") is None
assert (
page.evaluate("() => window.localStorage.getItem('omnigent:default-workspace-panel')")
is None
)
+3
View File
@@ -43,6 +43,9 @@ import {
} from "./lib/routing";
import { initChatStore } from "./store/chatStore";
import "./index.css";
// Eagerly register every Appearance preference so Reset doesn't depend on
// which lazily-loaded chunks the user has visited.
import "./lib/preferences/appearancePrefs";
import { QueueFlushProvider } from "./hooks/QueueFlushProvider";
import { SessionUpdatesProvider } from "./hooks/SessionUpdatesProvider";
+68
View File
@@ -0,0 +1,68 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { usePreference } from "@/hooks/usePreference";
import {
clearAppearancePreferenceRegistryForTests,
createLocalPreference,
} from "@/lib/preferences";
afterEach(() => {
cleanup();
localStorage.clear();
clearAppearancePreferenceRegistryForTests();
});
describe("usePreference", () => {
it("reads the initial value and updates on set", () => {
const pref = createLocalPreference({
key: "test:hook",
defaultValue: "auto" as const,
parse: (raw) => (raw === "dark" ? "dark" : "auto"),
serialize: (value) => value,
clearWhenDefault: true,
});
function Probe() {
const [value, setValue] = usePreference(pref);
return (
<div>
<span data-testid="value">{value}</span>
<button type="button" data-testid="set-dark" onClick={() => setValue("dark")}>
dark
</button>
</div>
);
}
render(<Probe />);
expect(screen.getByTestId("value").textContent).toBe("auto");
fireEvent.click(screen.getByTestId("set-dark"));
expect(screen.getByTestId("value").textContent).toBe("dark");
expect(localStorage.getItem("test:hook")).toBe("dark");
});
it("re-renders when the preference is written externally (e.g. reset)", () => {
const pref = createLocalPreference({
key: "test:hook-reset",
defaultValue: 16,
parse: (raw) => Number(raw),
serialize: (value) => String(value),
});
pref.write(18);
function Probe() {
const [value] = usePreference(pref);
return <span data-testid="value">{value}</span>;
}
render(<Probe />);
expect(screen.getByTestId("value").textContent).toBe("18");
act(() => {
pref.reset();
});
expect(screen.getByTestId("value").textContent).toBe("16");
expect(localStorage.getItem("test:hook-reset")).toBeNull();
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* React binding for a {@link LocalPreference}.
*
* Subscribes via `useSyncExternalStore` so Settings controls (and anything
* else) re-render when the preference is written — including Appearance reset
* — without a remount key.
*/
import { useCallback, useSyncExternalStore } from "react";
import type { LocalPreference } from "@/lib/preferences";
export type PreferenceSetter<T> = (value: T | ((prev: T) => T)) => void;
/**
* Subscribe to a local preference. Returns `[value, setValue]` like useState.
* `setValue` accepts a value or an updater; both go through `pref.write`.
*/
export function usePreference<T>(pref: LocalPreference<T>): [T, PreferenceSetter<T>] {
const value = useSyncExternalStore(pref.subscribe, pref.read, () => pref.defaultValue);
const setValue = useCallback<PreferenceSetter<T>>(
(next) => {
const resolved = typeof next === "function" ? (next as (prev: T) => T)(pref.read()) : next;
pref.write(resolved);
},
[pref],
);
return [value, setValue];
}
@@ -0,0 +1,43 @@
/**
* Anti-rot / load-order guards for the Appearance preference registry.
*
* These tests fail CI when:
* - A preference module isn't wired into the eager barrel (registry incomplete).
* - EXPECTED_APPEARANCE_STORAGE_KEYS drifts from what's actually registered.
* - A key is half-migrated (present in both registry and the legacy list).
*/
import { describe, expect, it } from "vitest";
import {
EXPECTED_APPEARANCE_STORAGE_KEYS,
LEGACY_APPEARANCE_STORAGE_KEYS,
} from "./appearancePrefs";
import { getAppearanceStorageKeys } from "./appearanceRegistry";
function sorted(keys: readonly string[]): string[] {
return [...keys].sort();
}
describe("appearance preference registry — load order", () => {
it("registers every expected key after the eager barrel loads", () => {
// appearancePrefs.ts is imported above; its side-effect imports must
// have registered every key in EXPECTED_APPEARANCE_STORAGE_KEYS. If a
// new preference is added with appearance: true but not imported here,
// this fails — Reset would silently skip it in production.
expect(sorted(getAppearanceStorageKeys())).toEqual(sorted(EXPECTED_APPEARANCE_STORAGE_KEYS));
});
it("does not leave any key in both the registry and the legacy list", () => {
// Half-migrated state: still cleared by LEGACY_APPEARANCE_STORAGE_KEYS
// AND registered — the dual-list rot this layer exists to prevent.
const registered = new Set(getAppearanceStorageKeys());
const overlap = LEGACY_APPEARANCE_STORAGE_KEYS.filter((key) => registered.has(key));
expect(overlap).toEqual([]);
});
it("keeps expected and legacy key lists disjoint by construction", () => {
const expected = new Set<string>(EXPECTED_APPEARANCE_STORAGE_KEYS);
const overlap = LEGACY_APPEARANCE_STORAGE_KEYS.filter((key) => expected.has(key));
expect(overlap).toEqual([]);
});
});
@@ -0,0 +1,46 @@
/**
* Eager Appearance preference barrel.
*
* Registration is an import side-effect of each `createLocalPreference({
* appearance: true })` module. Without this barrel, a preference that only
* lives in a lazily-loaded chunk would be missing from the registry until
* that chunk loads — and Appearance → Reset would silently skip it.
*
* Import this module once at app init (`main.tsx`, `embed.tsx`). When
* migrating a preference:
*
* 1. Add a side-effect import below.
* 2. Add its storage key to {@link EXPECTED_APPEARANCE_STORAGE_KEYS}.
* 3. Remove that key from {@link LEGACY_APPEARANCE_STORAGE_KEYS}.
*
* The anti-rot test in `appearancePrefs.test.ts` fails CI if those drift.
*/
// Side-effect imports — each module registers its preference on load.
import "@/lib/uiFontPreferences";
import "@/lib/workspacePanelPreferences";
import "@/lib/terminalThemePreferences";
/**
* Storage keys that MUST be registered after this barrel loads.
* Add a key here when migrating a preference onto the declarative layer.
*/
export const EXPECTED_APPEARANCE_STORAGE_KEYS = [
"omnigent:ui-font-size",
"omnigent:default-workspace-panel",
"omnigent:terminal-theme",
] as const;
/**
* Appearance localStorage keys not yet on `createLocalPreference(...,
* { appearance: true })`. Settings reset clears these explicitly. Remove a
* key when its module migrates — the registry then owns reset for it.
*/
export const LEGACY_APPEARANCE_STORAGE_KEYS = [
"omnigent:ui-font-family",
"omnigent:code-font-size",
"omnigent:code-font-family",
"omnigent:ui-theme-palette",
"omnigent:custom-theme",
"omnigent:hide-unconfigured-harnesses",
] as const;
@@ -0,0 +1,63 @@
/**
* Appearance preference registry.
*
* Preferences created with `appearance: true` register here. The Settings
* Appearance reset dialog resets every registered preference and clears their
* storage keys — so adding a setting updates reset automatically.
*
* Registration is an import side-effect. App init must import
* `./appearancePrefs` (the eager barrel) so every Appearance preference is
* registered regardless of which routes have loaded. The anti-rot test in
* `appearancePrefs.test.ts` asserts the registered key set.
*
* ## Migrating a remaining preference
*
* 1. Replace its read/write helpers with `createLocalPreference({ ..., appearance: true })`.
* 2. Keep the same `key` and `parse` the existing stored format (or migrate explicitly).
* 3. Side-effect-import the module from `appearancePrefs.ts` and add its key to
* `EXPECTED_APPEARANCE_STORAGE_KEYS`; remove it from `LEGACY_APPEARANCE_STORAGE_KEYS`.
* 4. Drop the matching hand-rolled write/apply calls from `resetAppearance` in Settings.
* 5. Optionally switch the Settings control to `usePreference(pref)`.
*/
import type { LocalPreference } from "./createLocalPreference";
/** Registry entry — only `key` / `reset` are used by Appearance reset. */
export type AppearancePreference = Pick<LocalPreference<unknown>, "key" | "reset">;
const appearancePreferences: AppearancePreference[] = [];
/** Register a preference for Appearance → Reset. Called by the factory. */
export function registerAppearancePreference(pref: AppearancePreference): void {
if (appearancePreferences.some((existing) => existing.key === pref.key)) {
return;
}
appearancePreferences.push(pref);
}
/** Snapshot of registered Appearance preferences (order = registration order). */
export function getAppearancePreferences(): readonly AppearancePreference[] {
return appearancePreferences;
}
/** Storage keys owned by registered Appearance preferences. */
export function getAppearanceStorageKeys(): readonly string[] {
return appearancePreferences.map((pref) => pref.key);
}
/** Reset every registered Appearance preference to its default. */
export function resetAppearancePreferences(): void {
for (const pref of appearancePreferences) {
pref.reset();
}
}
/** Test-only: drop prefs registered under `test:` keys; leave production prefs. */
export function clearAppearancePreferenceRegistryForTests(): void {
for (let i = appearancePreferences.length - 1; i >= 0; i--) {
const entry = appearancePreferences[i];
if (entry && entry.key.startsWith("test:")) {
appearancePreferences.splice(i, 1);
}
}
}
@@ -0,0 +1,258 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearAppearancePreferenceRegistryForTests,
createLocalPreference,
getAppearanceStorageKeys,
resetAppearancePreferences,
} from "./index";
afterEach(() => {
localStorage.clear();
clearAppearancePreferenceRegistryForTests();
});
describe("createLocalPreference", () => {
it("returns the default when nothing is stored", () => {
const pref = createLocalPreference({
key: "test:scalar",
defaultValue: 16,
parse: (raw) => Number(raw),
serialize: (value) => String(value),
});
expect(pref.read()).toBe(16);
expect(localStorage.getItem("test:scalar")).toBeNull();
});
it("round-trips a value through localStorage", () => {
const pref = createLocalPreference({
key: "test:scalar",
defaultValue: 16,
parse: (raw) => Number(raw),
serialize: (value) => String(value),
});
pref.write(18);
expect(localStorage.getItem("test:scalar")).toBe("18");
expect(pref.read()).toBe(18);
});
it("falls back to the default on corrupt stored values", () => {
const pref = createLocalPreference({
key: "test:json",
defaultValue: 16,
parse: (raw) => {
try {
const parsed: unknown = JSON.parse(raw);
return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : 16;
} catch {
return 16;
}
},
serialize: (value) => JSON.stringify(value),
});
localStorage.setItem("test:json", "}{not json");
expect(pref.read()).toBe(16);
});
it("normalizes on write and read", () => {
const clamp = (n: number) => Math.min(20, Math.max(12, Math.round(n)));
const pref = createLocalPreference({
key: "test:clamped",
defaultValue: 16,
parse: (raw) => Number(raw),
serialize: (value) => String(value),
normalize: clamp,
});
pref.write(99);
expect(pref.read()).toBe(20);
expect(localStorage.getItem("test:clamped")).toBe("20");
localStorage.setItem("test:clamped", "4");
expect(pref.read()).toBe(12);
});
it("removes the key when writing the default if clearWhenDefault is set", () => {
const pref = createLocalPreference({
key: "test:enum",
defaultValue: "auto" as const,
parse: (raw) => (raw === "light" || raw === "dark" ? raw : "auto"),
serialize: (value) => value,
clearWhenDefault: true,
});
pref.write("dark");
expect(localStorage.getItem("test:enum")).toBe("dark");
pref.write("auto");
expect(localStorage.getItem("test:enum")).toBeNull();
expect(pref.read()).toBe("auto");
});
it("reset clears storage and notifies with the default", () => {
const onChange = vi.fn();
const pref = createLocalPreference({
key: "test:reset",
defaultValue: 16,
parse: (raw) => Number(raw),
serialize: (value) => String(value),
onChange,
});
pref.write(18);
onChange.mockClear();
const storeCb = vi.fn();
const valueCb = vi.fn();
pref.subscribe(storeCb);
pref.subscribeValue(valueCb);
pref.reset();
expect(localStorage.getItem("test:reset")).toBeNull();
expect(pref.read()).toBe(16);
expect(storeCb).toHaveBeenCalledOnce();
expect(valueCb).toHaveBeenCalledWith(16);
expect(onChange).toHaveBeenCalledWith(16);
});
it("notifies subscribers on write even when localStorage fails", () => {
const pref = createLocalPreference({
key: "test:quota",
defaultValue: "a",
parse: (raw) => raw,
serialize: (value) => value,
});
const cb = vi.fn();
pref.subscribeValue(cb);
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("quota");
});
pref.write("b");
expect(cb).toHaveBeenCalledWith("b");
setItem.mockRestore();
});
it("stops notifying after unsubscribe", () => {
const pref = createLocalPreference({
key: "test:unsub",
defaultValue: 1,
parse: (raw) => Number(raw),
serialize: (value) => String(value),
});
const cb = vi.fn();
const unsub = pref.subscribeValue(cb);
unsub();
pref.write(2);
expect(cb).not.toHaveBeenCalled();
});
it("registers with the appearance registry when appearance is true", () => {
const pref = createLocalPreference({
key: "test:appearance",
defaultValue: "open" as const,
parse: (raw) => (raw === "collapsed" ? "collapsed" : "open"),
serialize: (value) => value,
clearWhenDefault: true,
appearance: true,
});
expect(getAppearanceStorageKeys()).toContain("test:appearance");
pref.write("collapsed");
expect(localStorage.getItem("test:appearance")).toBe("collapsed");
resetAppearancePreferences();
expect(localStorage.getItem("test:appearance")).toBeNull();
expect(pref.read()).toBe("open");
});
});
describe("createLocalPreference — migration / backward compatibility", () => {
beforeEach(() => {
clearAppearancePreferenceRegistryForTests();
});
it("preserves an existing JSON number font-size seed (old format)", () => {
// Old uiFontPreferences wrote JSON.stringify(px); seed exactly that.
localStorage.setItem("omnigent:ui-font-size", JSON.stringify(18));
const pref = createLocalPreference({
key: "omnigent:ui-font-size",
defaultValue: 16,
parse: (raw) => {
try {
const parsed: unknown = JSON.parse(raw);
return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : 16;
} catch {
return 16;
}
},
serialize: (value) => JSON.stringify(value),
appearance: true,
});
expect(pref.read()).toBe(18);
expect(localStorage.getItem("omnigent:ui-font-size")).toBe("18");
});
it("preserves an existing raw-string workspace-panel seed (old format)", () => {
localStorage.setItem("omnigent:default-workspace-panel", "collapsed");
const pref = createLocalPreference({
key: "omnigent:default-workspace-panel",
defaultValue: "open" as const,
parse: (raw) => (raw === "collapsed" ? "collapsed" : "open"),
serialize: (value) => value,
clearWhenDefault: true,
appearance: true,
});
expect(pref.read()).toBe("collapsed");
expect(localStorage.getItem("omnigent:default-workspace-panel")).toBe("collapsed");
});
it("preserves an existing raw-string terminal-theme seed (old format)", () => {
localStorage.setItem("omnigent:terminal-theme", "dark");
const pref = createLocalPreference({
key: "omnigent:terminal-theme",
defaultValue: "auto" as const,
parse: (raw) => (raw === "light" || raw === "dark" ? raw : "auto"),
serialize: (value) => value,
clearWhenDefault: true,
appearance: true,
});
expect(pref.read()).toBe("dark");
expect(localStorage.getItem("omnigent:terminal-theme")).toBe("dark");
});
it("runs an explicit parse migration when the stored shape changes", () => {
// Hypothetical old format: bare number string → new JSON object wrapper.
localStorage.setItem("test:migrated", "19");
const pref = createLocalPreference({
key: "test:migrated",
defaultValue: { size: 16 },
parse: (raw) => {
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed === "number" && Number.isFinite(parsed)) {
return { size: parsed };
}
if (
typeof parsed === "object" &&
parsed !== null &&
"size" in parsed &&
typeof (parsed as { size: unknown }).size === "number"
) {
return { size: (parsed as { size: number }).size };
}
} catch {
const asNumber = Number(raw);
if (Number.isFinite(asNumber)) return { size: asNumber };
}
return { size: 16 };
},
serialize: (value) => JSON.stringify(value),
});
expect(pref.read()).toEqual({ size: 19 });
pref.write({ size: 19 });
expect(localStorage.getItem("test:migrated")).toBe(JSON.stringify({ size: 19 }));
});
});
@@ -0,0 +1,164 @@
/**
* Declarative localStorage preference factory.
*
* One call owns the storage key, default, parse/serialize, validation,
* change subscription, and optional Appearance-reset registration — so adding
* a setting is a declaration instead of a multi-file recipe.
*
* Migrate remaining `*Preferences.ts` modules by wrapping them with
* {@link createLocalPreference}, setting `appearance: true` when the setting
* belongs in Appearance → Reset, then wiring it in `appearancePrefs.ts`
* (barrel import + `EXPECTED_APPEARANCE_STORAGE_KEYS`, remove from
* `LEGACY_APPEARANCE_STORAGE_KEYS`). Keep the
* same `key` (or supply an explicit `parse` migration) so persisted values
* survive the upgrade.
*/
import { registerAppearancePreference } from "./appearanceRegistry";
export type LocalPreferenceOptions<T> = {
/** Stable localStorage key. Changing it resets users unless `parse` migrates. */
key: string;
defaultValue: T;
/**
* Turn a stored string into T. Called only when the key exists. Must not
* throw — return `defaultValue` (or a migrated value) for corrupt input.
*/
parse: (raw: string) => T;
/** Turn T into the string written to localStorage. */
serialize: (value: T) => string;
/** Optional clamp/normalize applied after parse and before write. */
normalize?: (value: T) => T;
/**
* When true, writing the default removes the key instead of storing it.
* Matches enum prefs that treat "no key" as the product default.
*/
clearWhenDefault?: boolean;
/** Side effect after every write/reset (CSS vars, imperative widgets, …). */
onChange?: (value: T) => void;
/**
* Register with the Appearance reset registry so Settings reset stays in
* sync with the definition — no second hand-maintained key list.
*/
appearance?: boolean;
};
export type LocalPreference<T> = {
readonly key: string;
readonly defaultValue: T;
read: () => T;
write: (value: T) => void;
/** Clear the key and notify with `defaultValue` (Appearance reset path). */
reset: () => void;
/** `useSyncExternalStore`-compatible subscribe (no value arg). */
subscribe: (onStoreChange: () => void) => () => void;
/** Value-carrying subscribe for imperative listeners (editors, terminals). */
subscribeValue: (listener: (value: T) => void) => () => void;
};
function identity<T>(value: T): T {
return value;
}
/**
* Create a typed localStorage preference with pub/sub.
*
* Reads never throw (corrupt storage → default). Writes swallow quota/access
* errors but still notify listeners with the intended value so the UI can
* update even when persistence fails.
*/
export function createLocalPreference<T>(options: LocalPreferenceOptions<T>): LocalPreference<T> {
const {
key,
defaultValue,
parse,
serialize,
normalize = identity,
clearWhenDefault = false,
onChange,
appearance = false,
} = options;
const storeListeners = new Set<() => void>();
const valueListeners = new Set<(value: T) => void>();
const read = (): T => {
if (typeof window === "undefined") return defaultValue;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return defaultValue;
return normalize(parse(raw));
} catch {
return defaultValue;
}
};
const emit = (value: T): void => {
for (const listener of storeListeners) listener();
for (const listener of valueListeners) listener(value);
onChange?.(value);
};
const persist = (value: T): void => {
if (typeof window === "undefined") return;
try {
if (clearWhenDefault && Object.is(value, defaultValue)) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, serialize(value));
}
} catch {
// localStorage quota or access errors shouldn't break the app.
}
};
const write = (value: T): void => {
const next = normalize(value);
persist(next);
// Broadcast the intended value, not a storage re-read: if persist failed,
// subscribers must still see the new value rather than the stale/default.
emit(next);
};
const reset = (): void => {
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem(key);
} catch {
// localStorage access errors are non-fatal.
}
}
emit(defaultValue);
};
const subscribe = (onStoreChange: () => void): (() => void) => {
storeListeners.add(onStoreChange);
return () => {
storeListeners.delete(onStoreChange);
};
};
const subscribeValue = (listener: (value: T) => void): (() => void) => {
valueListeners.add(listener);
return () => {
valueListeners.delete(listener);
};
};
const preference: LocalPreference<T> = {
key,
defaultValue,
read,
write,
reset,
subscribe,
subscribeValue,
};
if (appearance) {
// Registry only needs key + reset; pass a narrow object to avoid T→unknown casts.
registerAppearancePreference({ key: preference.key, reset: preference.reset });
}
return preference;
}
+17
View File
@@ -0,0 +1,17 @@
export {
createLocalPreference,
type LocalPreference,
type LocalPreferenceOptions,
} from "./createLocalPreference";
export {
clearAppearancePreferenceRegistryForTests,
getAppearancePreferences,
getAppearanceStorageKeys,
registerAppearancePreference,
resetAppearancePreferences,
type AppearancePreference,
} from "./appearanceRegistry";
export {
EXPECTED_APPEARANCE_STORAGE_KEYS,
LEGACY_APPEARANCE_STORAGE_KEYS,
} from "./appearancePrefs";
@@ -5,6 +5,7 @@ import {
resolveTerminalIsDark,
subscribeTerminalTheme,
TERMINAL_THEME_DEFAULT,
terminalThemePreference,
writeTerminalThemeMode,
} from "./terminalThemePreferences";
@@ -37,6 +38,14 @@ describe("terminalThemePreferences — read/write", () => {
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(readTerminalThemeMode()).toBe("auto");
});
it("preserves a pre-factory localStorage seed (backward compatible)", () => {
// Old helper stored the raw mode string under this exact key.
localStorage.setItem(STORAGE_KEY, "light");
expect(terminalThemePreference.read()).toBe("light");
expect(readTerminalThemeMode()).toBe("light");
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
});
});
describe("normalizeTerminalThemeMode", () => {
+22 -49
View File
@@ -3,8 +3,11 @@
// imperatively (unlike the chrome theme, which rides the `.dark` class + CSS
// vars), so a mid-session change is pushed to mounted terminals via a pub/sub;
// `auto` follows the app's resolved theme while `light`/`dark` pin it.
//
// Owned by {@link createLocalPreference}; storage key and raw-string format
// are unchanged so existing localStorage values keep working.
const STORAGE_KEY = "omnigent:terminal-theme";
import { createLocalPreference } from "@/lib/preferences";
export const terminalThemeModes = ["auto", "light", "dark"] as const;
export type TerminalThemeMode = (typeof terminalThemeModes)[number];
@@ -27,45 +30,27 @@ export function normalizeTerminalThemeMode(value: string | null | undefined): Te
}
/**
* Read the persisted terminal theme mode.
*
* Returns "auto" when nothing is stored, on a server render (no `window`),
* or when the stored value is missing/unknown — never throws, so a corrupt
* entry can't break app boot.
* Declarative terminal theme preference. Same key and raw `"auto"`/`"light"`/
* `"dark"` string format as before — no migration rewrite of stored values.
*/
export const terminalThemePreference = createLocalPreference<TerminalThemeMode>({
key: "omnigent:terminal-theme",
defaultValue: TERMINAL_THEME_DEFAULT,
parse: (raw) => normalizeTerminalThemeMode(raw),
serialize: (value) => value,
normalize: normalizeTerminalThemeMode,
clearWhenDefault: true,
appearance: true,
});
/** Read the persisted terminal theme mode. */
export function readTerminalThemeMode(): TerminalThemeMode {
if (typeof window === "undefined") return TERMINAL_THEME_DEFAULT;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return TERMINAL_THEME_DEFAULT;
return normalizeTerminalThemeMode(raw);
} catch {
return TERMINAL_THEME_DEFAULT;
}
return terminalThemePreference.read();
}
/**
* Persist the terminal theme mode, then notify subscribers so mounted
* terminals re-apply it live. "auto" clears the key (the default). Swallows
* quota/access errors so a failed write can't break the app.
*/
/** Persist the terminal theme mode and notify subscribers. */
export function writeTerminalThemeMode(mode: TerminalThemeMode): void {
const normalized = normalizeTerminalThemeMode(mode);
if (typeof window !== "undefined") {
try {
if (normalized === TERMINAL_THEME_DEFAULT) {
window.localStorage.removeItem(STORAGE_KEY);
} else {
window.localStorage.setItem(STORAGE_KEY, normalized);
}
} catch {
// localStorage quota or access errors shouldn't break the app.
}
}
// Broadcast the intended value, not a storage re-read: if the write above
// failed (quota/denied), mounted terminals must still re-theme now rather
// than snapping back to the stale/default stored value.
emit(normalized);
terminalThemePreference.write(mode);
}
/**
@@ -87,10 +72,6 @@ export function resolveTerminalIsDark(mode: TerminalThemeMode, appIsDark: boolea
}
}
type TerminalThemeListener = (mode: TerminalThemeMode) => void;
const listeners = new Set<TerminalThemeListener>();
/**
* Subscribe to terminal theme changes. The callback fires with the current
* {@link TerminalThemeMode} whenever it is written (e.g. from Settings),
@@ -98,14 +79,6 @@ const listeners = new Set<TerminalThemeListener>();
* `ITheme` can't ride a CSS variable the way the chrome theme does. Returns
* an unsubscribe function.
*/
export function subscribeTerminalTheme(listener: TerminalThemeListener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Notify subscribers of the given terminal theme mode. Called after every write. */
function emit(mode: TerminalThemeMode): void {
for (const listener of listeners) listener(mode);
export function subscribeTerminalTheme(listener: (mode: TerminalThemeMode) => void): () => void {
return terminalThemePreference.subscribeValue(listener);
}
+16
View File
@@ -8,6 +8,7 @@ import {
UI_FONT_SIZE_DEFAULT,
UI_FONT_SIZE_MAX,
UI_FONT_SIZE_MIN,
uiFontSizePreference,
writeUiFontFamily,
writeUiFontSizePx,
} from "./uiFontPreferences";
@@ -70,6 +71,21 @@ describe("uiFontPreferences", () => {
// Clamped to the 20px max → 20 / 16 = 1.25.
expect(document.documentElement.style.getPropertyValue("--ui-font-scale")).toBe("1.25");
});
it("applies the scale as a write side-effect", () => {
writeUiFontSizePx(18);
// 18 / 16 = 1.125.
expect(document.documentElement.style.getPropertyValue("--ui-font-scale")).toBe("1.125");
});
it("preserves a pre-factory localStorage seed (backward compatible)", () => {
// Old helper wrote JSON.stringify(px) under this exact key.
localStorage.setItem(STORAGE_KEY, JSON.stringify(18));
expect(uiFontSizePreference.read()).toBe(18);
expect(readUiFontSizePx()).toBe(18);
// Must not rewrite/clear the user's value on read.
expect(localStorage.getItem(STORAGE_KEY)).toBe("18");
});
});
describe("uiFontPreferences — family", () => {
+40 -39
View File
@@ -15,8 +15,11 @@
// `--font-sans` at runtime is a no-op. The `html` rule reads
// `var(--ui-font-family, var(--font-sans))`, so an unset family falls back to
// the system stack and any value we set on documentElement wins.
//
// Size is owned by {@link createLocalPreference}; family is still hand-rolled
// (migrate next — same pattern).
const STORAGE_KEY = "omnigent:ui-font-size";
import { createLocalPreference } from "@/lib/preferences";
/** Reference size that a scale of 1 corresponds to (Tailwind/browser default). */
const BASE_FONT_SIZE_PX = 16;
@@ -31,44 +34,6 @@ export function clampUiFontSizePx(px: number): number {
return Math.min(UI_FONT_SIZE_MAX, Math.max(UI_FONT_SIZE_MIN, Math.round(px)));
}
function isValidPx(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
/**
* Read the persisted UI font size in px.
*
* Returns the default when nothing is stored, on a server render (no `window`),
* or when the stored value is missing/malformed — never throws, so a corrupt
* entry can't break app boot. A stored value outside the range is clamped.
*/
export function readUiFontSizePx(): number {
if (typeof window === "undefined") return UI_FONT_SIZE_DEFAULT;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return UI_FONT_SIZE_DEFAULT;
const parsed: unknown = JSON.parse(raw);
if (!isValidPx(parsed)) return UI_FONT_SIZE_DEFAULT;
return clampUiFontSizePx(parsed);
} catch {
return UI_FONT_SIZE_DEFAULT;
}
}
/**
* Persist the UI font size (px). The value is clamped to the supported range
* before writing. Swallows quota/access errors so a failed write can't break
* the app.
*/
export function writeUiFontSizePx(px: number): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(clampUiFontSizePx(px)));
} catch {
// localStorage quota or access errors shouldn't break the app.
}
}
/**
* Apply the given px size to the DOM by setting the `--ui-font-scale` variable
* on the document root. The root font-size rules in index.css multiply this in,
@@ -81,6 +46,42 @@ export function applyUiFontScale(px: number): void {
document.documentElement.style.setProperty("--ui-font-scale", String(scale));
}
function parseUiFontSizePx(raw: string): number {
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "number" || !Number.isFinite(parsed)) {
return UI_FONT_SIZE_DEFAULT;
}
return clampUiFontSizePx(parsed);
} catch {
return UI_FONT_SIZE_DEFAULT;
}
}
/**
* Declarative UI font-size preference. Same key and JSON number format as the
* pre-factory helpers — existing localStorage values load unchanged.
*/
export const uiFontSizePreference = createLocalPreference<number>({
key: "omnigent:ui-font-size",
defaultValue: UI_FONT_SIZE_DEFAULT,
parse: parseUiFontSizePx,
serialize: (px) => JSON.stringify(clampUiFontSizePx(px)),
normalize: clampUiFontSizePx,
onChange: applyUiFontScale,
appearance: true,
});
/** Read the persisted UI font size in px. */
export function readUiFontSizePx(): number {
return uiFontSizePreference.read();
}
/** Persist the UI font size (px) and apply `--ui-font-scale`. */
export function writeUiFontSizePx(px: number): void {
uiFontSizePreference.write(px);
}
// ---- Font family ---------------------------------------------------------
const FONT_FAMILY_STORAGE_KEY = "omnigent:ui-font-family";
@@ -4,6 +4,7 @@ import {
readDefaultWorkspacePanelOpen,
readWorkspacePanelDefault,
WORKSPACE_PANEL_DEFAULT,
workspacePanelPreference,
writeWorkspacePanelDefault,
} from "./workspacePanelPreferences";
@@ -31,6 +32,14 @@ describe("workspacePanelPreferences — read/write", () => {
expect(readDefaultWorkspacePanelOpen()).toBe(true);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it("preserves a pre-factory localStorage seed (backward compatible)", () => {
// Old helper stored the raw enum string under this exact key.
localStorage.setItem(STORAGE_KEY, "collapsed");
expect(workspacePanelPreference.read()).toBe("collapsed");
expect(readWorkspacePanelDefault()).toBe("collapsed");
expect(localStorage.getItem(STORAGE_KEY)).toBe("collapsed");
});
});
describe("normalizeWorkspacePanelDefault", () => {
+20 -30
View File
@@ -4,8 +4,11 @@
// This only seeds sessions that have no saved per-chat `open` state. Once a
// user toggles the rail in a session, that session's own
// `SessionWorkspaceState.open` wins on restore. Set from Appearance settings.
//
// Owned by {@link createLocalPreference}; storage key and raw-string format
// are unchanged so existing localStorage values keep working.
const STORAGE_KEY = "omnigent:default-workspace-panel";
import { createLocalPreference } from "@/lib/preferences";
export const workspacePanelDefaults = ["open", "collapsed"] as const;
export type WorkspacePanelDefault = (typeof workspacePanelDefaults)[number];
@@ -34,40 +37,27 @@ export function normalizeWorkspacePanelDefault(
}
/**
* Read the persisted default for new-chat Workspace rail visibility.
*
* Returns "open" when nothing is stored, on a server render (no `window`),
* or when the stored value is missing/unknown — never throws, so a corrupt
* entry can't break app boot.
* Declarative Workspace panel default. Same key and raw `"open"`/`"collapsed"`
* string format as before — no migration rewrite of stored values.
*/
export const workspacePanelPreference = createLocalPreference<WorkspacePanelDefault>({
key: "omnigent:default-workspace-panel",
defaultValue: WORKSPACE_PANEL_DEFAULT,
parse: (raw) => normalizeWorkspacePanelDefault(raw),
serialize: (value) => value,
normalize: normalizeWorkspacePanelDefault,
clearWhenDefault: true,
appearance: true,
});
/** Read the persisted default for new-chat Workspace rail visibility. */
export function readWorkspacePanelDefault(): WorkspacePanelDefault {
if (typeof window === "undefined") return WORKSPACE_PANEL_DEFAULT;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return WORKSPACE_PANEL_DEFAULT;
return normalizeWorkspacePanelDefault(raw);
} catch {
return WORKSPACE_PANEL_DEFAULT;
}
return workspacePanelPreference.read();
}
/**
* Persist the default Workspace panel visibility for new chats. "open" clears
* the key (the product default). Swallows quota/access errors so a failed
* write can't break settings.
*/
/** Persist the default Workspace panel visibility for new chats. */
export function writeWorkspacePanelDefault(value: WorkspacePanelDefault): void {
if (typeof window === "undefined") return;
try {
const normalized = normalizeWorkspacePanelDefault(value);
if (normalized === WORKSPACE_PANEL_DEFAULT) {
window.localStorage.removeItem(STORAGE_KEY);
} else {
window.localStorage.setItem(STORAGE_KEY, normalized);
}
} catch {
// localStorage quota or access errors shouldn't break settings.
}
workspacePanelPreference.write(value);
}
/**
+3
View File
@@ -24,6 +24,9 @@ import {
import { applyThemePalette, readThemePalette } from "./lib/themePalette";
import { applyCustomTheme, readCustomTheme } from "./lib/customTheme";
import { initChatStore } from "./store/chatStore";
// Eagerly register every Appearance preference so Reset doesn't depend on
// which lazily-loaded chunks the user has visited.
import "./lib/preferences/appearancePrefs";
import "./index.css";
// Start tracing before any request fires so fetch/XHR are patched in time
+57 -66
View File
@@ -96,19 +96,18 @@ import {
normalizeThemeMode,
type ThemeMode,
} from "@/components/theme/themeMode";
import { usePreference } from "@/hooks/usePreference";
import { LEGACY_APPEARANCE_STORAGE_KEYS, resetAppearancePreferences } from "@/lib/preferences";
import {
applyUiFontFamily,
applyUiFontScale,
clampUiFontSizePx,
readUiFontFamily,
readUiFontSizePx,
UI_FONT_FAMILY_DEFAULT,
UI_FONT_SIZE_DEFAULT,
UI_FONT_SIZE_MAX,
UI_FONT_SIZE_MIN,
UI_FONT_SIZE_STEP,
uiFontSizePreference,
writeUiFontFamily,
writeUiFontSizePx,
} from "@/lib/uiFontPreferences";
import {
clampCodeFontSizePx,
@@ -122,16 +121,9 @@ import {
writeCodeFontFamily,
writeCodeFontSizePx,
} from "@/lib/codeFontPreferences";
import { terminalThemePreference, type TerminalThemeMode } from "@/lib/terminalThemePreferences";
import {
readTerminalThemeMode,
TERMINAL_THEME_DEFAULT,
writeTerminalThemeMode,
type TerminalThemeMode,
} from "@/lib/terminalThemePreferences";
import {
readWorkspacePanelDefault,
WORKSPACE_PANEL_DEFAULT,
writeWorkspacePanelDefault,
workspacePanelPreference,
type WorkspacePanelDefault,
} from "@/lib/workspacePanelPreferences";
import { readDefaultBaseBranch, writeDefaultBaseBranch } from "@/lib/baseBranchPreferences";
@@ -497,12 +489,8 @@ function ModeControl() {
/** Terminal light/dark/match-app theme — its own section. */
function TerminalThemeControl() {
const [mode, setMode] = useState(() => readTerminalThemeMode());
const [mode, setMode] = usePreference(terminalThemePreference);
const labelId = useId();
const choose = useCallback((next: TerminalThemeMode) => {
setMode(next);
writeTerminalThemeMode(next);
}, []);
return (
<ThemeSubsection
labelId={labelId}
@@ -512,7 +500,7 @@ function TerminalThemeControl() {
<CardRadioGroup<TerminalThemeMode>
labelledBy={labelId}
value={mode}
onSelect={choose}
onSelect={setMode}
className="grid grid-cols-3 gap-3"
cardClassName="items-center gap-2 p-4"
items={terminalThemeCards.map((card) => ({
@@ -531,12 +519,8 @@ function TerminalThemeControl() {
* sessions keep restoring whatever the user last left them as.
*/
function WorkspacePanelDefaultControl() {
const [value, setValue] = useState(() => readWorkspacePanelDefault());
const [value, setValue] = usePreference(workspacePanelPreference);
const labelId = useId();
const choose = useCallback((next: WorkspacePanelDefault) => {
setValue(next);
writeWorkspacePanelDefault(next);
}, []);
return (
<ThemeSubsection
labelId={labelId}
@@ -546,7 +530,7 @@ function WorkspacePanelDefaultControl() {
<CardRadioGroup<WorkspacePanelDefault>
labelledBy={labelId}
value={value}
onSelect={choose}
onSelect={setValue}
className="grid grid-cols-2 gap-3"
cardClassName="items-center gap-2 p-4"
items={workspacePanelCards.map((card) => ({
@@ -810,6 +794,12 @@ function HideUnconfiguredHarnessesControl() {
);
}
/**
* Appearance localStorage keys not yet owned by `createLocalPreference(...,
* { appearance: true })` live in {@link LEGACY_APPEARANCE_STORAGE_KEYS}
* (`lib/preferences/appearancePrefs.ts`). Reset clears them explicitly.
*/
function AppearanceSection() {
// Embedded: the host owns light/dark, so the Mode and Color theme pickers
// would be no-ops — hide them and say so (matching ThemeModeMenu). Terminal
@@ -824,40 +814,31 @@ function AppearanceSection() {
// Reset every appearance preference back to the product default.
setTheme("system");
writeTerminalThemeMode(TERMINAL_THEME_DEFAULT);
// Migrated prefs: one registry call clears keys, notifies subscribers, and
// runs onChange (e.g. UI font scale). Adding a setting with
// `appearance: true` picks it up here automatically.
resetAppearancePreferences();
// Legacy appearance prefs not yet on the declarative layer. Shrink
// LEGACY_APPEARANCE_STORAGE_KEYS (and this block) as each module migrates —
// see `web/src/lib/preferences/appearancePrefs.ts`.
writeThemePalette(DEFAULT_PALETTE);
applyThemePalette(DEFAULT_PALETTE);
writeCustomTheme(DEFAULT_CUSTOM_THEME);
applyCustomTheme(DEFAULT_CUSTOM_THEME);
writeWorkspacePanelDefault(WORKSPACE_PANEL_DEFAULT);
writeHideUnconfiguredHarnesses(DEFAULT_HIDE_UNCONFIGURED_HARNESSES);
applyUiFontScale(UI_FONT_SIZE_DEFAULT);
applyUiFontFamily(UI_FONT_FAMILY_DEFAULT);
writeCodeFontSizePx(CODE_FONT_SIZE_DEFAULT);
writeCodeFontFamily(CODE_FONT_FAMILY_DEFAULT);
// Remove the persisted keys so this device has no appearance overrides at
// all. Some write helpers already remove the key for the default value;
// clearing the list here makes the intent explicit and keeps the reset
// behavior consistent even if a helper changes later.
// Clear remaining legacy keys so this device has no appearance overrides.
// Migrated keys are already removed by resetAppearancePreferences().
if (typeof window !== "undefined") {
try {
for (const key of [
"omnigent:ui-font-size",
"omnigent:ui-font-family",
"omnigent:code-font-size",
"omnigent:code-font-family",
"omnigent:terminal-theme",
"omnigent:ui-theme-palette",
"omnigent:custom-theme",
"omnigent:default-workspace-panel",
"omnigent:hide-unconfigured-harnesses",
]) {
for (const key of LEGACY_APPEARANCE_STORAGE_KEYS) {
window.localStorage.removeItem(key);
}
} catch {
@@ -865,8 +846,8 @@ function AppearanceSection() {
}
}
// Remount the controls so they re-read the freshly-cleared defaults from
// localStorage rather than keeping their stale seeded state.
// Remount legacy (non-usePreference) controls so they re-read defaults.
// Migrated controls subscribe and update without a remount.
setResetKey((k) => k + 1);
};
@@ -1008,30 +989,40 @@ function UiFontSizeControl() {
// the way to "18") or an empty field while retyping — don't get clamped on
// every keystroke. We only commit while typing when the draft is already a
// valid in-range size; blur/Enter clamps and re-syncs the text.
const [px, setPx] = useState(() => readUiFontSizePx());
const [px, setPx] = usePreference(uiFontSizePreference);
const [draft, setDraft] = useState(() => String(px));
const commit = useCallback((next: number) => {
const clamped = clampUiFontSizePx(next);
setPx(clamped);
setDraft(String(clamped));
writeUiFontSizePx(clamped);
applyUiFontScale(clamped);
}, []);
// Keep the draft text in sync when Appearance reset (or another writer)
// changes the committed size out from under the field.
useEffect(() => {
setDraft(String(px));
}, [px]);
const onDraftChange = useCallback((text: string) => {
setDraft(text);
// Apply live only once the field holds a valid, in-range whole number;
// leave partial/out-of-range/empty drafts untouched until blur.
if (/^\d+$/.test(text)) {
const value = Number(text);
if (value >= UI_FONT_SIZE_MIN && value <= UI_FONT_SIZE_MAX) {
setPx(value);
writeUiFontSizePx(value);
applyUiFontScale(value);
const commit = useCallback(
(next: number) => {
const clamped = clampUiFontSizePx(next);
setPx(clamped);
// Always re-sync draft text. When blur reverts an empty/invalid field to
// the same committed size, `px` is unchanged so the effect won't run.
setDraft(String(clamped));
},
[setPx],
);
const onDraftChange = useCallback(
(text: string) => {
setDraft(text);
// Apply live only once the field holds a valid, in-range whole number;
// leave partial/out-of-range/empty drafts untouched until blur.
if (/^\d+$/.test(text)) {
const value = Number(text);
if (value >= UI_FONT_SIZE_MIN && value <= UI_FONT_SIZE_MAX) {
setPx(value);
}
}
}
}, []);
},
[setPx],
);
// Clamp and re-sync the text to the committed value. An empty or invalid
// draft reverts to the last committed size rather than a bogus one.