diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index daa54c601e..307ced34cb 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -678,6 +678,10 @@ the SearchField over that anchor. letting their shells reuse the grouped surface. The default field surface stays theme-owned, and portalled menus remain on the opaque panel surface. +`FieldGroup fieldSurface="filled"` uses the theme's grouped field background with +the standard resting, hover, focus, invalid, and forced-color borders. It applies to Input, Select, Combobox, Textarea, and NumberInput; nested +groups can explicitly restore the default surface. + `Menu` remains composable inline anatomy. `MenuPopover` composes it into a controlled anchored or coordinate popup. Pass `items`, `open`, `onClose` and either `anchorRef` or `position`. Entries can include `submenu`, `shortcut`, 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 1ec8876520..be328f2c64 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.module.css +++ b/design-system/packages/ui/src/components/Combobox/Combobox.module.css @@ -424,4 +424,13 @@ from { opacity: 0; } to { opacity: 1; } } + + /* Filled fields keep standard borders over the grouped-field background. */ + .root[data-field-surface="filled"] .trigger { + background: var(--openbitfun-color-field-group-background); + } + + .root[data-field-surface="filled"] .trigger:hover:not(:disabled) { + background: var(--openbitfun-color-field-background-hover); + } } 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 f32c4d0d5e..eb5db6a8e6 100644 --- a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.meta.ts +++ b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.meta.ts @@ -13,7 +13,7 @@ export const fieldGroupMeta = { { name: "FormSection.actions", type: "ReactNode" }, { defaultValue: "h2", name: "FormSection.headingAs", type: "h2 | h3 | h4" }, { defaultValue: "subtle", name: "appearance", type: "subtle | plain" }, - { defaultValue: "default", name: "fieldSurface", type: "default | ambient" }, + { defaultValue: "default", name: "fieldSurface", type: "default | ambient | filled" }, { defaultValue: "true", name: "dividers", type: "boolean" }, { defaultValue: "center", name: "FieldRow.align", type: "center | start" }, { defaultValue: "md", name: "FieldRow.padding", type: "none | md" }, diff --git a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx index f7ca4c9c30..e4e7e62511 100644 --- a/design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx +++ b/design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx @@ -29,7 +29,7 @@ export interface FormSectionProps export interface FieldGroupProps extends HTMLAttributes { appearance?: FieldGroupAppearance; dividers?: boolean; - /** Lets text and picker field shells reuse the surrounding grouped surface. */ + /** Ambient reuses the group surface; filled keeps standard borders over a grouped-field background. */ fieldSurface?: FieldGroupFieldSurface; } diff --git a/design-system/packages/ui/src/components/Input/Input.module.css b/design-system/packages/ui/src/components/Input/Input.module.css index f702775e0e..9051f9d7b1 100644 --- a/design-system/packages/ui/src/components/Input/Input.module.css +++ b/design-system/packages/ui/src/components/Input/Input.module.css @@ -145,4 +145,13 @@ border-color: Highlight; } } + + /* Filled fields keep standard borders over the grouped-field background. */ + .field[data-field-surface="filled"] { + background: var(--openbitfun-color-field-group-background); + } + + .field[data-field-surface="filled"]:hover:not([data-disabled="true"]) { + background: var(--openbitfun-color-field-background-hover); + } } diff --git a/design-system/packages/ui/src/components/NumberInput/NumberInput.module.css b/design-system/packages/ui/src/components/NumberInput/NumberInput.module.css index 35578ce10d..79b20509d6 100644 --- a/design-system/packages/ui/src/components/NumberInput/NumberInput.module.css +++ b/design-system/packages/ui/src/components/NumberInput/NumberInput.module.css @@ -24,4 +24,13 @@ .root[data-variant="default"] .buttons .stepButton + .stepButton { border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border); } @media (prefers-reduced-motion: reduce) { .control { transition: none; } } @media (forced-colors: active) { .control { border-color: ButtonText; } .root .control:focus-within { border-color: Highlight; } } + + /* Filled fields keep standard borders over the grouped-field background. */ + .root[data-field-surface="filled"] .control { + background: var(--openbitfun-color-field-group-background); + } + + .root[data-field-surface="filled"] .control:hover { + background: var(--openbitfun-color-field-background-hover); + } } diff --git a/design-system/packages/ui/src/components/Select/Select.module.css b/design-system/packages/ui/src/components/Select/Select.module.css index a87a2a596e..e37c75fee0 100644 --- a/design-system/packages/ui/src/components/Select/Select.module.css +++ b/design-system/packages/ui/src/components/Select/Select.module.css @@ -326,4 +326,13 @@ color: Highlight; } } + + /* Filled fields keep standard borders over the grouped-field background. */ + .root[data-field-surface="filled"] .trigger { + background: var(--openbitfun-color-field-group-background); + } + + .root[data-field-surface="filled"] .trigger:hover:not(:disabled) { + background: var(--openbitfun-color-field-background-hover); + } } diff --git a/design-system/packages/ui/src/components/Textarea/Textarea.module.css b/design-system/packages/ui/src/components/Textarea/Textarea.module.css index e493bc9fbc..003ae99d4e 100644 --- a/design-system/packages/ui/src/components/Textarea/Textarea.module.css +++ b/design-system/packages/ui/src/components/Textarea/Textarea.module.css @@ -39,4 +39,13 @@ .count { margin-inline-start: auto; white-space: nowrap; } @media (prefers-reduced-motion: reduce) { .textarea { transition: none; } } @media (forced-colors: active) { .textarea { border-color: ButtonText; } .root .textarea:focus { border-color: Highlight; } } + + /* Filled fields keep standard borders over the grouped-field background. */ + .root[data-field-surface="filled"] .textarea { + background: var(--openbitfun-color-field-group-background); + } + + .root[data-field-surface="filled"] .textarea:hover:not(:disabled) { + background: var(--openbitfun-color-field-background-hover); + } } diff --git a/design-system/packages/ui/src/internal/fieldSurface.ts b/design-system/packages/ui/src/internal/fieldSurface.ts index 821366aefc..45d2c2f756 100644 --- a/design-system/packages/ui/src/internal/fieldSurface.ts +++ b/design-system/packages/ui/src/internal/fieldSurface.ts @@ -1,6 +1,6 @@ import { createContext, useContext } from "react"; -export type FieldSurface = "ambient" | "default"; +export type FieldSurface = "ambient" | "default" | "filled"; export const FieldSurfaceContext = createContext("default"); diff --git a/design-system/packages/ui/tests/field-group.test.mjs b/design-system/packages/ui/tests/field-group.test.mjs index 9819194688..df447353bc 100644 --- a/design-system/packages/ui/tests/field-group.test.mjs +++ b/design-system/packages/ui/tests/field-group.test.mjs @@ -95,19 +95,21 @@ test("field surface context reaches canonical field shells and resets for nested ["number-input", createElement(NumberInput, { onValueChange() {}, value: 1 })], ]; - controls.forEach(([component, control]) => { - const markup = renderToStaticMarkup( - createElement(FieldGroup, { fieldSurface: "ambient" }, control), - ); - assert.match(markup, new RegExp(`data-openbitfun-component="${component}"[^>]+data-field-surface="ambient"`)); - }); + for (const fieldSurface of ["ambient", "filled"]) { + controls.forEach(([component, control]) => { + const markup = renderToStaticMarkup( + createElement(FieldGroup, { fieldSurface }, control), + ); + assert.match(markup, new RegExp(`data-openbitfun-component="${component}"[^>]+data-field-surface="${fieldSurface}"`)); + }); - const nestedMarkup = renderToStaticMarkup( - createElement(FieldGroup, { fieldSurface: "ambient" }, - createElement(FieldGroup, { fieldSurface: "default" }, createElement(Input)), - ), - ); - assert.match(nestedMarkup, /data-openbitfun-component="input"[^>]+data-field-surface="default"/); + const nestedMarkup = renderToStaticMarkup( + createElement(FieldGroup, { fieldSurface }, + createElement(FieldGroup, { fieldSurface: "default" }, createElement(Input)), + ), + ); + assert.match(nestedMarkup, /data-openbitfun-component="input"[^>]+data-field-surface="default"/); + } }); test("ambient field groups preserve opaque overlays while field shells reuse the group surface", async () => { 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 index 2c9efe647b..ba648945a3 100644 --- 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 @@ -83,14 +83,212 @@ describe('ModelSettingsPage loading and provider actions', () => { root = createRoot(container); }); - afterEach(() => { + afterEach(async () => { act(() => root.unmount()); + await i18nService.applyPersistedLanguage('en-US'); container.remove(); vi.useRealTimers(); vi.restoreAllMocks(); activateSurface('local'); }); + const editor = () => document.querySelector( + '.openbitfun-model-settings__editor-dialog:not([data-state="exiting"])', + ); + const editorButton = (key: string) => [...editor()!.querySelectorAll('button')] + .find(element => element.textContent === copy(key))!; + const editorDraft = () => getSettingsDraftSnapshot().resources.find(resource => resource.id === 'model-provider-editor')!; + const inputValue = async (input: HTMLInputElement, value: string) => act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + const openNewProvider = async (provider = 'custom') => { + await act(async () => root.render()); + await act(async () => { + reads.get('ai.models')!.resolve([]); + reads.get('ai.default_models')!.resolve({}); + }); + await act(async () => button('actions.addModel').click()); + if (provider !== 'custom') { + await inputValue(container.querySelector('[data-testid="settings-model-provider-search"]')!, provider); + } + await act(async () => container.querySelector(`button[data-provider-id="${provider}"]`)!.click()); + }; + + it.each([ + ['en-US', 'cancel'], ['zh-CN', 'escape'], ['zh-TW', 'close'], + ] as const)('opens a neutral, pristine custom provider in %s and dismisses with %s', async (locale, dismissal) => { + await i18nService.applyPersistedLanguage(locale); + const update = vi.spyOn(configManager, 'updateConfig'); + const probe = vi.spyOn(aiApi, 'testAIConfigConnection'); + const discover = vi.spyOn(aiApi, 'listModelsByConfig'); + await openNewProvider(); + expect(editor()!.querySelector('[data-testid="settings-provider-editor-brand"]')?.textContent).toBe(copy('providerSelection.customTitle')); + expect(editor()!.querySelector('[data-testid="settings-model-provider-name-input"]')!.value).toBe(''); + const endpoint = editor()!.querySelector('[data-testid="settings-model-base-url-input"]')!; + expect(endpoint.value).toBe(''); + expect(endpoint.placeholder).toBe(copy('form.baseUrlPlaceholder')); + expect(editor()!.textContent).not.toContain('bigmodel.cn'); + expect(editor()!.querySelector('.openbitfun-model-settings__resolved-url')).toBeNull(); + expect(editorDraft().dirty).toBe(false); + expect(editor()!.getAttribute('data-preset')).toBe('default'); + expect(editor()!.getAttribute('data-size')).toBe('lg'); + expect(endpoint.closest('[data-field-surface]')?.getAttribute('data-field-surface')).toBe('filled'); + await act(async () => editor()!.querySelector('[data-testid="settings-model-advanced-toggle"] button')!.click()); + expect(editorDraft().dirty).toBe(false); + await act(async () => { + if (dismissal === 'escape') document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + else if (dismissal === 'close') editor()!.querySelector('[data-openbitfun-part="close"]')!.click(); + else editorButton('actions.cancel').click(); + }); + expect(document.querySelector('[role="alertdialog"]')).toBeNull(); + expect(editor()).toBeNull(); + expect(editorDraft().dirty).toBe(false); + expect(container.querySelector('[data-openbitfun-view="selection"]')).not.toBeNull(); + expect(document.activeElement).toBe(container.querySelector('button[data-provider-id="custom"]')); + await act(async () => button('actions.cancel').click()); + expect(container.querySelector('[data-openbitfun-view="settings"]')).not.toBeNull(); + expect(update).not.toHaveBeenCalled(); + expect(probe).not.toHaveBeenCalled(); + expect(discover).not.toHaveBeenCalled(); + }); + + it.each(['custom', 'zhipu'])('treats reverted %s fields as pristine and preserves real changes across reopen', async provider => { + await openNewProvider(provider); + const endpoint = () => editor()!.querySelector('[data-testid="settings-model-base-url-input"]')!; + const initialUrl = endpoint().value; + expect(editorDraft().dirty).toBe(false); + if (provider === 'zhipu') expect(initialUrl).toContain('bigmodel.cn'); + await inputValue(endpoint(), 'https://custom.example.test/v1'); + expect(editorDraft().dirty).toBe(true); + await inputValue(endpoint(), initialUrl); + expect(editorDraft().dirty).toBe(false); + await inputValue(endpoint(), 'https://custom.example.test/v1'); + await act(async () => editorButton('actions.cancel').click()); + const confirmation = document.querySelector('[role="alertdialog"]')!; + expect(confirmation.textContent).toContain(copy('draftClose.title')); + await act(async () => [...confirmation.querySelectorAll('button')] + .find(element => element.textContent === copy('draftClose.keepAndClose'))!.click()); + expect(editor()).toBeNull(); + expect(editorDraft().dirty).toBe(true); + await act(async () => button('actions.addModel').click()); + expect(endpoint().value).toBe('https://custom.example.test/v1'); + expect(editorDraft().dirty).toBe(true); + await inputValue(endpoint(), initialUrl); + expect(editorDraft().dirty).toBe(false); + await act(async () => editorButton('actions.cancel').click()); + expect(editor()).toBeNull(); + expect(container.querySelector('[data-openbitfun-view="selection"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="settings-model-provider-search"]')!.value) + .toBe(provider === 'custom' ? '' : provider); + }); + + it.each(['custom', 'zhipu'])('returns a discarded %s draft to the same provider selection', async provider => { + const update = vi.spyOn(configManager, 'updateConfig'); + await openNewProvider(provider); + await act(async () => editorButton('actions.cancel').click()); + if (provider === 'custom') { + await act(async () => container.querySelector('[data-testid="settings-model-provider-expand-btn"]')!.click()); + } + const providerCount = container.querySelectorAll('button[data-provider-id]').length; + await act(async () => container.querySelector(`button[data-provider-id="${provider}"]`)!.click()); + const name = () => editor()!.querySelector('[data-testid="settings-model-provider-name-input"]')!; + const initialName = name().value; + await inputValue(name(), 'Unsaved provider'); + await act(async () => editorButton('actions.cancel').click()); + const confirmation = document.querySelector('[role="alertdialog"]')!; + await act(async () => [...confirmation.querySelectorAll('button')] + .find(element => element.textContent === copy('draftClose.discard'))!.click()); + expect(editor()).toBeNull(); + expect(editorDraft().dirty).toBe(false); + expect(container.querySelector('[data-openbitfun-view="selection"]')).not.toBeNull(); + expect(container.querySelectorAll('button[data-provider-id]')).toHaveLength(providerCount); + expect(document.activeElement).toBe(container.querySelector(`button[data-provider-id="${provider}"]`)); + expect(container.querySelector('[data-testid="settings-model-provider-search"]')!.value) + .toBe(provider === 'custom' ? '' : provider); + await act(async () => container.querySelector(`button[data-provider-id="${provider}"]`)!.click()); + expect(name().value).toBe(initialName); + expect(editorDraft().dirty).toBe(false); + expect(update).not.toHaveBeenCalled(); + }); + + it('returns an existing provider editor to the model overview', async () => { + await act(async () => root.render()); + await act(async () => { + reads.get('ai.models')!.resolve([model]); + reads.get('ai.default_models')!.resolve({}); + }); + await act(async () => container.querySelector('.openbitfun-model-settings__source-pill')!.click()); + expect(editorDraft().dirty).toBe(false); + await act(async () => editorButton('actions.cancel').click()); + expect(editor()).toBeNull(); + expect(container.querySelector('[data-openbitfun-view="settings"]')).not.toBeNull(); + expect(container.querySelector('[data-openbitfun-view="selection"]')).toBeNull(); + }); + + it.each(['provider', 'model'])('does not dirty an existing %s when reselecting or restoring default request modes', async target => { + const update = vi.spyOn(configManager, 'updateConfig'); + await act(async () => root.render()); + await act(async () => { + reads.get('ai.models')!.resolve([model]); + reads.get('ai.default_models')!.resolve({}); + }); + await act(async () => container.querySelector(target === 'provider' + ? '.openbitfun-model-settings__source-pill' + : `[data-config-id="${model.id}"] button[aria-label="${copy('actions.edit')}"]`, + )!.click()); + await act(async () => editor()!.querySelector('[data-testid="settings-model-advanced-toggle"] button')!.click()); + const mergeButtons = () => [...editor()!.querySelectorAll('.openbitfun-model-settings__mode-button')] + .filter(element => element.textContent === copy('advancedSettings.customHeaders.modeMerge')); + expect(mergeButtons()).toHaveLength(2); + await act(async () => mergeButtons().forEach(element => element.click())); + expect(editorDraft().dirty).toBe(false); + await act(async () => editorButton('advancedSettings.customHeaders.modeReplace').click()); + expect(editorDraft().dirty).toBe(true); + await act(async () => mergeButtons().forEach(element => element.click())); + expect(editorDraft().dirty).toBe(false); + await act(async () => editorButton('advancedSettings.customRequestBody.modeTrim').click()); + expect(editorDraft().dirty).toBe(true); + await act(async () => mergeButtons().forEach(element => element.click())); + expect(editorDraft().dirty).toBe(false); + await act(async () => editorButton('actions.cancel').click()); + expect(document.querySelector('[role="alertdialog"]')).toBeNull(); + expect(editor()).toBeNull(); + expect(update).not.toHaveBeenCalled(); + }); + + it.each(['en-US', 'zh-CN', 'zh-TW'] as const)('saves nested model edits into the provider draft in %s', async locale => { + await i18nService.applyPersistedLanguage(locale); + const update = vi.spyOn(configManager, 'updateConfig'); + await openNewProvider(); + await act(async () => editor()!.querySelector('[data-testid="settings-model-add-custom-btn"]')!.click()); + const manualName = editor()!.querySelector('[data-testid="settings-model-manual-name-input"]')!; + await inputValue(manualName, 'custom-model'); + await act(async () => manualName.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))); + await act(async () => editor()!.querySelector('[data-testid="settings-model-capsule-edit"]')!.click()); + await inputValue(editor()!.querySelector('[data-testid="settings-model-name-input"]')!, 'renamed-model'); + const save = editor()!.querySelector('[data-testid="settings-model-save-btn"]')!; + expect(save.textContent).toBe(copy('actions.save')); + await act(async () => save.click()); + expect(editor()!.querySelector('[data-testid="settings-model-name-input"]')).toBeNull(); + expect(editor()!.querySelector('[data-testid="settings-model-selected-row"]')?.getAttribute('data-model-name')).toBe('renamed-model'); + expect(editorDraft().dirty).toBe(true); + expect(update).not.toHaveBeenCalled(); + }); + + it('protects a typed custom model and clears dirty state when that model is removed', async () => { + await openNewProvider(); + await act(async () => editor()!.querySelector('[data-testid="settings-model-add-custom-btn"]')!.click()); + const name = editor()!.querySelector('[data-testid="settings-model-manual-name-input"]')!; + await inputValue(name, 'custom-model'); + expect(editorDraft().dirty).toBe(true); + await act(async () => name.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))); + expect(editor()!.querySelector('[data-testid="settings-model-selected-row"]')).not.toBeNull(); + expect(editorDraft().dirty).toBe(true); + await act(async () => editor()!.querySelector('[data-testid="settings-model-selected-remove-btn"]')!.click()); + expect(editorDraft().dirty).toBe(false); + }); + it.each([false, true])('shows host accounts on the page without importing a model (account slots: %s)', async slotsSupported => { const account = { provider: 'codex' as const, connected: true, account: 'shared@example.invalid', @@ -815,7 +1013,7 @@ describe('ModelSettingsPage loading and provider actions', () => { const editor = document.querySelector('.openbitfun-model-settings__editor-dialog')!; const brand = editor.querySelector('[data-testid="settings-provider-editor-brand"]')!; expect(brand.getAttribute('aria-label')).toBe(model.name); - expect(editor.getAttribute('data-preset')).toBe('panel'); + expect(editor.getAttribute('data-preset')).toBe('default'); expect(editor.querySelector('[data-openbitfun-part="title"]')?.classList.contains('sr-only')).toBe(true); expect(brand.closest('[data-openbitfun-part="heading"]')).not.toBeNull(); if (kind === 'subscription') expect(brand.querySelector('[data-product-logo="codex"]')?.style.width).toBe('64px'); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss index b934fb0c19..6e92cdc4b2 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss @@ -1275,60 +1275,43 @@ } &__connection-section > .openbitfun-config-page-section__body { - display: grid; - grid-template-columns: 112px minmax(0, 1.6fr) 96px minmax(0, 1fr); - column-gap: var(--openbitfun-space-4); + display: flex; + flex-direction: column; + gap: var(--openbitfun-space-4); + overflow: visible; } - &__connection-section &__connection-row, - &__connection-section &__endpoint-row { + &__connection-section &__connection-row { display: grid; - grid-column: 1 / -1; - grid-template-columns: subgrid; - column-gap: var(--openbitfun-space-4); - min-width: 0; - padding: var(--openbitfun-space-3) var(--openbitfun-space-4); - } - - &__connection-row > .openbitfun-config-page-row { - grid-column: span 2; - grid-template-columns: subgrid; - column-gap: var(--openbitfun-space-4); + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: var(--openbitfun-space-4); min-width: 0; - padding: 0; - border-top: 0; } - &__connection-row > .openbitfun-config-page-row + .openbitfun-config-page-row { - border-top: 0; + &__connection-section &__connection-row--single { + grid-template-columns: minmax(0, 1fr); } - @media (hover: hover) and (pointer: fine) { - @container model-editor (width > 560px) { - &__connection-row:hover { - background: var(--openbitfun-component-config-page-row-hover-background); + &__connection-row > .openbitfun-config-page-row, + &__connection-section &__endpoint-row { + grid-template-columns: minmax(0, 1fr); + gap: var(--openbitfun-space-2); + min-width: 0; + padding: 0; + border: 0; - > .openbitfun-config-page-row:hover { - background: transparent; - } - } + > .openbitfun-config-page-row__meta { + max-inline-size: none; } - } - &__connection-row--single > .openbitfun-config-page-row { - grid-column: 1 / -1; - } - - &__connection-row--single > .openbitfun-config-page-row, - &__connection-section &__endpoint-row { > .openbitfun-config-page-row__control { - grid-column: 2 / -1; + grid-column: 1; } - } - &__connection-row + &__connection-row, - &__connection-row + &__endpoint-row { - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-component-config-page-divider); + &:hover { + background: transparent; + } } &__connection-value { @@ -1735,8 +1718,10 @@ &__selected-models-empty { flex-basis: 100%; - padding-block: var(--openbitfun-space-4); - text-align: center; + padding: var(--openbitfun-space-3) var(--openbitfun-space-4); + border-radius: var(--openbitfun-radius-base); + background: var(--openbitfun-color-field-group-background); + text-align: start; color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-support-font-size); line-height: var(--openbitfun-type-support-line-height); @@ -1838,9 +1823,9 @@ &__provider-editor-brand { display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; color: var(--openbitfun-color-content-primary); - text-align: center; + text-align: start; overflow-wrap: anywhere; font-family: var(--openbitfun-type-heading-dialog-font-family); font-size: var(--openbitfun-type-heading-dialog-font-size); @@ -1868,6 +1853,10 @@ &__advanced-settings-content { padding-inline: 0; + + .openbitfun-config-page-row { + border: 0; + } } &__form-content { @@ -2766,7 +2755,7 @@ } } -@container model-editor (max-width: 560px) { +@container model-editor (max-width: 480px) { .openbitfun-model-settings__advanced-settings { .openbitfun-config-page-row:not(.openbitfun-model-settings__toggle-row) { grid-template-columns: minmax(0, 1fr); @@ -2797,34 +2786,14 @@ } } - .openbitfun-model-settings__connection-section > .openbitfun-config-page-section__body { + .openbitfun-model-settings__connection-section .openbitfun-model-settings__connection-row { grid-template-columns: minmax(0, 1fr); } - .openbitfun-model-settings__connection-row > .openbitfun-config-page-row { - grid-column: 1 / -1; - padding-block: var(--openbitfun-space-3); - } - - .openbitfun-model-settings__connection-row--single > .openbitfun-config-page-row, - .openbitfun-model-settings__connection-section .openbitfun-model-settings__endpoint-row { - > .openbitfun-config-page-row__control { - grid-column: 1; - } - } - .openbitfun-model-settings__form--modal .openbitfun-model-settings__edit-section .openbitfun-config-page-row { row-gap: var(--openbitfun-space-2); } - .openbitfun-model-settings__connection-section .openbitfun-model-settings__connection-row { - padding-block: 0; - } - - .openbitfun-model-settings__connection-row > .openbitfun-config-page-row + .openbitfun-config-page-row { - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-component-config-page-divider); - } - .openbitfun-model-settings__endpoint-inputs { grid-template-columns: minmax(0, 1fr); } 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 9833c781c9..c31885e2c8 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 @@ -42,7 +42,6 @@ import { matchesImageModel } from '@/infrastructure/config/components/image-gene import ModelTagsField from '@/infrastructure/config/components/ModelTagsField'; import { configsNeedingAutoTest, - providerConnectionChanged, stableJson, } from '@/infrastructure/config/components/modelConnectionTestPlan'; import { ModelDiscoveryCoordinator } from '@/infrastructure/config/components/modelDiscoveryCoordinator'; @@ -410,6 +409,30 @@ function normalizeComparableString(value: string | undefined): string { return (value || '').trim(); } +// Normalize effective defaults only for comparison; retain the saved shape. +function comparableRequestSettings(config: Partial): ModelEditorRequestSettings { + return { + ...getModelEditorRequestSettings(config), + custom_headers: config.custom_headers || {}, + custom_headers_mode: config.custom_headers_mode || 'merge', + custom_request_body: normalizeComparableString(config.custom_request_body), + custom_request_body_mode: config.custom_request_body_mode || 'merge', + }; +} + +// Only editable provider values belong in the baseline. Request URLs are derived, +// and opening disclosures or reselecting a default must not create a draft. +function providerEditorSnapshot(config: Partial): string { + return stableJson({ + name: normalizeComparableString(config.name), + provider: config.provider || 'openai', + base_url: normalizeComparableString(config.base_url), + api_key: normalizeComparableString(config.api_key), + auth: config.auth || { type: 'api_key' }, + ...comparableRequestSettings(config), + }); +} + function modelDraftHasUnsavedChanges( draft: SelectedModelDraft, persistedModels: AIModelConfigType[], @@ -428,7 +451,7 @@ function modelDraftHasUnsavedChanges( draft.maxTokens !== persisted.max_tokens || stableJson(normalizeChannelRouting(draft.channelRouting)) !== stableJson(normalizeChannelRouting(persisted.channel_routing)) || stableJson(draft.reasoning) !== stableJson(canonicalReasoningConfig(persisted)) || - stableJson(draft.requestSettings) !== stableJson(getModelEditorRequestSettings(persisted)) || + stableJson(comparableRequestSettings(draft.requestSettings)) !== stableJson(comparableRequestSettings(persisted)) || (draft.userTags !== undefined && stableJson(draft.userTags) !== stableJson(getModelUserTags(persisted))) ); } @@ -505,6 +528,11 @@ const ModelSettingsPage: React.FC = () => { const [isEditing, setIsEditing] = useState(false); const [isEditorSaving, setIsEditorSaving] = useState(false); const [editingConfig, setEditingConfig] = useState | null>(null); + const newProviderInitialSnapshotRef = React.useRef(null); + const initializeNewProvider = useCallback((config: Partial) => { + newProviderInitialSnapshotRef.current = providerEditorSnapshot(config); + setEditingConfig(config); + }, []); const [editingTargetKey, setEditingTargetKey] = useState(null); const [draftCloseConfirmOpen, setDraftCloseConfirmOpen] = useState(false); const [draftConflictConfirmOpen, setDraftConflictConfirmOpen] = useState(false); @@ -528,6 +556,16 @@ const ModelSettingsPage: React.FC = () => { const [creationMode, setCreationMode] = useState<'selection' | 'form' | null>(null); const [providerQuery, setProviderQuery] = useState(''); const [showAllProviders, setShowAllProviders] = useState(false); + const providerSelectionRef = React.useRef(null); + const returnProviderFocusIdRef = React.useRef(null); + useEffect(() => { + if (creationMode !== 'selection' || !returnProviderFocusIdRef.current) return; + const providerId = returnProviderFocusIdRef.current; + const trigger = Array.from(providerSelectionRef.current?.querySelectorAll('button[data-provider-id]') || []) + .find(button => button.dataset.providerId === providerId); + trigger?.focus(); + returnProviderFocusIdRef.current = null; + }, [creationMode]); const [selectedProviderId, setSelectedProviderId] = useState(null); const [proxyConfig, setProxyConfig] = useState(initialProxy); @@ -1016,12 +1054,17 @@ const ModelSettingsPage: React.FC = () => { const persistedModels = editingConfig.id ? aiModels.filter(model => model.id === editingConfig.id) : aiModels.filter(model => editingProviderModelIds.has(model.id || '')); - if (persistedModels.length === 0) return true; + if (persistedModels.length === 0) { + // Missing persisted records remain dirty; only new editors have a baseline. + if (!editingTargetKey?.startsWith('new-provider:')) return true; + return selectedModelDrafts.length > 0 + || providerEditorSnapshot(editingConfig) !== newProviderInitialSnapshotRef.current; + } const persisted = persistedModels[0]; const comparableConfig = { ...persisted, ...editingConfig } as AIModelConfigType; - const providerFieldsChanged = providerConnectionChanged(persisted, comparableConfig) - || normalizeComparableString(editingConfig.name) !== normalizeComparableString(getProviderDisplayName(persisted)); + const providerFieldsChanged = providerEditorSnapshot(comparableConfig) + !== providerEditorSnapshot({ ...persisted, name: getProviderDisplayName(persisted) }); const draftFieldsChanged = selectedModelDrafts.some(draft => modelDraftHasUnsavedChanges(draft, aiModels)); const persistedIds = new Set(persistedModels.map(model => model.id).filter(Boolean)); const draftIds = new Set(selectedModelDrafts.map(draft => draft.configId).filter(Boolean)); @@ -1029,7 +1072,7 @@ const ModelSettingsPage: React.FC = () => { || Array.from(persistedIds).some(id => !draftIds.has(id)); return providerFieldsChanged || draftFieldsChanged || selectionChanged; - }, [aiModels, editingConfig, editingProviderModelIds, isAddingCustomModel, manualModelInput, selectedModelDrafts]); + }, [aiModels, editingConfig, editingProviderModelIds, editingTargetKey, isAddingCustomModel, manualModelInput, selectedModelDrafts]); const createDraftsFromConfigs = (configs: AIModelConfigType[]) => ( configs.map(config => createModelDraft(config.model_name, config, { @@ -1450,7 +1493,7 @@ const ModelSettingsPage: React.FC = () => { setShowApiKey(false); setSelectedProviderId(null); setEditingTargetKey(targetKey); - setEditingConfig({ + initializeNewProvider({ name: subscriptionConnectionName(account), provider: account.suggested_format, base_url: account.provider === 'opencode' ? 'https://opencode.ai/zen/v1' : account.suggested_base_url, @@ -1478,7 +1521,7 @@ const ModelSettingsPage: React.FC = () => { setCreationMode('form'); setIsEditing(true); }); - }, [requestEditorOpen, resetRemoteModelDiscovery, subscriptionConnectionName]); + }, [initializeNewProvider, requestEditorOpen, resetRemoteModelDiscovery, subscriptionConnectionName]); const loginCoordinatorRef = React.useRef(new SubscriptionLoginCoordinator()); const subscriptionLoginMountedRef = React.useRef(true); @@ -1877,7 +1920,7 @@ const ModelSettingsPage: React.FC = () => { // Dynamically get translated name const providerName = t(`providers.${template.id}.name`); - setEditingConfig({ + initializeNewProvider({ name: providerName, base_url: template.baseUrl, request_url: '', @@ -1907,10 +1950,10 @@ const ModelSettingsPage: React.FC = () => { setShowApiKey(false); setSelectedProviderId(null); setEditingTargetKey('new-provider:custom'); - setEditingConfig({ + initializeNewProvider({ name: '', - base_url: 'https://open.bigmodel.cn/api/paas/v4', - request_url: resolveRequestUrl('https://open.bigmodel.cn/api/paas/v4', 'openai'), + base_url: '', + request_url: '', api_key: '', model_name: '', provider: 'openai', @@ -2532,13 +2575,25 @@ const ModelSettingsPage: React.FC = () => { setIsEditing(false); }; + const discardEditingDraftAndClose = () => { + const returnToProviderSelection = editingTargetKey?.startsWith('new-provider:') + && !editingTargetKey.startsWith('new-provider:subscription:'); + closeEditingModal(); + if (returnToProviderSelection) { + returnProviderFocusIdRef.current = selectedProviderId || 'custom'; + setCreationMode('selection'); + setProviderQuery(providerQuery); + setShowAllProviders(showAllProviders); + } + }; + const requestCloseEditingModal = () => { if (editorSavingRef.current) return; if (editingModalHasUnsavedChanges) { setDraftCloseConfirmOpen(true); return; } - closeEditingModal(); + discardEditingDraftAndClose(); }; const continueEditingCurrentDraft = () => { @@ -2726,7 +2781,7 @@ const ModelSettingsPage: React.FC = () => { /> -
+
)} @@ -5530,7 +5589,8 @@ const ModelSettingsPage: React.FC = () => { open={draftCloseConfirmOpen} onOpenChange={(open) => { if (!open) setDraftCloseConfirmOpen(false); }} onConfirm={preserveEditingDraftAndClose} - onSecondary={closeEditingModal} + onSecondary={discardEditingDraftAndClose} + secondaryActionPlacement="start" title={t('draftClose.title')} message={t('draftClose.message')} confirmText={t('draftClose.keepAndClose')} diff --git a/src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts b/src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts index 8cb4a6e5e8..9c1812abe5 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts @@ -13,7 +13,7 @@ const styles = readFileSync( ); describe('ModelSettingsPage dialog presentation', () => { - it('uses the same Panel Dialog preset throughout model management workflows', () => { + it('uses a content-sized editor and panel dialogs for model browsing workflows', () => { const owners = [ '../../../app/scenes/settings/pages/ai/ModelSettingsPage.tsx', '../../../app/scenes/settings/pages/ai/ModelDiscoveryPicker.tsx', @@ -29,7 +29,10 @@ describe('ModelSettingsPage dialog presentation', () => { const visit = (node: ts.Node) => { if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.tagName.getText(tree) === 'Dialog') { const preset = node.attributes.properties.find(property => ts.isJsxAttribute(property) && property.name.getText(tree) === 'preset'); - expect(preset?.getText(tree), owner).toBe('preset="panel"'); + const editor = node.attributes.properties.some(property => ts.isJsxAttribute(property) + && property.name.getText(tree) === 'className' + && property.initializer?.getText(tree) === '"openbitfun-model-settings__editor-dialog"'); + expect(preset?.getText(tree), owner).toBe(editor ? 'preset="default"' : 'preset="panel"'); dialogs += 1; } ts.forEachChild(node, visit); @@ -51,14 +54,15 @@ describe('ModelSettingsPage dialog presentation', () => { expect(editorDialogStart).toBeGreaterThan(-1); expect(editingFormStart).toBeGreaterThan(-1); expect(editorDialog).toContain('className="openbitfun-model-settings__editor-dialog"'); - expect(editorDialog).toContain('preset="panel"'); + expect(editorDialog).toContain('preset="default"'); + expect(editorDialog).toContain('size="lg"'); expect(editorDialog).toContain(''); expect(editorDialog).toContain(''); expect(editorDialog).not.toContain('size="2xl"'); expect(editorDialog).toMatch( - /\{!reasoningPanelDraft && \(\s*/, + /\{!reasoningPanelDraft && \(\s*/, ); - expect(editorDialog).toContain('appearance="floating"'); + expect(editorDialog).not.toContain('appearance="floating"'); expect(editorDialog).toContain( '