Skip to content

feat(webui): restructure Settings into scoped sections with schema-typed inputs #1917

Closed

Description

@inureyes

Part of #1910.

Problem / Background

Measured 2026-09-17 against a release build of main 0ef0a1a4 with a model loaded and --settings --props --slots --metrics enabled: the Settings route is one scroll of 5,720 px at 1440x900 with 129 form inputs, 763 words and 11 h2 headings. A user looking for default_temperature has to scroll past appearance, a privacy paragraph, eight session-default fields, a model selector, the load profile editor with a JSON dump and a CLI dump, and the effective-context block before reaching the live fields, which are then rendered as raw text with float noise.

Current Behavior

SettingsScreen (webui/src/app.tsx:134-139) renders appearance controls (theme, material, language, high contrast, glass intensity range, reduce motion, reduce transparency via a private Toggle at app.tsx:141), then an info banner "Browser appearance only", then ServerSettings (webui/src/features/settings/server-settings.tsx:12-28), which stacks in one column: Privacy, GenerationSettings (generation-settings.tsx: seven text Fields from GENERATION_FIELDS plus a disabled "Reasoning / structured output" field whose value is a sentence), a "Selected model for settings" Select, ProfileSettings (profile-settings.tsx: scope select, three fields, save/discard/reset, a code dump of the profile JSON and of profileCli, an import/export <details> with a textarea), "Effective context" (three disabled fields, a code dump of props.geometry, ContextCheck from context-check.tsx), and LiveSettings (live-settings.tsx:49: every mutable field as a text Field, then a <details> with every read-only field).

LiveSettings shows every value through settingInput(spec, value) (webui/src/api/settings.ts:44), which is JSON.stringify for anything but strings. Floats therefore show f32 noise (0.800000011920929, 0.949999988079071, 0.05000000074505806) because the server stores f32 (src/server/config.rs:509-510) and serializes it with json!(live.default_temperature) (src/server/runtime_settings.rs:269); null appears as the literal string null; labels are raw keys (default_dry_multiplier, default_dry_sequence_breakers, lang_bias_config, chat_template_kwargs); enums such as diffusion_sampler (allowed: entropy-bound, confidence-threshold, runtime_settings.rs:431) are free-text; booleans are text; the hint concatenates spec.help, "Current: ...", "Type: ..." and "allowed: ...". The schema already carries what a typed control needs: SettingSpec has type (SettingKind, nine kinds), allowed, mutable, default, help, reason (webui/src/api/settings.ts:2-3, mirrored by KnobSpec in runtime_settings.rs:176-186).

Every section opens with a two- to four-sentence contract statement, for example the fingerprint and numeric-bounds paragraph at live-settings.tsx:49 and the CLI-precedence paragraph at profile-settings.tsx:17. Generation parameters exist in three places with precedence explained only in prose: GenerationSettings (session defaults in generation-preferences.ts), the Chat "Parameters for next turn" overrides (webui/src/features/chat/parameters.tsx:12-14), and the live default_* fields; the UI never shows the resolved value for the next request. All settings copy goes through a local words(en, ko) helper (server-settings.tsx:17, live-settings.tsx:16, generation-settings.tsx:12, profile-settings.tsx:13, context-check.tsx:20), which #1913 replaces with t() from webui/src/i18n/catalog.ts.

Proposed Solution

