Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 11 additions & 2 deletions design-system/packages/ui/src/components/FieldGroup/FieldGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -18,6 +19,8 @@ export interface FormSectionProps
extends Omit<HTMLAttributes<HTMLElement>, "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;
Expand All @@ -41,6 +44,7 @@ export const FormSection = forwardRef<HTMLElement, FormSectionProps>(
children,
className,
description,
descriptionLayout = "stacked",
headingAs = "h2",
leading,
title,
Expand All @@ -51,6 +55,7 @@ export const FormSection = forwardRef<HTMLElement, FormSectionProps>(
{...props}
className={classNames(styles.section, className)}
data-openbitfun-component="form-section"
data-description-layout={descriptionLayout}
ref={ref}
>
{(title !== undefined && title !== null)
Expand All @@ -73,11 +78,15 @@ export const FormSection = forwardRef<HTMLElement, FormSectionProps>(
{ className: styles.title, "data-openbitfun-part": "title" },
title,
)}
{description !== undefined && description !== null && (
{description !== undefined && description !== null && (descriptionLayout === "inline" ? (
<OverflowText as="div" className={styles.description} data-openbitfun-part="description">
{description}
</OverflowText>
) : (
<div className={styles.description} data-openbitfun-part="description">
{description}
</div>
)}
))}
</div>
) : null}
</div>
Expand Down
20 changes: 20 additions & 0 deletions design-system/packages/ui/tests/field-group.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ test("form grouping composes semantic sections, grouped surfaces, and independen
);

assert.match(markup, /<section[^>]+data-openbitfun-component="form-section"/);
assert.match(markup, /data-description-layout="stacked"/);
assert.match(markup, /<h3[^>]+data-openbitfun-part="title"[^>]*>Provider<\/h3>/);
assert.match(markup, /data-openbitfun-part="heading-region"/);
assert.match(markup, /data-openbitfun-part="leading"[^>]*><svg aria-hidden="true"><\/svg>/);
Expand All @@ -56,6 +57,25 @@ test("form grouping composes semantic sections, grouped surfaces, and independen
assert.match(markup, /data-control-width="fill"/);
});

test("inline summaries preserve separate headings, descriptions, and actions", () => {
const markup = renderToStaticMarkup(
createElement(FormSection, {
actions: createElement("button", { type: "button", "aria-expanded": false }, "Expand"),
description: "Connection settings",
descriptionLayout: "inline",
headingAs: "h3",
title: "Provider",
}),
);

assert.match(markup, /data-description-layout="inline"/);
assert.match(markup, /<h3[^>]+data-openbitfun-part="title"[^>]*>Provider<\/h3>/);
assert.match(markup, /data-openbitfun-part="description"/);
assert.match(markup, /Connection settings/);
assert.match(markup, /data-openbitfun-part="actions"[^>]*><button[^>]*aria-expanded="false"/);
assert.doesNotMatch(markup, /<h3[^>]*>[\s\S]*Connection settings[\s\S]*<\/h3>/);
});

test("form grouping styles consume only shared public composition tokens", async () => {
const styles = await readFile(new URL("../dist/styles.css", import.meta.url), "utf8");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,25 @@ const ExecutionSettingsPage: React.FC = () => {
) : (
<>

<ConfigPageSection
title={t('deferredToolLoading.sectionTitle')}
description={t('deferredToolLoading.sectionDescription')}
>
<ConfigPageRow
label={t('common.enable')}
description={!enableDeferredToolLoading ? t('deferredToolLoading.warning') : undefined}
align="center"
>
<div className="openbitfun-runtime-settings__row-control" data-openbitfun-component="runtime-settings" data-openbitfun-part="control">
<Switch
checked={enableDeferredToolLoading}
onChange={(event) => handleDeferredToolLoadingChange(event.target.checked)}
disabled={deferredToolLoadingConfigSaving}
/>
</div>
</ConfigPageRow>
</ConfigPageSection>

{/* ── Tool execution behavior ────────────────────────────── */}
<ConfigPageSection
title={t('toolExecution.sectionTitle')}
Expand Down Expand Up @@ -453,25 +472,6 @@ const ExecutionSettingsPage: React.FC = () => {
</ConfigPageRow>
</ConfigPageSection>

<ConfigPageSection
title={t('deferredToolLoading.sectionTitle')}
description={t('deferredToolLoading.sectionDescription')}
>
<ConfigPageRow
label={t('common.enable')}
description={!enableDeferredToolLoading ? t('deferredToolLoading.warning') : undefined}
align="center"
>
<div className="openbitfun-runtime-settings__row-control" data-openbitfun-component="runtime-settings" data-openbitfun-part="control">
<Switch
checked={enableDeferredToolLoading}
onChange={(event) => handleDeferredToolLoadingChange(event.target.checked)}
disabled={deferredToolLoadingConfigSaving}
/>
</div>
</ConfigPageRow>
</ConfigPageSection>

<ToolJsonRepairSection />
<ReviewCapacitySection />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ const MemorySettingsSection: React.FC = () => {
<ConfigPageSection
title={t('sections.advanced.title')}
description={t('sections.advanced.description')}
descriptionLayout={advancedOpen ? 'stacked' : 'inline'}
extra={(
<Tooltip
content={t(advancedOpen ? 'actions.collapseAdvanced' : 'actions.expandAdvanced')}
Expand All @@ -463,7 +464,7 @@ const MemorySettingsSection: React.FC = () => {
onClick={() => setAdvancedOpen((open) => !open)}
aria-label={t(advancedOpen ? 'actions.collapseAdvanced' : 'actions.expandAdvanced')}
aria-expanded={advancedOpen}
icon={advancedOpen ? <Icon name="chevron-up" size="lg" /> : <Icon name="chevron-down" size="lg" />}
icon={<Icon name={advancedOpen ? 'chevron-down' : 'chevron-right'} size="lg" />}
/>
</Tooltip>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>((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<string, ReturnType<typeof deferred>>;
const copy = (key: string) => String(i18nService.getI18nInstance().t(key, { ns: 'settings/models' }));
const button = (key: string) => [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(element => element.textContent === copy(key))!;
const selectors = () => [...container.querySelectorAll<HTMLButtonElement>(
'.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(
<T,>(path: string) => reads.get(path)!.promise as Promise<T | undefined>,
);
vi.spyOn(configManager, 'getConfig').mockImplementation(
<T,>(path?: string) => reads.get(path!)!.promise as Promise<T>,
);
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(<ModelSettingsPage />));

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(<ModelSettingsPage />));
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<HTMLInputElement>('input')]
.find(element => element.value === 'http://localhost:7890');
expect(proxy?.disabled).toBe(false);
expect([...container.querySelectorAll<HTMLInputElement>('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);
});
});
Loading
Loading