Skip to content

feat(webui): show loaded models and compose routes on the app shell - #1927

Merged
inureyes merged 16 commits into
mainfrom
feature/issue-1914-app-shell
Sep 19, 2026
Merged

inureyes merged 16 commits into
mainfrom
feature/issue-1914-app-shell

Conversation

@inureyes

@inureyes inureyes commented Sep 19, 2026

Copy link
Copy Markdown
Member

Part of #1910. Closes #1914.

The toolbar showed the browser's selected model, so a fresh tab said "No model selected" while a model was Ready on the server. The shell now shows what the server holds. The #1915 to #1918 redesigns build on it.

What changes

  • Toolbar: a loaded count, up to three chips with a lifecycle badge, and a "+n" chip that opens the palette.
    • It reads "Loaded models unknown" until an authenticated catalog arrives.
    • A chip opens the model's inspector on Models and never loads it.
    • Below 960 px the chips scroll in one row.
  • Footer: {mode} · {status} · v{version}. The instance id and event sequence move into a collapsed disclosure.
  • One PageHeader per route: Models, Chat and Activity move their actions and their stale or action errors into the shared header. The eyebrow keys, gallery.issue and the now-unused model.selected.none are deleted.
  • Shortcuts: globalShortcuts feeds the help dialog (six entries).
    • Cmd/Ctrl+N opens Chat with a new conversation. The composer also handles Cmd/Ctrl+N and Cmd/Ctrl+Enter.
    • Neither fires during IME composition, and neither repeats while a key is held.
    • On macOS the composer's new-conversation key is Cmd+N only, so Ctrl+N still moves the caret.
  • Palette: it lists the loaded models on an empty query and matches catalog models by name or id (at most 20). gallery is no longer listed.

Decisions

  • Focus fallback: it stays on the PageHeader h1, as refactor(webui): adopt every shared @lablup/ui-common component #1902 shipped, not on the PageLayout wrapper as the issue planned.
    • A named heading is a better landing target.
    • The adapter now always marks the h1, so every route has exactly one fallback.
    • modal-focus.test.tsx is unchanged. A new main.test.tsx case checks that focus lands on that h1 when a dialog closes after its trigger is gone.
  • Layout floor: the layout now holds at 195 CSS px (a 390 px window at 200 percent zoom). Before, body { min-width: 320px } overflowed every route there by 125 px.
    • Removed that rule and switched single-column tracks to minmax(0, 1fr).
    • Added a max-width: 319px toolbar block.
    • Long headings now wrap, which matters with CI's wider DejaVu fonts.
    • Layouts at 320 px and wider are unchanged.
    • tests/shell.spec.ts pins 0 px page overflow at 195 and 320 px, in en and ko, on every route.
  • Screenshot baselines: chore: stop committing binary assets and guard against new ones #1904 removed the committed baselines, so the issue's recapture and SHA-256 table steps do not apply.