Split Settings into four sections, each a tab (the existing controlled Tabs adapter at webui/src/design-system/common-adapters.tsx:47) addressed by a route hash: #settings/appearance, #settings/requests, #settings/model, #settings/server. Extend routeFromHash (webui/src/app.tsx:20-23) so settings/<section> resolves to route = 'settings' with a section value, defaulting to appearance for a bare #settings or an unknown section; RouteId in webui/src/design-system/shell.tsx:8 is unchanged, the section is a second value passed to SettingsScreen. Tab changes write the hash with history.replaceState so Back leaves Settings rather than stepping through tabs. Each tab opens with PageHeader from #1914 and a one-line description; the long contract statements move to docs/webui/settings.md and are reachable from a help Tooltip (the retained local one in webui/src/design-system/primitives.tsx:132) or a "Learn more" link, not inline prose.

  • Appearance (browser): the existing appearance controls, moved out of app.tsx into webui/src/features/settings/appearance-settings.tsx; Toggle becomes a shared primitive so the live-settings boolean control can reuse it. The "Browser appearance only" banner becomes the tab's one-line description.
  • Requests (browser memory): the seven GENERATION_FIELDS as number inputs. Each field shows its effective next-request value and source through a new pure helper in generation-defaults.ts: resolveEffectiveParameter(name, override: number | undefined, sessionDefault: number | undefined, serverDefault: unknown): { value: number | null; source: 'override' | 'session' | 'server' | 'unknown' }. The server default is current["default_" + name] from the selected ready model's /settings response when a schema entry with that name exists, otherwise unknown (rendered as "server default, not readable"). parameters.tsx uses the same helper for its "Inherited" hint so Chat and Settings agree. Remove the disabled "Reasoning / structured output" sentence field; that statement goes to the docs.
  • Model (selected model): the model Select (keep testId="settings-model-selector"), then the next-load profile editor with its JSON and profileCli output behind a "Show as CLI flags" disclosure with a copy button, and import/export behind one disclosure. When the selected model is Ready and --settings is on, the live mutable settings render below it, grouped by prefix in this order: Sampling (default_* except default_dry_*), DRY (default_dry_*), Diffusion (diffusion_*), Template (chat_template_kwargs, lang_bias_config, and any other name containing template or bias), Other (the remainder). The fingerprint-conflict message stays, promoted to a banner at the top of the group. Labels come from catalog keys settings.live.<name> with the raw key as secondary text; a name without a catalog entry falls back to the raw key alone so schema growth never blanks a label.
  • Server (read-only): startup values (the !spec.mutable schema entries, no longer inside a <details>), the effective context block (nCtx, totalSlots, kvCacheMode as a definition list, geometry behind a disclosure), the ContextCheck tokenizer probe, and the disabled-endpoint banners.

Typed controls come from one new module, webui/src/features/settings/setting-control.tsx, exporting SettingControl({ spec, value, draft, onChange, error, disabled }) that maps SettingKind to a control: bool to Toggle; str with allowed to Select; str without allowed to a text Field; int to <input type="number" step="1">; float to <input type="number" step="any">; array, object, object_or_null to a <textarea> that validates JSON on blur; every _or_null kind adds a "Use null" toggle that, when on, disables the inner control and writes the literal string null into the draft. The draft stays Record<string, string> and parseSettingInput (api/settings.ts:32-43) stays the sole validator, so the apply, reject-retention, fingerprint and reset flows in live-settings.tsx:24-48 are unchanged. Add formatSettingValue(spec, value): string beside settingInput in api/settings.ts: for float it returns the shortest decimal that round-trips through Math.fround (String(Number(Math.fround(v).toPrecision(7))) is acceptable if it satisfies the test below), for int it is String(v), for null it is the empty string (the null toggle carries the state), and for everything else it defers to settingInput. Display uses formatSettingValue; what is sent is exactly what the user typed, parsed by parseSettingInput. An alternative, rounding on the server, was rejected because the endpoint is shared with non-WebUI clients and #1910 keeps /ui-api/v1 and /settings unchanged.

Scope

In scope: webui/src/app.tsx (route parsing, SettingsScreen becomes a tab host), webui/src/features/settings/ (new appearance-settings.tsx, setting-control.tsx, settings-screen.tsx; rewrite server-settings.tsx, live-settings.tsx, generation-settings.tsx, profile-settings.tsx; generation-defaults.ts gains resolveEffectiveParameter), webui/src/api/settings.ts (formatSettingValue), webui/src/features/chat/parameters.tsx (hint only), webui/src/design-system/primitives.tsx (Toggle), webui/src/i18n/catalog.ts and tests/fixtures/webui/strings.json (new keys), docs/webui/settings.md (receives the moved contract prose), the four settings test files and webui/tests/settings.spec.ts.

