From d0ea5e9409d10b1b5c82d69c2a11843d5f644793 Mon Sep 17 00:00:00 2001 From: GCWing Date: Thu, 8 Oct 2026 21:23:43 +0800 Subject: [PATCH] fix(settings): decouple model loading and polish settings presentation --- design-system/packages/ui/README.md | 7 + .../src/components/Combobox/Combobox.meta.ts | 1 + .../components/Combobox/Combobox.module.css | 2 +- .../components/FieldGroup/FieldGroup.meta.ts | 1 + .../FieldGroup/FieldGroup.module.css | 20 ++ .../src/components/FieldGroup/FieldGroup.tsx | 13 +- .../packages/ui/tests/field-group.test.mjs | 20 ++ .../pages/ai/ExecutionSettingsPage.tsx | 38 +-- .../pages/ai/MemorySettingsSection.tsx | 3 +- .../ai/ModelSettingsPage.loading.test.tsx | 136 ++++++++++ .../settings/pages/ai/ModelSettingsPage.tsx | 242 +++++++++++------- .../pages/application/PetSettingsSection.tsx | 1 - .../VoiceSettingsSection.presentation.test.ts | 2 +- .../pages/data/DiagnosticsSettingsPage.tsx | 21 +- .../development/TerminalSettingsPage.tsx | 6 +- .../development/WorktreeSettingsSection.tsx | 2 +- .../ApplicationSettings.presentation.test.ts | 5 +- .../pages/shared/RuntimeSettings.scss | 50 ++-- .../QuickActionsSettingsSection.appearance.ts | 5 +- ...ctionsSettingsSection.presentation.test.ts | 13 +- .../tools/QuickActionsSettingsSection.scss | 5 - .../tools/QuickActionsSettingsSection.tsx | 31 +-- .../config/components/DefaultModelConfig.tsx | 152 ++++------- .../config/components/VoiceInputConfig.scss | 2 +- .../components/common/ConfigPageLayout.scss | 7 + .../common/ConfigPageLayout.test.tsx | 16 ++ .../components/common/ConfigPageLayout.tsx | 19 +- .../infrastructure/update/UpdateMenuItems.tsx | 3 +- .../src/locales/en-US/settings/models.json | 2 +- .../src/locales/zh-CN/settings/models.json | 2 +- .../src/locales/zh-TW/settings/models.json | 2 +- 31 files changed, 519 insertions(+), 310 deletions(-) create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index efaf6b3bc4..c7eaa22c86 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -465,6 +465,8 @@ inside one border and shadow. Flipping above the field keeps the search header beside the anchor. Labels, validation, and the field id follow the active input; Escape or selection restores the trigger, and Tab continues from its position in the form. Search, typed values, and multiple selection remain component-owned. +Joined picker surfaces use the default neutral border on focus, matching the +quiet search treatment while preserving validation and forced-color states. `SearchField variant="embedded"` removes its standalone pill surface for these compositions; its container must supply padding, height, and visible focus treatment. Standalone SearchField pills use a subtle neutral border, increasing @@ -620,6 +622,11 @@ leading roles scale with user typography. FieldGroup uses the form group tint, retaining its existing row padding, dividers, and radius. The Patterns form specimen shows both orientations and long values over a tinted container. +FormSection keeps descriptions below the title by default. Use +`descriptionLayout="inline"` for compact summaries that share one line with +the title. The description uses OverflowText while the heading and actions +remain independent. + Menu and Listbox row surfaces are separated by `overlay.menu.rowGap` (2px), including grouped options and the Listbox used by Select, Combobox and MultiSelect. This is distinct from `itemGap` (icon-to-label spacing) and `sectionGap` (8px). diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.meta.ts b/design-system/packages/ui/src/components/Combobox/Combobox.meta.ts index 8622558dfd..1d836017fb 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.meta.ts +++ b/design-system/packages/ui/src/components/Combobox/Combobox.meta.ts @@ -28,6 +28,7 @@ export const comboboxMeta = { "color.action.neutral.content", "color.action.neutral.surface", "color.border.subtle", + "color.border.default", "color.surface.panel", "color.focus.ring", "color.status.danger.border", diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.module.css b/design-system/packages/ui/src/components/Combobox/Combobox.module.css index 8289a304ac..8bc63530f3 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.module.css +++ b/design-system/packages/ui/src/components/Combobox/Combobox.module.css @@ -305,7 +305,7 @@ } .popover:focus-within { - border-color: var(--openbitfun-color-field-border-focus); + border-color: var(--openbitfun-color-border-default); } .popover[data-invalid="true"] { diff --git a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.meta.ts b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.meta.ts index efbb40b178..f32c4d0d5e 100644 --- a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.meta.ts +++ b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.meta.ts @@ -8,6 +8,7 @@ export const fieldGroupMeta = { props: [ { name: "FormSection.title", type: "ReactNode" }, { name: "FormSection.description", type: "ReactNode" }, + { defaultValue: "stacked", name: "FormSection.descriptionLayout", type: "stacked | inline" }, { name: "FormSection.leading", type: "ReactNode" }, { name: "FormSection.actions", type: "ReactNode" }, { defaultValue: "h2", name: "FormSection.headingAs", type: "h2 | h3 | h4" }, diff --git a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.module.css b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.module.css index d550fb16bf..92ac373098 100644 --- a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.module.css +++ b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.module.css @@ -71,6 +71,26 @@ letter-spacing: var(--openbitfun-type-body-sm-letter-spacing); } + .section[data-description-layout="inline"] .header, + .section[data-description-layout="inline"] .headingRegion { + align-items: center; + } + + .section[data-description-layout="inline"] .headingContent { + flex-direction: row; + align-items: baseline; + gap: var(--openbitfun-layout-form-section-header-gap); + } + + .section[data-description-layout="inline"] .title { + flex: 0 0 auto; + } + + .section[data-description-layout="inline"] .description { + flex: 1 1 auto; + min-inline-size: 0; + } + .actions { display: flex; flex: 0 0 auto; diff --git a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx index 827f9e8b85..f7ca4c9c30 100644 --- a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx +++ b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx @@ -6,6 +6,7 @@ import { } from "react"; import { classNames } from "../../internal/classNames"; import { FieldSurfaceContext, type FieldSurface } from "../../internal/fieldSurface"; +import { OverflowText } from "../../primitives/OverflowText"; import styles from "./FieldGroup.module.css"; export type FieldGroupAppearance = "plain" | "subtle"; @@ -18,6 +19,8 @@ export interface FormSectionProps extends Omit, "title"> { actions?: ReactNode; description?: ReactNode; + /** Keep compact section summaries beside the title instead of below it. */ + descriptionLayout?: "stacked" | "inline"; headingAs?: FormSectionHeading; leading?: ReactNode; title?: ReactNode; @@ -41,6 +44,7 @@ export const FormSection = forwardRef( children, className, description, + descriptionLayout = "stacked", headingAs = "h2", leading, title, @@ -51,6 +55,7 @@ export const FormSection = forwardRef( {...props} className={classNames(styles.section, className)} data-openbitfun-component="form-section" + data-description-layout={descriptionLayout} ref={ref} > {(title !== undefined && title !== null) @@ -73,11 +78,15 @@ export const FormSection = forwardRef( { className: styles.title, "data-openbitfun-part": "title" }, title, )} - {description !== undefined && description !== null && ( + {description !== undefined && description !== null && (descriptionLayout === "inline" ? ( + + {description} + + ) : (
{description}
- )} + ))} ) : null} diff --git a/design-system/packages/ui/tests/field-group.test.mjs b/design-system/packages/ui/tests/field-group.test.mjs index 672330e9ce..9819194688 100644 --- a/design-system/packages/ui/tests/field-group.test.mjs +++ b/design-system/packages/ui/tests/field-group.test.mjs @@ -41,6 +41,7 @@ test("form grouping composes semantic sections, grouped surfaces, and independen ); assert.match(markup, /]+data-openbitfun-component="form-section"/); + assert.match(markup, /data-description-layout="stacked"/); assert.match(markup, /]+data-openbitfun-part="title"[^>]*>Provider<\/h3>/); assert.match(markup, /data-openbitfun-part="heading-region"/); assert.match(markup, /data-openbitfun-part="leading"[^>]*>
+ handleDeferredToolLoadingChange(event.target.checked)} + disabled={deferredToolLoadingConfigSaving} + /> +
+ + + {/* ── Tool execution behavior ────────────────────────────── */} { - - -
- handleDeferredToolLoadingChange(event.target.checked)} - disabled={deferredToolLoadingConfigSaving} - /> -
-
-
- diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/MemorySettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/MemorySettingsSection.tsx index 52b657f7e3..333f9b8b7b 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/MemorySettingsSection.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/MemorySettingsSection.tsx @@ -451,6 +451,7 @@ const MemorySettingsSection: React.FC = () => { { onClick={() => setAdvancedOpen((open) => !open)} aria-label={t(advancedOpen ? 'actions.collapseAdvanced' : 'actions.expandAdvanced')} aria-expanded={advancedOpen} - icon={advancedOpen ? : } + icon={} /> )} diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx new file mode 100644 index 0000000000..3d6eb7f337 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { aiApi } from '@/infrastructure/api'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import type { AIModelConfig } from '@/infrastructure/config/types'; +import { i18nService } from '@/infrastructure/i18n'; +import ModelSettingsPage from './ModelSettingsPage'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function deferred() { + let resolve!: (value: unknown) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const model: AIModelConfig = { + id: 'loading-test-model', + name: 'Test provider', + model_name: 'loading-test-model', + provider: 'openai', + base_url: 'https://example.test/v1', + api_key: '', + enabled: true, + category: 'general_chat', + capabilities: ['text_chat'], +}; + +// Service reads are controlled to exercise pending, failed, and recovered states. +// The actual settings and design-system components render; this is not visual QA. +describe('ModelSettingsPage independent loading', () => { + let container: HTMLDivElement; + let root: Root; + let reads: Map>; + const copy = (key: string) => String(i18nService.getI18nInstance().t(key, { ns: 'settings/models' })); + const button = (key: string) => [...container.querySelectorAll('button')] + .find(element => element.textContent === copy(key))!; + const selectors = () => [...container.querySelectorAll( + '.default-model-config [role="combobox"]', + )]; + + beforeAll(async () => { + await Promise.all((['settings', 'settings/models', 'settings/default-model', 'components'] as const).map( + namespace => i18nService.loadNamespace(namespace), + )); + }); + + beforeEach(() => { + reads = new Map([ + 'ai.models', 'ai.default_models', 'ai.proxy', + 'ai.stream_idle_timeout_secs', 'ai.stream_ttft_timeout_secs', + ].map(path => [path, deferred()])); + vi.spyOn(configManager, 'getOptionalConfig').mockImplementation( + (path: string) => reads.get(path)!.promise as Promise, + ); + vi.spyOn(configManager, 'getConfig').mockImplementation( + (path?: string) => reads.get(path!)!.promise as Promise, + ); + vi.spyOn(aiApi, 'getModelCatalog').mockImplementation(() => new Promise(() => {})); + vi.spyOn(aiApi, 'getLocalModelsDevCatalogs').mockImplementation(() => new Promise(() => {})); + vi.spyOn(aiApi, 'getModelsDevCatalogStatus').mockImplementation(() => new Promise(() => {})); + vi.spyOn(aiApi, 'listSubscriptionAccounts').mockImplementation(() => new Promise(() => {})); + vi.spyOn(aiApi, 'onModelCatalogUpdated').mockReturnValue(() => {}); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('shows stable content immediately and releases models without waiting for catalogs or network settings', async () => { + await act(async () => root.render()); + + for (const key of ['sections.acquisition', 'sections.selectionModes', 'sections.pool', 'sections.more']) { + expect(container.textContent).toContain(copy(key)); + } + for (const key of ['acquisition.noneConfigured', 'acquisition.noneImported', 'pool.empty']) { + expect(container.textContent).not.toContain(copy(key)); + } + expect(selectors()).toHaveLength(4); + expect(selectors().every(element => element.disabled)).toBe(true); + expect(button('actions.addModel').disabled).toBe(true); + expect(button('acquisition.import').disabled).toBe(false); + + await act(async () => { + reads.get('ai.models')!.resolve([model]); + reads.get('ai.default_models')!.resolve({ primary: model.id }); + }); + + expect(container.querySelector('[data-testid="settings-model-pool"]')?.textContent).toContain(model.model_name); + expect(selectors()[0].disabled).toBe(false); + expect(selectors()[0].textContent).toContain(model.model_name); + expect(button('actions.addModel').disabled).toBe(false); + }); + + it('isolates a failed model read, keeps network settings usable, and recovers through the section retry', async () => { + await act(async () => root.render()); + await act(async () => { + reads.get('ai.models')!.reject(new Error('Model host unavailable')); + reads.get('ai.default_models')!.resolve({}); + reads.get('ai.proxy')!.resolve({ enabled: true, url: 'http://localhost:7890' }); + reads.get('ai.stream_idle_timeout_secs')!.resolve(120); + reads.get('ai.stream_ttft_timeout_secs')!.resolve(60); + }); + + expect(container.textContent).toContain(copy('messages.loadFailedLocked')); + expect(container.textContent).toContain(copy('sections.selectionModes')); + expect(container.textContent).not.toContain(copy('pool.empty')); + expect(selectors().every(element => element.disabled)).toBe(true); + await act(async () => button('sections.more').click()); + const proxy = [...container.querySelectorAll('input')] + .find(element => element.value === 'http://localhost:7890'); + expect(proxy?.disabled).toBe(false); + expect([...container.querySelectorAll('input')].some( + element => element.value === '120' && !element.disabled, + )).toBe(true); + + reads.set('ai.models', deferred()); + await act(async () => button('messages.retry').click()); + await act(async () => reads.get('ai.models')!.resolve([model])); + expect(container.textContent).not.toContain(copy('messages.loadFailedLocked')); + expect(container.querySelector('[data-testid="settings-model-pool"]')?.textContent).toContain(model.model_name); + expect(button('actions.addModel').disabled).toBe(false); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx index 076e40bbec..0113cfe63c 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx @@ -402,6 +402,7 @@ const ModelSettingsPage: React.FC = () => { const { t: tDefault } = useI18n('settings/default-model'); const { t: tComponents } = useI18n('components'); const peerDevice = usePeerDeviceModeOptional(); + const modelDiscoverySurface = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : 'local'; const connectionTestSupported = !peerDevice?.peerMode.active || peerDevice.currentPeerCapabilities?.hostKind !== 'cli'; const [aiModels, setAiModels] = useState([]); @@ -415,6 +416,9 @@ const ModelSettingsPage: React.FC = () => { const [subscriptionLoadError, setSubscriptionLoadError] = useState(false); const [isConfigLoading, setIsConfigLoading] = useState(true); const [configLoadError, setConfigLoadError] = useState(false); + const [proxyLoadState, setProxyLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); + const [streamTimeoutLoadState, setStreamTimeoutLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); + const modelConfigReady = !isConfigLoading && !configLoadError; const [modelCatalog, setModelCatalog] = useState> | null>(null); const [modelsDevStatus, setModelsDevStatus] = useState> | null>(null); const [modelsDevStatusAvailable, setModelsDevStatusAvailable] = useState(true); @@ -533,7 +537,7 @@ const ModelSettingsPage: React.FC = () => { const [subscriptionAccounts, setSubscriptionAccounts] = useState([]); const subscriptionRefreshesRef = React.useRef(new Set()); const [refreshingSubscriptionProviders, setRefreshingSubscriptionProviders] = useState>(new Set()); - const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false); + const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(true); const [loggingInProvider, setLoggingInProvider] = useState(null); const [subscriptionLoginPanel, setSubscriptionLoginPanel] = useState(null); const [subscriptionLoginClock, setSubscriptionLoginClock] = useState(() => Date.now()); @@ -606,6 +610,7 @@ const ModelSettingsPage: React.FC = () => { }, [getCustomRequestBodyTrimHint, t]); const loadModelCatalog = useCallback(async () => { + const scope = getActiveSurfaceScope(); try { // Host-owned facts (configured models, defaults, session selection) come // from the rendered host. The provider templates and the reasoning @@ -622,6 +627,7 @@ const ModelSettingsPage: React.FC = () => { return null; }), ]); + if (!scope.isCurrent()) return; setModelCatalog(localCatalogs ? { ...hostCatalog, @@ -630,16 +636,21 @@ const ModelSettingsPage: React.FC = () => { } : hostCatalog); } catch (error) { + if (!scope.isCurrent()) return; setModelCatalog(null); log.warn('Failed to load model reasoning catalog', { error }); } }, []); const loadModelsDevStatus = useCallback(async () => { + const scope = getActiveSurfaceScope(); try { - setModelsDevStatus(await aiApi.getModelsDevCatalogStatus()); + const status = await aiApi.getModelsDevCatalogStatus(); + if (!scope.isCurrent()) return; + setModelsDevStatus(status); setModelsDevStatusAvailable(true); } catch (error) { + if (!scope.isCurrent()) return; setModelsDevStatusAvailable(false); log.warn('Failed to load models.dev catalog status', { error }); } @@ -668,42 +679,80 @@ const ModelSettingsPage: React.FC = () => { }, [loadModelCatalog, loadModelsDevStatus, notification, t]); const loadConfig = useCallback(async () => { + const scope = getActiveSurfaceScope(); setIsConfigLoading(true); setConfigLoadError(false); try { - const [models, proxy, streamIdleTimeoutSecs, streamTtftTimeoutSecs] = await Promise.all([ - configManager.getConfig('ai.models'), - configManager.getConfig('ai.proxy'), - configManager.getConfig('ai.stream_idle_timeout_secs'), - configManager.getConfig('ai.stream_ttft_timeout_secs'), + // Optional reads preserve missing-key compatibility while propagating host + // failures, instead of presenting a failed model read as an empty pool. + const [models, defaults] = await Promise.all([ + configManager.getOptionalConfig('ai.models'), + configManager.getOptionalConfig('ai.default_models'), ]); + if (!scope.isCurrent()) return; setAiModels(models || []); - await loadModelCatalog(); - await loadModelsDevStatus(); + setPoolDefaults(defaults || {}); + } catch (error) { + if (!scope.isCurrent()) return; + log.error('Failed to load AI model config', error); + setConfigLoadError(true); + } finally { + if (scope.isCurrent()) setIsConfigLoading(false); + } + }, []); + + const loadProxyConfig = useCallback(async () => { + const scope = getActiveSurfaceScope(); + setProxyLoadState('loading'); + try { + const proxy = await configManager.getConfig('ai.proxy'); + if (!scope.isCurrent()) return; const resolvedProxy = proxy || { enabled: false, url: '', username: '', password: '' }; - const idle = streamIdleTimeoutSecs != null ? String(streamIdleTimeoutSecs) : ''; - const ttft = streamTtftTimeoutSecs != null ? String(streamTtftTimeoutSecs) : ''; setProxyConfig(resolvedProxy); setSavedProxyConfig(resolvedProxy); + setProxyLoadState('ready'); + } catch (error) { + if (!scope.isCurrent()) return; + log.error('Failed to load AI proxy config', error); + setProxyLoadState('error'); + } + }, []); + + const loadStreamTimeouts = useCallback(async () => { + const scope = getActiveSurfaceScope(); + setStreamTimeoutLoadState('loading'); + try { + const [streamIdleTimeoutSecs, streamTtftTimeoutSecs] = await Promise.all([ + configManager.getConfig('ai.stream_idle_timeout_secs'), + configManager.getConfig('ai.stream_ttft_timeout_secs'), + ]); + if (!scope.isCurrent()) return; + const idle = streamIdleTimeoutSecs != null ? String(streamIdleTimeoutSecs) : ''; + const ttft = streamTtftTimeoutSecs != null ? String(streamTtftTimeoutSecs) : ''; setStreamIdleTimeoutInput(idle); setStreamTtftTimeoutInput(ttft); setSavedStreamTimeouts({ idle, ttft }); + setStreamTimeoutLoadState('ready'); } catch (error) { - log.error('Failed to load AI config', error); - setConfigLoadError(true); - } finally { - setIsConfigLoading(false); + if (!scope.isCurrent()) return; + log.error('Failed to load AI stream timeout config', error); + setStreamTimeoutLoadState('error'); } - }, [loadModelCatalog, loadModelsDevStatus]); + }, []); useEffect(() => { const unsubscribeCatalog = aiApi.onModelCatalogUpdated(() => { void loadModelCatalog(); void loadModelsDevStatus(); }); - loadConfig(); + // Independent sections become usable as soon as their own data is ready. + void loadConfig(); + void loadProxyConfig(); + void loadStreamTimeouts(); + void loadModelCatalog(); + void loadModelsDevStatus(); return unsubscribeCatalog; - }, [loadConfig, loadModelCatalog, loadModelsDevStatus]); + }, [loadConfig, loadProxyConfig, loadStreamTimeouts, loadModelCatalog, loadModelsDevStatus, modelDiscoverySurface]); const refreshSubscriptionAccounts = useCallback(async () => { const scope = getActiveSurfaceScope(); @@ -724,7 +773,7 @@ const ModelSettingsPage: React.FC = () => { useEffect(() => { refreshSubscriptionAccounts(); - }, [refreshSubscriptionAccounts]); + }, [refreshSubscriptionAccounts, modelDiscoverySurface]); useEffect(() => { if (!subscriptionLoginPanel || subscriptionLoginPanel.status !== 'pending') return; @@ -845,7 +894,6 @@ const ModelSettingsPage: React.FC = () => { modelDiscoveryRef.current.reset(); }, []); - const modelDiscoverySurface = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : 'local'; useEffect(() => { let disposed = false; let revision = 0; @@ -854,8 +902,8 @@ const ModelSettingsPage: React.FC = () => { const requestRevision = ++revision; try { const [models, defaults] = await Promise.all([ - configManager.getConfig('ai.models'), - configManager.getConfig('ai.default_models'), + configManager.getOptionalConfig('ai.models'), + configManager.getOptionalConfig('ai.default_models'), ]); if (disposed || !scope.isCurrent() || revision !== requestRevision) return; setAiModels(models || []); @@ -864,7 +912,6 @@ const ModelSettingsPage: React.FC = () => { if (!disposed && scope.isCurrent()) log.warn('Failed to refresh model pool', { error }); } }; - void refreshPool(); const unsubscribe = configManager.onConfigChange(path => { if (!path || path === 'ai' || path.startsWith('ai.models') || path.startsWith('ai.default_models')) { void refreshPool(); @@ -2186,6 +2233,7 @@ const ModelSettingsPage: React.FC = () => { }; const handleSaveProxy = async (): Promise => { + if (proxyLoadState !== 'ready') return false; if (!isProxyDirty) return true; if (proxySavingRef.current) return false; if (proxyConfig.enabled && !proxyConfig.url.trim()) { @@ -2213,6 +2261,7 @@ const ModelSettingsPage: React.FC = () => { }; const handleSaveStreamTimeouts = async (): Promise => { + if (streamTimeoutLoadState !== 'ready') return false; if (!isStreamTimeoutDirty) return true; if (streamTimeoutSavingRef.current) return false; if (isStreamTimeoutInvalid) { @@ -2431,25 +2480,6 @@ const ModelSettingsPage: React.FC = () => { }).sort((a, b) => priority(a) - priority(b)); }, [aiModels, poolQuery, poolCapability, poolProvider, poolRoles, poolDefaults]); - if (isConfigLoading || configLoadError) { - return ( - - - - {isConfigLoading ? ( -
{t('messages.loading')}
- ) : ( - void loadConfig()} - /> - )} -
-
- ); - } - if (creationMode === 'selection') { return ( @@ -3569,7 +3599,7 @@ const ModelSettingsPage: React.FC = () => { )} > - {apiProviderGroups.length > 0 ? ( + {!modelConfigReady ? pendingConfigSummary : apiProviderGroups.length > 0 ? (
    {apiProviderGroups.map(group => (
  • @@ -4034,7 +4085,7 @@ const ModelSettingsPage: React.FC = () => {
    {t('acquisition.subscriptions')} - {subscriptionProviderGroups.length > 0 && ( + {modelConfigReady && subscriptionProviderGroups.length > 0 && ( {t('acquisition.importedSummary', { providers: i18nService.formatNumber(subscriptionProviderGroups.length), @@ -4049,7 +4100,7 @@ const ModelSettingsPage: React.FC = () => {
    )} > - {subscriptionProviderGroups.length > 0 ? ( + {!modelConfigReady ? pendingConfigSummary : subscriptionProviderGroups.length > 0 ? (
      {subscriptionProviderGroups.map(group => (
    • @@ -4097,6 +4148,7 @@ const ModelSettingsPage: React.FC = () => { size="sm" variant="quiet" aria-label={t('pool.manageConnections')} + disabled={!modelConfigReady} onClick={() => setShowProviderManager(true)} icon={} /> @@ -4104,35 +4156,37 @@ const ModelSettingsPage: React.FC = () => { )} )}> - {aiModels.length > 0 && ( -
      - -
      - )} +
      + +
      {hasSuspendedEditorDraft && ( setIsEditing(true)} onDiscard={closeEditingModal} /> )} - {aiModels.length > 0 ? ( +
      + setPoolQuery(event.target.value)} placeholder={t('pool.search')} + aria-label={t('pool.search')} /> + setPoolProvider(String(value))} + options={[{ value: '', label: t('pool.allProviders') }, ...poolProviderOptions]} /> +
      + {!modelConfigReady ? ( + renderSectionLoadState(isConfigLoading ? 'loading' : 'error', loadConfig) + ) : aiModels.length > 0 ? ( <> -
      - setPoolQuery(event.target.value)} placeholder={t('pool.search')} - aria-label={t('pool.search')} /> - setPoolProvider(String(value))} - options={[{ value: '', label: t('pool.allProviders') }, ...poolProviderOptions]} /> -
      {visiblePoolModels.length > 0 ? (
      @@ -4165,7 +4219,8 @@ const ModelSettingsPage: React.FC = () => { setStreamTtftTimeoutInput(e.target.value)} - placeholder={t('streamTtftTimeout.placeholder')} + placeholder={streamTimeoutPlaceholder ?? t('streamTtftTimeout.placeholder')} + disabled={streamTimeoutLoadState !== 'ready'} size="sm" /> @@ -4176,11 +4231,13 @@ const ModelSettingsPage: React.FC = () => { setStreamIdleTimeoutInput(e.target.value)} - placeholder={t('streamIdleTimeout.placeholder')} + placeholder={streamTimeoutPlaceholder ?? t('streamIdleTimeout.placeholder')} + disabled={streamTimeoutLoadState !== 'ready'} size="sm" /> - { saveLabel={t('streamIdleTimeout.save')} onSave={() => void handleSaveStreamTimeouts()} onDiscard={discardStreamTimeoutDraft} - /> + />} { description={t('proxy.enableHint')} > - setProxyConfig(prev => ({ ...prev, enabled: e.target.checked }))} - /> + /> : {proxyPlaceholder}} setProxyConfig(prev => ({ ...prev, url: e.target.value }))} - placeholder={t('proxy.urlPlaceholder')} - disabled={!proxyConfig.enabled} + placeholder={proxyPlaceholder ?? t('proxy.urlPlaceholder')} + disabled={proxyLoadState !== 'ready' || !proxyConfig.enabled} size="sm" /> @@ -4221,8 +4278,8 @@ const ModelSettingsPage: React.FC = () => { setProxyConfig(prev => ({ ...prev, username: e.target.value }))} - placeholder={t('proxy.usernamePlaceholder')} - disabled={!proxyConfig.enabled} + placeholder={proxyPlaceholder ?? t('proxy.usernamePlaceholder')} + disabled={proxyLoadState !== 'ready' || !proxyConfig.enabled} size="sm" /> @@ -4231,12 +4288,13 @@ const ModelSettingsPage: React.FC = () => { type="password" value={proxyConfig.password || ''} onChange={(e) => setProxyConfig(prev => ({ ...prev, password: e.target.value }))} - placeholder={t('proxy.passwordPlaceholder')} - disabled={!proxyConfig.enabled} + placeholder={proxyPlaceholder ?? t('proxy.passwordPlaceholder')} + disabled={proxyLoadState !== 'ready' || !proxyConfig.enabled} size="sm" /> - { saveLabel={t('proxy.save')} onSave={() => void handleSaveProxy()} onDiscard={discardProxyDraft} - /> + />}
      diff --git a/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx index 34814fdd76..a9f24e285f 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx @@ -280,7 +280,6 @@ const PetSettingsSection: React.FC<{ isActive?: boolean }> = ({ isActive = true ))} )} - )} > diff --git a/src/web-ui/src/app/scenes/settings/pages/application/VoiceSettingsSection.presentation.test.ts b/src/web-ui/src/app/scenes/settings/pages/application/VoiceSettingsSection.presentation.test.ts index 70bcffa865..cb65bb4e54 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/VoiceSettingsSection.presentation.test.ts +++ b/src/web-ui/src/app/scenes/settings/pages/application/VoiceSettingsSection.presentation.test.ts @@ -34,7 +34,7 @@ describe('VoiceSettingsSection status presentation', () => { expect(summaryRule).toContain('font-size: var(--openbitfun-type-body-sm-font-size)'); expect(summaryRule).toContain('line-height: var(--openbitfun-type-body-sm-line-height)'); expect(summaryRule).toContain('text-align: start'); - expect(warningRule).toContain('color: var(--openbitfun-color-status-warning-emphasis)'); + expect(warningRule).toContain('color: inherit'); expect(modelRule).toContain('color: var(--openbitfun-color-content-primary)'); expect(modelRule).toContain('font-weight: var(--openbitfun-type-label-selected-font-weight)'); }); diff --git a/src/web-ui/src/app/scenes/settings/pages/data/DiagnosticsSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/data/DiagnosticsSettingsPage.tsx index 7610170e00..4a55b1ad5e 100644 --- a/src/web-ui/src/app/scenes/settings/pages/data/DiagnosticsSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/data/DiagnosticsSettingsPage.tsx @@ -168,22 +168,13 @@ function LoggingSection() {
      - {runtimeInfo?.previousUnexpectedExit?.detected && ( + {runtimeInfo?.previousUnexpectedExit?.detected && runtimeInfo.previousUnexpectedExit.category === 'crash' && ( )} diff --git a/src/web-ui/src/app/scenes/settings/pages/development/TerminalSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/development/TerminalSettingsPage.tsx index dca9736368..258bf69d83 100644 --- a/src/web-ui/src/app/scenes/settings/pages/development/TerminalSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/development/TerminalSettingsPage.tsx @@ -19,9 +19,6 @@ type TerminalShellOption = ComboboxOption & { shell?: ShellInfo; }; -const formatShellLabel = (shell: ShellInfo): string => - `${shell.name}${shell.version ? ` (${shell.version})` : ''}`; - function TerminalSection() { const { t } = useTranslation('settings/application'); const [defaultShell, setDefaultShell] = useState(''); @@ -89,9 +86,8 @@ function TerminalSection() { () => [ { value: AUTO_DETECT_SHELL_VALUE, label: t('terminal.controls.autoDetect') }, ...availableShells.map((shell) => ({ - description: shell.path, value: shell.path, - label: formatShellLabel(shell), + label: shell.name, shell, })), ], diff --git a/src/web-ui/src/app/scenes/settings/pages/development/WorktreeSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/development/WorktreeSettingsSection.tsx index 82911f486c..a80c66ec72 100644 --- a/src/web-ui/src/app/scenes/settings/pages/development/WorktreeSettingsSection.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/development/WorktreeSettingsSection.tsx @@ -728,7 +728,7 @@ const WorktreeSettingsSection: React.FC = () => { className="openbitfun-worktree-settings__empty" icon={} title={t('management.empty.title')} - description={t('management.empty.description')} + description={null} /> ); } diff --git a/src/web-ui/src/app/scenes/settings/pages/shared/ApplicationSettings.presentation.test.ts b/src/web-ui/src/app/scenes/settings/pages/shared/ApplicationSettings.presentation.test.ts index ea68739bb7..b9bc30f2a2 100644 --- a/src/web-ui/src/app/scenes/settings/pages/shared/ApplicationSettings.presentation.test.ts +++ b/src/web-ui/src/app/scenes/settings/pages/shared/ApplicationSettings.presentation.test.ts @@ -24,15 +24,16 @@ describe('Application settings presentation', () => { expect(source).toContain('{isTauri && ('); }); - it('keeps the previous-exit notice outside the settings section surface', () => { + it('only shows confirmed-crash notices outside the settings section surface', () => { const loggingSection = readFileSync(fileURLToPath(new URL('../data/DiagnosticsSettingsPage.tsx', import.meta.url)), 'utf8'); const noticeStart = loggingSection.indexOf( - '{runtimeInfo?.previousUnexpectedExit?.detected && (', + "{runtimeInfo?.previousUnexpectedExit?.detected && runtimeInfo.previousUnexpectedExit.category === 'crash' && (", ); const settingsSectionStart = loggingSection.indexOf(''); expect(loggingSection).not.toBe(''); + expect(loggingSection).not.toContain('logging.previousUncleanShutdown'); expect(noticeStart).toBeGreaterThanOrEqual(0); expect(settingsSectionStart).toBeGreaterThan(noticeStart); expect(settingsSectionEnd).toBeGreaterThan(settingsSectionStart); diff --git a/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.scss b/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.scss index 6b380d094d..a9c06b28d7 100644 --- a/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.scss +++ b/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.scss @@ -139,18 +139,12 @@ min-width: 0; min-height: 96px; padding: var(--openbitfun-space-2) var(--openbitfun-space-3); - border: 1px solid var(--openbitfun-color-border-default); - border-radius: var(--openbitfun-radius-md); - background: var(--openbitfun-color-surface-scene); + border: 0; + background: transparent; color: var(--openbitfun-color-content-primary); font: inherit; text-align: start; cursor: pointer; - transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - } - - &__pet-summary:hover { - background: var(--openbitfun-color-action-quiet-hover); } &__pet-summary:focus-visible { @@ -190,10 +184,24 @@ } &__pet-stack { + --_pet-stack-spread: 0px; + --_pet-stack-lift: 0px; display: flex; flex: 0 0 auto; align-items: center; - padding-inline: var(--openbitfun-space-1); + padding-inline: var(--openbitfun-space-3); + } + + @media (hover: hover) and (pointer: fine) { + &__pet-stack:hover { + --_pet-stack-spread: var(--openbitfun-space-2); + --_pet-stack-lift: var(--openbitfun-space-1); + } + } + + &__pet-summary:focus-visible &__pet-stack { + --_pet-stack-spread: var(--openbitfun-space-2); + --_pet-stack-lift: var(--openbitfun-space-1); } &__pet-stack-card { @@ -206,15 +214,20 @@ border: 1px solid var(--openbitfun-color-border-default); border-radius: var(--openbitfun-radius-sm); background: var(--openbitfun-color-surface-scene); - transform: rotate(-6deg); + transform: translateX(calc(-1 * var(--_pet-stack-spread))) rotate(-6deg); + transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); & + & { margin-inline-start: -28px; - transform: rotate(-2deg); + transform: translateY(calc(-1 * var(--_pet-stack-lift))) rotate(-2deg); } &:last-child { - transform: rotate(4deg); + transform: translateX(var(--_pet-stack-spread)) rotate(4deg); + } + + &:only-child { + transform: none; } } @@ -223,16 +236,6 @@ height: 54px; } - &__pet-summary-chevron { - flex: 0 0 auto; - color: var(--openbitfun-color-content-muted); - transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - } - - &__pet-summary[aria-expanded='true'] &__pet-summary-chevron { - transform: rotate(180deg); - } - &__pet-gallery { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -383,8 +386,7 @@ @media (prefers-reduced-motion: reduce) { .openbitfun-runtime-settings { - &__pet-summary, - &__pet-summary-chevron, + &__pet-stack-card, &__pet-card-delete, &__pet-preview-sprite { animation: none; diff --git a/src/web-ui/src/app/scenes/settings/pages/tools/QuickActionsSettingsSection.appearance.ts b/src/web-ui/src/app/scenes/settings/pages/tools/QuickActionsSettingsSection.appearance.ts index 404eb2e2b3..fc3451b5c9 100644 --- a/src/web-ui/src/app/scenes/settings/pages/tools/QuickActionsSettingsSection.appearance.ts +++ b/src/web-ui/src/app/scenes/settings/pages/tools/QuickActionsSettingsSection.appearance.ts @@ -3,11 +3,8 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; export const quickActionsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'quick-actions-config', parts: [ - { id: 'root' }, { id: 'content' }, { id: 'list' }, { id: 'empty' }, + { id: 'root' }, { id: 'content' }, { id: 'list' }, { id: 'dialog' }, { id: 'dialogIcon' }, { id: 'field' }, { id: 'dialogFooter' }, { id: 'row' }, { id: 'rowIcon' }, { id: 'rowBody' }, { id: 'rowControls' }, ], - states: [ - { id: 'empty', selector: { kind: 'self', suffix: '[data-openbitfun-state~="empty"]' } }, - ], }; diff --git a/src/web-ui/src/app/scenes/settings/pages/tools/QuickActionsSettingsSection.presentation.test.ts b/src/web-ui/src/app/scenes/settings/pages/tools/QuickActionsSettingsSection.presentation.test.ts index 80578f694a..03c173bb22 100644 --- a/src/web-ui/src/app/scenes/settings/pages/tools/QuickActionsSettingsSection.presentation.test.ts +++ b/src/web-ui/src/app/scenes/settings/pages/tools/QuickActionsSettingsSection.presentation.test.ts @@ -20,12 +20,11 @@ describe('QuickActionsConfig draft lifecycle', () => { expect(source).not.toContain('discardConfirmOpen'); }); - it('uses the shared empty-state presentation for custom actions', () => { - expect(source).toContain('}'); - expect(source).toContain('description={t(\'sections.custom.empty\')}'); - expect(source).not.toContain('imageSize="sm"'); - expect(source).not.toContain('icon={ { + expect(source).toContain('customActions.length > 0 && ('); + expect(source).toContain("{t('add.button')}"); + expect(source).not.toContain(' { } > -
      - {customActions.length === 0 ? ( -
      - } - description={t('sections.custom.empty')} - actions={( - - )} - /> -
      - ) : ( - customActions.map(action => ( + {customActions.length > 0 && ( +
      + {customActions.map(action => ( { disabled={saving} t={t} /> - )) - )} -
      + ))} +
      + )} diff --git a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx index 702d8fddd4..f5cd24e5a3 100644 --- a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx @@ -1,14 +1,14 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Layers } from 'lucide-react'; -import { Combobox, Icon, IconButton, Spinner, Tooltip } from '@openbitfun/ui'; +import { Combobox, Icon, IconButton, Tooltip } from '@openbitfun/ui'; import { notificationService } from '@/shared/notification-system'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { configManager } from '../services/ConfigManager'; import type { AIModelConfig, - DefaultModels, + DefaultModelsConfig, } from '../types'; -import { ConfigEmptyState, ConfigPageRow } from './common'; +import { ConfigPageRow } from './common'; import { createLogger } from '@/shared/utils/logger'; import { useModelSelectPresentation } from './ModelSelectPresentation'; import { @@ -24,9 +24,35 @@ const normalizeSelectValue = (value: string | number | (string | number)[]): str type DefaultModelSlot = 'primary' | 'fast' | 'image_understanding' | 'speech_recognition'; -export const DefaultModelConfig: React.FC<{ compact?: boolean }> = ({ compact = false }) => { +interface DefaultModelConfigProps { + compact?: boolean; + models: AIModelConfig[]; + defaultModels: DefaultModelsConfig; + loading: boolean; + disabled: boolean; + onDefaultModelsChange: (defaults: DefaultModelsConfig) => void; +} + +export const DefaultModelConfig: React.FC = ({ + compact = false, + models, + defaultModels, + loading, + disabled, + onDefaultModelsChange, +}) => { const { t } = useTranslation('settings/default-model'); const { buildModelOption } = useModelSelectPresentation(); + const controlsDisabled = loading || disabled || models.length === 0; + const pendingPlaceholder = loading ? ( + + {t('loading')} + + ) : disabled ? t('messages.loadFailed') : models.length === 0 ? ( + + {t('empty.noModels')} + + ) : undefined; const renderSlotLabel = (label: string, description: string) => ( {label} @@ -41,58 +67,6 @@ export const DefaultModelConfig: React.FC<{ compact?: boolean }> = ({ compact = ); - - const [loading, setLoading] = useState(true); - const [models, setModels] = useState([]); - const [defaultModels, setDefaultModels] = useState({ - primary: null, - fast: null, - image_understanding: null, - speech_recognition: null, - }); - - const loadData = useCallback(async () => { - try { - setLoading(true); - - const [allModels, defaultModelsConfig] = await Promise.all([ - configManager.getConfig('ai.models') || [], - configManager.getConfig('ai.default_models') || {}, - ]); - - setModels(allModels); - - setDefaultModels({ - primary: defaultModelsConfig?.primary || null, - fast: defaultModelsConfig?.fast || null, - image_understanding: defaultModelsConfig?.image_understanding || null, - speech_recognition: defaultModelsConfig?.speech_recognition || null, - }); - } catch (error) { - log.error('Failed to load data', error); - notificationService.error(t('messages.loadFailed')); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - void loadData(); - - const unsubscribeModels = configManager.watch('ai.models', () => { - void loadData(); - }); - const unsubscribeDefaultModels = configManager.watch('ai.default_models', () => { - void loadData(); - }); - - return () => { - unsubscribeModels(); - unsubscribeDefaultModels(); - }; - }, [loadData]); - - const getModelName = useCallback((modelId: string | null | undefined): string | undefined => { if (!modelId) return undefined; const model = models.find(m => m.id === modelId); @@ -118,20 +92,16 @@ export const DefaultModelConfig: React.FC<{ compact?: boolean }> = ({ compact = }, [t]); const handleDefaultModelChange = async (slot: DefaultModelSlot, modelId: string | number) => { + if (controlsDisabled) return; + const scope = getActiveSurfaceScope(); const modelIdStr = modelId ? String(modelId) : null; try { - const currentConfig = await configManager.getConfig('ai.default_models') || {}; - - - await configManager.setConfig('ai.default_models', { - ...currentConfig, - [slot]: modelIdStr, - }); - - setDefaultModels(prev => ({ - ...prev, - [slot]: modelIdStr, - })); + const updatedDefaults = await configManager.updateConfig( + 'ai.default_models', + current => ({ ...current, [slot]: modelIdStr }), + ); + if (!scope.isCurrent()) return; + onDefaultModelsChange(updatedDefaults); const modelName = getModelName(modelIdStr); let successMessage: string; @@ -150,6 +120,7 @@ export const DefaultModelConfig: React.FC<{ compact?: boolean }> = ({ compact = { duration: 2000 } ); } catch (error) { + if (!scope.isCurrent()) return; log.error('Failed to update default model', { slot, modelId: modelIdStr, error }); notificationService.error(t('messages.updateFailed')); } @@ -166,29 +137,8 @@ export const DefaultModelConfig: React.FC<{ compact?: boolean }> = ({ compact = isSelectableModelForCapability(model, 'speech_recognition') )); - if (loading) { - return ( -
      - -

      {t('loading')}

      -
      - ); - } - - if (models.length === 0) { - return ( -