Validation

  • Static checks: typecheck, lint, check:theme-selectors, bundle verify, binary-asset and contract checks pass.
  • Unit: 538 Vitest and 35 node:test pass.
  • Playwright Chromium (per spec, on macOS and in an Ubuntu 24.04 container with CI's fonts): shell 14, browser 29, ui-common-shell 12, ui-common-components 14, models 8, chat 2, activity 3, settings 3, theme 23, engines 3, performance 4.
  • browser:all in the container: passes. One WebKit case was flaky under parallel load and passed 12 of 12 when repeated alone.

Live acceptance (bundled binary)

I ran Playwright Chromium against a server built from b9465f91 (test-fast, metal and accelerate, embedded bundle), with qwen3-0.6b-4bit Ready and 224 catalog entries. Each check used a fresh browser context.

# Check Result
1 The toolbar names the loaded model before any selection Pass. Before login it reads "Loaded models unknown". After login it reads "1 loaded" with a qwen3-0.6b-4bit chip and a Ready badge, and no model is selected. The footer reads router_pool · ready · v0.7.0 with its details collapsed.
2 The chip opens the inspector Pass. From Settings, the chip goes to #models and the inspector shows the model. No non-GET request was sent.
3 Cmd+N from Settings Pass. Meta+N goes to #chat with an empty "New conversation". The palette's New conversation also works. Stock browser windows keep Cmd+N for themselves, so the palette entry covers that case.
4 IME and Cmd+Enter Pass (emulated). Headless Chromium cannot drive a real IME, so I used composition events. Nothing sent during compositionstart, with isComposing: true, or with keyCode 229. After composition, Cmd+Enter sent one short turn. On a macOS user agent, Ctrl+N in the composer kept the draft and moved the caret. On the Windows user agent, it started a conversation.
5 ? lists the shortcuts Pass. It lists six entries.
6 No horizontal scroll Pass. At 390 px, scrollWidth equals innerWidth on Models, Chat, Activity and Settings, and the drawer closes with Escape back to the menu button. At 195 CSS px (emulating 200 percent zoom), overflow is 0 on all four routes and the drawer's close button stays inside the panel.

Note on check 4: my 16-token request cap did not match the ?autoload=false chat URL. The single turn used the UI defaults, and the server's runtime counters report 1 completed request with 4 completion tokens.

The WebUI wrote the raw preference into data-theme, so the default `system` matched neither the light nor the dark selector, and the --token-* contract was configured by hand on :root, where no theme file could take effect.

data-theme now always holds a <family>-<scheme> id (mlxcel-light, mlxcel-dark, glass-light, glass-dark) resolved by theme.ts, with the stored family and scheme kept apart and the pre-change flat `theme` field migrated. main.tsx imports one theme entry after ui-common base.css; the baseline values moved into mlxcel theme blocks unchanged, and a glass family reads the existing intensity, transparency, contrast and motion switches. A classic script in index.html applies the id before first paint, since the CSP forbids inline scripts. check-theme-selectors fails on any theme selector no shipped id matches and runs in the webui-bundle job. The glass tokens derive from lablup/backend.ai-go, recorded in NOTICE.

The .material-glass rule now writes -webkit-backdrop-filter first: the minifier kept only the prefixed form, so the chrome blur never rendered in Chromium or Firefox. models.css read two tokens no theme defines.

Validated with typecheck, lint, the new gate, unit (32 node, 401 Vitest), the Chromium browser suite (59), the three-engine smoke, and build_bundle.py --verify.

Refs #1903
The glass theme binds a highlighted gradient fill to --token-buttonPrimaryBg and --token-buttonDangerBg, but the rendered primary and danger buttons never showed it. components.css hard-coded #245bd8 and #c6352b on .ds-button-primary and .ds-button-danger with the same specificity as ui-common's .button--primary and later in the bundle, so both glass schemes painted the mlxcel fill at rest and switched to the glass hue only on hover.

The two rules now read the theme tokens. The mlxcel tokens resolve to the same two colors, so the baseline does not move.

theme.spec.ts now checks the computed fill of the rendered buttons rather than only the tokens: the mlxcel baseline stays flat, both glass schemes paint the gradient over their own solid, and every opaque case paints no gradient. Put the two hex values back and the new glass case fails, because it receives background-image none. Typecheck, lint, the theme-selector gate, unit (32 node:test, 401 Vitest), the Chromium suite (60) and build_bundle.py --verify pass. The committed bundle is regenerated.

Refs #1903
The classic-script checks in build_bundle.py and theme-bootstrap.test.ts used `\b(type="module"|async|defer)\b`, whose trailing word boundary after a quote never matches, so a `type="module"` bootstrap passed, and the ordering check then found the bootstrap's own module attribute. Both now reject any type, async or defer attribute and locate the app module by its src. Feeding build_bundle.py a module, deferred, late, missing or inline variant of index.html rejects each.

The bootstrap parity cases now cover every family and scheme theme.ts ships, so a family added there but not to theme-bootstrap.js fails the unit gate (checked by adding one). The glass danger button gets its own red-tinted shadow instead of the primary blue, and glass.css cites the upstream files by URL.

Refs #1903
From the security review. The gate compared a resolved argv path with import.meta.url, which Node resolves through symlinks, so running it through a symlinked path exited 0 without checking anything; it now compares real paths. The import and attribute-selector patterns backtracked cubically and quadratically on long whitespace runs and are now linear, attribute names match case-insensitively as they do in HTML, and .jsx, .cjs, .mts and .cts files are scanned. build_bundle.py and the unit test also reject a nomodule or duplicated bootstrap tag. Each new spec case fails against the old code.

Glass no longer gives ui-common secondary buttons a backdrop blur. Those buttons sit on transcript, table and form surfaces, which the epic keeps stable, and in WebKit each one became its own blur layer repainted during streaming. They keep the tinted translucent fill.

Refs #1903
data-glass-intensity has one discrete CSS rule per integer 0-100 in glass-intensity.css, so a value outside that range or shape (negative, over 100, a fraction, a non-finite number, a non-numeric string) previously reached loadAppearance and applyAppearance untested; an unclamped write there would silently drop the glass blur and saturation with no visible error.

Adds two preferences.test.ts cases covering clampIntensity's boundary behavior through both entry points, and updates the Vitest count in docs/webui/design-system.md's Verification section from 425 to 427. Regenerates src/webui/assets/mlxcel-webui-manifest.json (source_digest_sha256 only; asset bytes are unchanged) since the test file lives under webui/src.

Validation: pnpm --dir webui run typecheck/lint/check:theme-selectors/unit (35 node:test, 427 Vitest across 40 files), pnpm exec playwright test against the full theme/browser/activity/chat/models/settings suite (60 passed), python3 scripts/webui/build_bundle.py --verify, check_webui_contract.py --self-test, check_binary_assets.py and check_cross_repo_refs.py.
The rebase onto main picked up #1922, which adds Vitest files and Activity and Settings browser cases, so the Verification section now reads 448 Vitest tests across 43 files and 62 Chromium tests (63 with font diagnostics).

Refs #1903
Brings in #1902's ui-common component adoption. components.css merged cleanly with both sides intact: the .material-glass rule keeps -webkit-backdrop-filter ahead of backdrop-filter, .ds-button-primary and .ds-button-danger keep reading --token-buttonPrimaryBg and --token-buttonDangerBg, and #1902's :where(:not(:disabled)) hover rules are unchanged. app.tsx takes #1902's PageHeader Settings screen with this branch's Theme and Color scheme selects and the system-scheme listener. The design-system and ui-common docs keep both texts, with the test counts recomputed. The bundle was regenerated, not hand-merged.

theme.spec.ts (including the glass button fill case) and ui-common-components.spec.ts (including both button hover cases) pass in the full Chromium suite (88), with unit (35 node:test, 470 Vitest), the theme-selector gate and build_bundle.py --verify.

Refs #1903
…eme-system

# Conflicts:
#	src/webui/assets/assets/code-highlight-BZ-vBiaX.js
#	src/webui/assets/assets/code-highlight-CzWARrNf.js
#	src/webui/assets/assets/code-highlight-DVOlMPN7.js
#	src/webui/assets/assets/history-BTvgVv_A.js
#	src/webui/assets/assets/history-Bh2B8HiI.js
#	src/webui/assets/assets/history-DTnpX_xH.js
#	src/webui/assets/assets/index-Bfja5tJs.js
#	src/webui/assets/assets/index-Bm7r1mqL.js
#	src/webui/assets/assets/index-Cw4PvI3b.js
#	src/webui/assets/index.html
#	src/webui/assets/mlxcel-webui-manifest.json
The toolbar printed the browser's selected model, so a fresh tab said "No model selected" while the server held a loaded model. It now shows the server's loaded models as up to three chips with a lifecycle badge plus a "+n" chip that opens the command palette; a chip selects the model and opens its inspector, never loads. The sidebar footer keeps mode, status and version and moves the instance id and event sequence into a collapsed disclosure.

Every route renders one PageHeader with its actions and its stale or action error in the header. The adapter always makes the h1 the dialog focus fallback, and the hand-rolled heading markup, its CSS and the eyebrow strings are gone.

globalShortcuts in shell.tsx feeds the help dialog. Cmd/Ctrl+N opens Chat with a one-shot new-conversation request; the composer also handles Cmd/Ctrl+Enter and Cmd/Ctrl+N outside IME composition. The palette moves to command-palette.tsx, drops gallery and finds catalog models by name or id.

The layout holds at 195 CSS px (a 390 px window at 200 percent zoom): body loses min-width, single-column tracks use minmax(0, 1fr), and a narrow toolbar block stacks its rows. tests/shell.spec.ts pins 195 and 320 px in both locales.

Typecheck, lint, unit (512 Vitest, 35 node:test), Playwright Chromium per spec, bundle verify, binary-asset and contract checks pass.

Refs #1914
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:high High priority area:architecture Architecture and code structure changes status:review Under review labels Sep 19, 2026
On a desktop toolbar short of room the loaded-model chips named nothing. At the 1024 px reference viewport with three long-named models plus "+1", each chip was about 100 px wide; the badge kept its full width and the name collapsed to 0 to 10 px, while the badge label itself was clipped to "Read". Flex shrinking also split the room by natural width, so the shortest name was starved first.

The loaded region is now a grid whose chip columns start at zero and grow in equal shares up to their natural width, and each chip gives up space in stages: the name truncates down to 5rem, then the badge narrows to a pill holding only its state dot, then the name truncates further. The clipped label stays in the accessible name and the full name in the chip title. Compact widths keep the wrapping flex row, and roomy toolbars render as before.

A shell.spec.ts case pins it at 1024 px: every chip keeps at least 40 px of name, the region stays clear of the toolbar actions, and the accessible name still carries the state. It fails on the previous CSS (names measured 0, 10 and 7 px). typecheck, lint, unit, check:theme-selectors, the bundle, binary-asset and contract checks pass, as do the eleven Chromium specs this PR runs, one per command.

Refs #1914
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Server-loaded models in the toolbar, footer disclosure, one PageHeader per route, completed shortcuts and palette, 195 CSS px floor.

Findings Addressed

  • At 1024 px with three long-named models plus "+1", chips showed no name (0, 10 and 7 px) and a clipped "Read" badge. Chips now share the row equally and give up room name first (to 5rem), then badge (to its dot). A new shell.spec.ts case pins it and fails on the old CSS. Fixed in 4316635 (HIGH)

Remaining Items

  • Signed out, and after login until the first catalog snapshot, the toolbar says "No model loaded" although the browser does not know yet. Suggest an explicit unknown state while catalogSequence === null (MEDIUM), left for a decision because it needs a string and changes asserted behavior
  • openModel calls selectModel for the already-selected model, which cancels observation, forces a refetch and clears runtimeHistory; fix(webui): open the model inspector from anywhere in a library row #1926 adds the same-id guard to the row button (MEDIUM)
  • At 960 px and below, three long-named chips wrap one per row and the sticky toolbar grows to about 160 to 270 px, as the brief specified (MEDIUM)
  • connectionFooterDetails repeats the snapshotSequence fallback chain (LOW)

Verification

  • All stated requirements implemented
  • No placeholder/mock code remaining
  • Integrated into project code flow
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass: typecheck, lint, unit (512 Vitest plus 35 node:test), theme selectors, bundle, binary-asset and contract checks, and all eleven Chromium specs (shell now 12 cases)

Four follow-ups from the PR #1927 review.

The toolbar said "No model loaded" while signed out and until the first catalog page arrived, which asserts a server state the UI has not read. `AppShell.loadedModels` is now nullable: App passes null unless an authenticated session holds a catalog snapshot (signed out, before the first page, after a server-restart reset), and the region then shows "Loaded models unknown" (`toolbar.loaded.unknown`, en and ko).

Opening the already selected model from a chip or the palette dispatched `selectModel` again, which cancels observation, refetches and clears the runtime history. `modelSelectionFor` in provider-surfaces computes the target, and App skips the dispatch when it equals the current selection.

Below 960 px the chip row wrapped, so the sticky toolbar grew a row per long-named chip: with four models it was 269 px tall at 400 px wide and 162 px at 700 px. The row now scrolls sideways inside itself with chips capped at min(14rem, 80%), which holds the toolbar at its one-chip height (124 px and 82 px). Chromium does not scroll an element that is already partly visible when it takes focus, so the region scrolls a keyboard-focused chip fully into view.

`help.body` now says that Esc still closes an open dialog.

Validation: typecheck, lint and theme selectors; 35 node:test and 517 Vitest tests; Playwright shell (14), browser (29), ui-common-shell (12), ui-common-components (14), models, chat, activity, settings and theme specs; bundle build and verify; binary-asset and contract checks. Each new test fails with its fix reverted.
@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review

No CRITICAL or HIGH findings, so no commits were pushed. Reviewed at e2fe6a4b.

MEDIUM

  • webui/src/features/chat/chat.tsx:176: the composer takes Ctrl+N as new conversation on every platform. On macOS, Ctrl+N is the text-system "move down one line" binding in a textarea (Playwright's macOS editing table maps Control+KeyN to moveDown:). The handler calls preventDefault and then create(), which clears the draft and attached images, so a Mac user moving down a multi-line prompt loses the unsent text. Suggested fix: accept only Cmd+N in the composer on macOS. The global handler does not have this problem because it skips edit fields.

LOW

  • webui/src/command-palette.tsx:52-58: the query resets only when the palette opens, so a query typed before closing keeps paletteModelMatches running on every snapshot render while the palette is closed, and up to 20 model buttons stay in the closed dialog. A probe confirmed this. The cost is small (19 to 70 µs per call at 212 entries), but resetting on close or skipping the search while closed removes it.
  • webui/src/design-system/shell.tsx:108 and chat.tsx:176: there is no event.repeat guard. Wherever Cmd/Ctrl+N reaches the page, holding it creates one empty conversation per auto-repeat, up to the 50-conversation cap.

Checked, no finding

  • API names, ids and the instance id render only as JSX text or title. There is no innerHTML, no inline style and no URL built from API data.
  • Chips, "+n" and the palette only call selectModel and navigate, and the specs assert zero non-GET calls.
  • The new-conversation request is raised only when Chat can mount, is consumed on mount, and is cleared on sign-out. Logout resets the snapshot, which also hides the footer details.
  • A Chromium probe clicked non-focusable content in an open modal. Focus moved to the <dialog>, so neither Cmd+N nor ? fired.
  • loadedModels takes 7 µs at 212 entries, and revealFocusedChip runs only on focus.

Gates: typecheck, lint, check:theme-selectors and unit (528 Vitest, 35 node:test) pass. The bundle verify and binary-asset checks pass. In Playwright, shell (14), browser (29) and chat (2) pass.

Security/performance follow-up for PR #1927 (issue #1914). The composer's new-conversation shortcut matched metaKey or ctrlKey on every platform, so macOS Ctrl+N, the native Cocoa/Emacs "move down a line" binding inside a textarea, cleared the draft and attached images instead of moving the caret.

Add webui/src/design-system/keyboard.ts with isApplePlatform() and isPrimaryModifier(), and gate the composer's new-conversation shortcut on the platform's own modifier (Cmd on Apple, Ctrl elsewhere), leaving Ctrl+N untouched on Apple platforms. The Enter/Cmd/Ctrl+Enter send path is left unconditional since it has no platform-specific native binding to protect.

Also stop a held key from flooding conversations: both the global shell shortcut and the composer now ignore event.repeat for the new-conversation combo (the global handler still calls preventDefault on the repeat so the browser does not act on it), and the command palette clears its query on close as well as on open, so a search typed before closing no longer keeps matching in the background and holding stale model buttons in the closed dialog.

Regenerated src/webui/assets/ from the updated source. Updated docs/webui/chat.md, which claimed Ctrl+N worked in the composer the same way the global shortcut does, and refreshed the Vitest count in docs/webui/design-system.md.
CI runs the browser suite with DejaVu Sans, which is wider than the macOS system font, and three shell.spec.ts cases failed there although they passed locally. At 195 CSS px the English "Authentication" heading set the login form's min-content 32 px past the viewport, and the drawer title "Primary navigation" pushed the close button out of the 163 px drawer, where overflow: hidden clipped it. In a tight 1024 px toolbar the chip names shrank to 37 px, below the test's 40 px.

The login heading and paragraph, and the drawer title, now wrap anywhere, and the drawer close button no longer shrinks. Chips use 8 px leading padding and a 4 px gap between name and badge, which gives each name 8 px more: 45 px with DejaVu and 54 px with the macOS font.

Reproduced and verified in an Ubuntu 24.04 container with the CI font packages (fonts-dejavu-core/extra 2.37-8, fonts-wqy-zenhei 0.9.45-8) and Playwright Chromium: shell 14, browser 29, ui-common-shell 12, ui-common-components 14, models 8, chat 2, activity 3, settings 3, theme 23 passed. The same specs pass on macOS. The bundle is regenerated and verified.

Refs #1914
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 19, 2026
@inureyes
inureyes merged commit 835a552 into main Sep 19, 2026
18 checks passed
@inureyes
inureyes deleted the feature/issue-1914-app-shell branch September 19, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:architecture Architecture and code structure changes priority:high High priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(webui): app shell shows loaded models, uses PageLayout/PageHeader, and completes the global shortcuts

1 participant