Out of scope: any server change (no numeric min/max in the schema, no rounding, no new endpoint); the Chat parameter panel layout (Chat child of #1910); the toolbar and shell (shell child); migrating non-settings screens off words() (i18n child).

Implementation Notes

  • Reuse: Tabs (common-adapters.tsx:47), Select (common-select.tsx:13), Field, ErrorBanner, Dialog, Tooltip from primitives.tsx; PageHeader, PageLayout, Badge, BaseCard from @lablup/ui-common 0.1.0-alpha.19 as wrapped by feat(webui): app shell shows loaded models, uses PageLayout/PageHeader, and completes the global shortcuts #1914 and refactor(webui): adopt every shared @lablup/ui-common component #1902 (do not import ui-common directly from a feature file; add an adapter in common-adapters.tsx if one is missing). parseSettingInput, validateGenerationDefaults, validateLoadProfile, profileCli, useLoadProfile, useGenerationDefaults, contextBudget are unchanged.
  • Constraints: docs/webui/ux-contract.md gates hold (keyboard-first, visible focus, 200 percent zoom, 390 px with no horizontal scroll, WCAG 2.2 AA, IME-safe inputs); the Tabs adapter already stops composing keydown. No window.confirm; the existing Dialog reset confirmations stay. The schema may hold up to 256 entries (api/settings.ts:11); grouping and labelling must not assume a fixed list.
  • Edge cases: --settings off shows the existing "Live settings disabled by operator" banner in the Model tab and the Server tab shows only /props data; single-model mode keeps the "restart required" banner in the Model tab; no ready model selected shows the "No ready model selected" banner and the Requests tab resolves every server default as unknown; a float whose f32 round trip is not shorter (for example 0.1234567) displays the 7-significant-digit form; a _or_null field whose current value is null opens with the null toggle on and the inner control empty; a str enum whose current value is not in allowed renders the Select with an extra disabled option showing the raw value so nothing is hidden; an unknown hash section falls back to appearance.
  • Error handling: per-field errors from parseSettingInput and from rejected[].reason stay attached to the field; a JSON textarea parse failure sets the field error "Enter valid JSON" and blocks Apply exactly as invalid text does today; refresh and apply failures keep the existing messages.

Acceptance Criteria

  • No single Settings tab exceeds two viewport heights (1,800 px) at 1440x900 with a model loaded and --settings --props --slots --metrics on; the before value (5,720 px, 129 inputs, 763 words) and the after value per tab are recorded in the closing comment.
  • Every live setting renders a control matching its schema kind and enum: diffusion_sampler is a Select with exactly its allowed values, every bool is a Toggle, every float shows no more than 7 significant digits, and the literal string null appears nowhere in the DOM.
  • Each of the seven request fields shows a resolved value and its source, and the Chat "Parameters for next turn" hint shows the same value for the same state.
  • default_temperature is reachable within two clicks of opening Settings (Model tab, then the field) with a ready model selected.
  • #settings/server opens the Server tab directly; Back from any tab leaves Settings.
  • The fingerprint-conflict flow, partial-apply reporting, "saving is not loading" profile semantics, disabled-endpoint banners and the context probe wording ("raw tokens" as a lower bound) behave as before; live-settings.test.tsx, profiles-state.test.tsx, server-settings-selection.test.tsx, settings.test.ts and webui/tests/settings.spec.ts pass with only selector updates.
  • New unit tests: formatSettingValue maps 0.800000011920929 to 0.8 and 0.05000000074505806 to 0.05 and leaves 0.1234567 at 7 digits; SettingControl renders the right element per SettingKind and the null toggle writes null to the draft; resolveEffectiveParameter returns override over session over server over unknown; the route parser maps #settings/model and #settings/bogus correctly.
  • Integrated into the real app flow: the four tabs are what #settings renders in the bundled binary, not a parallel screen.
  • Verified against the bundled binary with --settings enabled and a loaded model, at 1440x900 and 390x844, with screenshots attached.

Verification

pnpm --dir webui run lint
pnpm --dir webui run typecheck
pnpm --dir webui run unit
pnpm --dir webui run browser
pnpm --dir webui run verify-generated
cargo build --release --features metal,accelerate
./target/release/mlxcel-server --webui --models-dir models/mlx --api-key-file <file> --no-models-autoload --settings --props --metrics --slots

Manual: load mlx-community/Qwen3-4B-4bit from Models, open #settings/model, confirm default_temperature shows 0.8 in a number input and diffusion_sampler is a select, change default_temperature to 0.7, Apply, confirm the value reads back as 0.7; open #settings/requests, set temperature 0.5, confirm the effective row reads 0.5 (session default) and the Chat parameters hint agrees; record document.documentElement.scrollHeight, input count and word count per tab.

Technical Considerations

Depends on #1914 (PageHeader/PageLayout wrappers and route plumbing), #1913 (settings copy on t() keys; if it has not merged, add the new keys to the catalog and leave the words() sites it owns untouched), and #1902. Related: docs/webui/settings.md scope table, docs/webui/ui-common.md export matrix, #1911 for the Activity counters.

Activity

  1. added a commit that references this issue on Sep 19, 2026
    7cc49fc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions