diff --git a/tools/ui/src/lib/stores/init.ts b/tools/ui/src/lib/stores/init.ts index 04466378c..1faa80303 100644 --- a/tools/ui/src/lib/stores/init.ts +++ b/tools/ui/src/lib/stores/init.ts @@ -1,10 +1,15 @@ /** - * Explicit store initialization, called once from the root layout. + * Explicit store initialization, run once and shared by every caller. * * Order matters: migrations run first because they rename and rewrite * localStorage keys, so every store that reads localStorage initializes * only after they complete. Constructors and module-level side effects * stay empty so import order can no longer change startup behavior. + * + * The returned promise resolves once the persisted state is in memory, which + * route loads await before reading settings: they run ahead of the root layout + * script. The conversation list loads in the background, awaited by the chat + * page that renders it. */ // direct imports, not via the barrel, to avoid circular deps @@ -16,19 +21,20 @@ import { versionStore } from './version.svelte'; import { browser } from '$app/environment'; import { MigrationService } from '$lib/services/migration.service'; -let started = false; +let startup: Promise | null = null; -export async function initStores(): Promise { - if (!browser || started) return; +export function initStores(): Promise { + if (!browser) return Promise.resolve(); - started = true; + startup ??= (async () => { + await MigrationService.runAllMigrations(); - await MigrationService.runAllMigrations(); + settingsStore.initialize(); + permissionsStore.initialize(); + toolsStore.initialize(); + void versionStore.initialize(); + void conversationsStore.init(); + })(); - settingsStore.initialize(); - permissionsStore.initialize(); - toolsStore.initialize(); - void versionStore.initialize(); - - await conversationsStore.init(); + return startup; } diff --git a/tools/ui/src/lib/stores/settings.svelte.ts b/tools/ui/src/lib/stores/settings.svelte.ts index d5fe0557a..f23f6953a 100644 --- a/tools/ui/src/lib/stores/settings.svelte.ts +++ b/tools/ui/src/lib/stores/settings.svelte.ts @@ -358,17 +358,24 @@ class SettingsStore { // UI settings are the admin's defaults for new users: applied once on // the first visit, never on later loads, so the user's config can // diverge. "Reset to Default" is the explicit way back to the baseline. + // A first visit config carries factory values only, so a key that + // already diverges here was set by the user before the baseline could + // be reached, through the API key splash, and stays theirs. if (uiSettings && this.isFirstVisit) { this.isFirstVisit = false; for (const [key, value] of Object.entries(uiSettings)) { - if (!this.userOverrides.has(key) && value !== undefined) { - setConfigValue(this.config, key, value); + if (value === undefined || this.userOverrides.has(key)) continue; - // theme lives in mode-watcher, not just in config -> propagate - if (key === SETTINGS_KEYS.THEME) { - setMode(value as ColorMode); - } + if (getConfigValue(this.config, key) !== getConfigValue(SETTING_CONFIG_DEFAULT, key)) { + continue; + } + + setConfigValue(this.config, key, value); + + // theme lives in mode-watcher, not just in config -> propagate + if (key === SETTINGS_KEYS.THEME) { + setMode(value as ColorMode); } } } diff --git a/tools/ui/src/routes/(chat)/+page.ts b/tools/ui/src/routes/(chat)/+page.ts index 7905af6b5..0c46aaa8a 100644 --- a/tools/ui/src/routes/(chat)/+page.ts +++ b/tools/ui/src/routes/(chat)/+page.ts @@ -1,6 +1,10 @@ import type { PageLoad } from './$types'; +import { initStores } from '$lib/stores/init'; import { validateApiKey } from '$lib/utils'; export const load: PageLoad = async ({ fetch }) => { + // loads run before the root layout script, so the stored API key reaches + // the probe only once the settings store has read localStorage + await initStores(); await validateApiKey(fetch); }; diff --git a/tools/ui/src/routes/(chat)/chat/[id]/+page.ts b/tools/ui/src/routes/(chat)/chat/[id]/+page.ts index 7905af6b5..0c46aaa8a 100644 --- a/tools/ui/src/routes/(chat)/chat/[id]/+page.ts +++ b/tools/ui/src/routes/(chat)/chat/[id]/+page.ts @@ -1,6 +1,10 @@ import type { PageLoad } from './$types'; +import { initStores } from '$lib/stores/init'; import { validateApiKey } from '$lib/utils'; export const load: PageLoad = async ({ fetch }) => { + // loads run before the root layout script, so the stored API key reaches + // the probe only once the settings store has read localStorage + await initStores(); await validateApiKey(fetch); }; diff --git a/tools/ui/tests/client/ui-settings-sync.svelte.test.ts b/tools/ui/tests/client/ui-settings-sync.svelte.test.ts index 86e089893..6dca891c8 100644 --- a/tools/ui/tests/client/ui-settings-sync.svelte.test.ts +++ b/tools/ui/tests/client/ui-settings-sync.svelte.test.ts @@ -47,6 +47,19 @@ describe('server ui_settings application semantics', () => { expect(stored.apiKey).toBe('sk-user-key'); }); + it('keeps a value the user sets before the baseline is reachable', () => { + settingsStore.initialize(); + // the splash is the only way in when the server runs with --api-key, + // so the first user write lands before the first successful /props + settingsStore.updateConfig('apiKey', 'sk-user-key'); + mockProps({ apiKey: 'admin-placeholder', theme: 'dark' }); + + settingsStore.syncWithServerDefaults(); + + expect(settingsStore.config.apiKey).toBe('sk-user-key'); + expect(settingsStore.config.theme).toBe('dark'); + }); + it('Reset to Default reapplies the full baseline, api key included', () => { settingsStore.initialize(); settingsStore.updateConfig('theme', 'light');