Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51c313950a | |||
| 2468866ae8 | |||
| 09da89a62b | |||
| df20c0bd0d | |||
| f4edf6d625 |
@@ -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
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user