Repository navigation
feat(webui): rebuild Chat as a conversation view - #1931
Conversation
Chat needs to load a model with exactly the request the Models library sends, so the request body and the capacity-conflict handling move out of the library's load() into features/models/load-action.ts. submitLoad re-checks canLoad and the eviction candidate, sends one load model action (profile only when it sets a field, eviction target at its revision, fresh idempotency key), and on a conflict envelope calls onCapacity once and rethrows. loadErrorMessage maps a refusal to the stale copy and delegates the rest to the Models errorMessage, so both screens show the same load errors. The library keeps its guards and calls submitLoad at its single load() call site; screen.test.tsx and load-profile-integration.test.tsx pass unchanged. load-action.test.ts pins the body shape with and without a profile and eviction target, the refusals, and that onCapacity fires only on conflict. Refs #1915
Chat read as a form: one flat article per turn, a 60vh transcript inside a scrolling page, a select for conversations, and a picker over the whole catalog that sent unloaded models to Models. The route now fills the content pane: a conversation list (a pane from 961 px, a Drawer below) beside the conversation, whose message list is the only scroller above a pinned composer. Small or zoomed windows fall back to page flow. - chat.tsx keeps state and the request flow; conversation-list, message-list (replaces transcript.tsx), composer, model-picker and settings-drawer render it. Retry re-sends a failed last turn through the same send() guards without touching the draft. - Role-distinct messages with timestamps, an elapsed-seconds label while streaming and Done/Stopped/Did not finish/Failed labels; per-message toolbars. - The picker lists Ready chat models first and never loads; an inline Load confirms and sends the Models request through load-action.ts. - One settings drawer holds conversation settings, next-turn parameters and local history, opened by an overrides badge too. - History payload version 2 adds ChatTurn.startedAt; version-1 imports back-fill 0. - Drawer adapter: width and side modifier classes, Escape left to an open dialog, IME Escape ignored. The Clear All refocus now runs after the re-enabling commit, removing a timer race. Validated with typecheck, lint, the theme-selector gate, 428 vitest tests, 10 consecutive confirmations.test.tsx passes, and the chat, shell, browser and models Chromium specs. Refs #1915
Output of python3 scripts/webui/build_bundle.py; build_bundle.py --verify passes. Initial JS is 166.99 kB gzip, under the 200 kB budget. Refs #1915
The shared ui-common Drawer records its Tab cycle's first and last controls once, when it opens. Chat's settings and conversation-list drawers disable their trailing controls while a response streams, so Tab from the last usable control left the aria-modal panel: in Chromium it reached <body> and then the shell's home link or navigation button behind the open drawer, at 1440 and 390 px. The Drawer adapter now wraps Tab and Shift+Tab at the panel's first and last usable tab stops at the time of the key press (enabled, not inert or hidden, rendered). For the navigation sheet's static links this is the same cycle as before; ui-common-shell, shell, browser and ui-common-components specs still pass. A unit test disables the last control after opening and checks the wrap; chat.spec's streaming test now opens the settings drawer mid-stream and asserts Tab and Shift+Tab stay inside it. Both fail with the wrap disabled.
Retry re-sends a failed last turn through send(), which returns silently when no Ready chat model is selected or the connection is down. The Retry button stayed enabled in that state, so a click did nothing and said nothing, while Send beside it was disabled. MessageList takes a canSend flag (Chat passes canChat) and disables Retry with it; Edit and regenerate stays available because it only moves the prompt back into the composer. The message-list test covers the disabled state and the chat doc says Retry is gated like Send.
The model picker's confirmCapacity path (a conflict opens the Models capacity dialog, the chosen eviction target is re-validated, then the load is sent again with the eviction target and the pending load profile) had no test. The new case answers the dialog through its native select and asserts the second model action's exact body, a fresh idempotency key, and that the dialog closes.
Rebuilt with scripts/webui/build_bundle.py after the Drawer Tab wrap and the Retry gate; build_bundle.py --verify passes.
Implementation reviewReviewed against the consolidated #1915 brief (including its accepted deviations), the epic #1910 contract and the diff. Send, Stop, edit, IME, Cmd/Ctrl+N, the 50 ms flush, generation fencing, caps and budgets are unchanged; Fixed on this branch
Remaining
Verification
|
Imported history accepts updatedAt as any non-negative safe integer and elapsedMs as any finite number, but a Date only holds times up to 8.64e15 ms. The conversation list and the message timestamps pass those values to toISOString and Intl.DateTimeFormat, which throw a RangeError during render. With no error boundary the whole WebUI unmounted as soon as a crafted import was confirmed (updatedAt) or its conversation was opened (elapsedMs, which local history also persists). isoTime and formatClock now treat a time a Date cannot hold as unknown: the row keeps its relative time without a dateTime attribute and the message omits its clock. The new startedAt field is also bounded to 8.64e15 at validation. updatedAt and elapsedMs keep their existing bounds so previously saved payloads still load. New tests render a ConversationList from an imported MAX_SAFE_INTEGER updatedAt and a MessageList with an overflowing completion and start time, and reject a startedAt past 8.64e15. All three failed before the fix.
ModelPicker re-renders with Chat on every 50 ms stream flush and rebuilt its options each time: three catalog filters, three localeCompare sorts and a label lookup per option, plus a fresh options array that also invalidated the shared Select's own memo. The options are now memoized on the catalog, the selected entry and the locale. The saving is small (about 36 microseconds per rebuild for a 200-entry catalog in V8) but it is per flush. A test spies on localeCompare to pin that re-renders with the same catalog do not re-sort and a new catalog does; it fails with the memo removed.
Rebuilt with scripts/webui/build_bundle.py after the timestamp guard and the picker memo; build_bundle.py --verify passes and the initial JS gzip stays under the 200 KiB budget.
Security and performance reviewFixed on this branch:
Reported, not changed (LOW):
Checked and clean: model output, titles and model names render as text; no inline styles or CSSOM writes; Load needs a confirmation and re-checks revision, Verification: typecheck, eslint (0 warnings), vitest for chat, models, design-system, main and i18n (418 passed), Playwright |
Clearing the last next-turn override while its settings drawer is open unmounts the header's overrides badge, so the shared Drawer's own focus hand-off (which returns focus to whatever opened it) has nothing left to focus and leaves focus on <body>. The settings drawer's onClose now checks for that and falls back to the settings IconButton. The composer also rebuilt its Intl.NumberFormat on every render, which happens on every 50 ms stream flush while a response is generating; memoize it per locale instead, the same per-locale caching time.ts already applies to its own formatters. Adds a unit test that opens the drawer from the overrides badge, clears the override inside it to unmount the badge, forces a body-only focus state (jsdom does not run the browser's inert focus-fixup rule that a real close relies on), and asserts closing the drawer refocuses the settings control. Validation: - pnpm run typecheck, eslint --max-warnings=0, check:theme-selectors all pass - vitest run src/features/chat src/design-system src/i18n src/main.test.tsx: 360 passed - playwright chat.spec.ts (5/5) and shell.spec.ts (14/14) via the 4915 config
chat.md still described the pre-review state in four places: Retry's disable condition was narrower than the code (it disables whenever Send would be refused, not only on a not-Ready model), the settings drawer's Tab wrap and its opener-gone focus fallback were undocumented, imported updatedAt/elapsedMs values past what a Date can hold were not mentioned as rendering unknown rather than crashing, and the startedAt schema bound (8.64e15) was missing. Also drops two changelog-style phrasings ("the adapter now forwards", "the Drawer adapter gained") in favor of describing the current behavior directly.
Refs #1915
python3 scripts/webui/build_bundle.py after the settings-focus and composer-formatter fixes; --verify passes.
The real-chat acceptance read only the first catalog page, so on a server with more than 50 checkpoints the loaded model could sit on a later page and the harness failed before it started. It now reads the model's own `/ui-api/v1/catalog/{id}` entry for the initial state and the request-lease polls. The short request's override drops from 128 to 64 tokens, which the isolated acceptance server allows and still yields a visible reply.
Validated against the isolated bundled server at b9fab1b with qwen3-0.6b-4bit: the spec passes, with zero CSP violations and no external requests, and the lease rose above 0 and returned to 0 after Stop.
Refs #1915
On the current conversation row, the hover tint stacked on the current-row tint. That dropped the muted model name and relative time to a 4.42:1 contrast ratio, below 4.5:1. The pointer rests there right after a one-click switch, which is where the live acceptance run caught it. Hover now tints only rows that are not current. The Chat browser spec now runs axe after that switch. It failed at 1440 before the CSS change and passes after it. Refs #1915
Brings in #1928 (Settings tabs). This branch rebuilds Chat and #1928 edits the same two chat files, so both are resolved here. The Chat settings drawer's sampling link now opens `#settings/requests`. The next-turn parameter hints keep #1928's server-default resolution inside this branch's drawer section, which has no `<details>` wrapper. `chat.params.server_default` has no remaining caller, so it is dropped from the catalog and the string fixture. The bundle is regenerated, not hand-merged. Validated on the merged tree: typecheck, lint, check:theme-selectors, unit (62 files, 641 tests), build_bundle --verify, the WebUI contract and binary-asset checks, and the Playwright Chromium chat, shell, settings and browser specs.
Brings in #1931 (Chat), which added the same end-anchored Drawer variant this branch had. The adapter now has one API, main's `width="medium"` and `side="end"` modifier classes over the constant inline sheet width; the Models inspector drawer uses them and this branch's `placement` prop, its 520 px inline width and the matching layout-gate allowance are gone. There is one Tab-wrap implementation, computed at key-press time: it wraps at the panel's first and last usable stops, counts a Details summary and skips the contents of a closed details, and stops every Tab inside the panel in the capture phase so the package's stale handler cannot wrap early. It keeps main's IME Escape guard and main's rule that an Escape inside an open native dialog belongs to that dialog, and this branch's fixes: a drawer confirmation's Escape leaves the drawer open, and a Tab pressed with focus on no element enters the panel (a Tab from a control outside it, such as a portalled Select list, keeps its own order). The Models Inspect button uses main's info icon, the row load goes through main's load-action.ts, both drawer test sets are kept, and the bundle was regenerated rather than hand-merged. Unit (35 node + 664 Vitest) and all 119 Chromium Playwright tests pass, including chat.spec.ts. Refs #1918
Rebuilds the Chat route as a conversation view: a conversation list (a pane from 961 px, a Drawer below), user and assistant messages you can tell apart without reading them (alignment, a "You" label and a model badge, timestamps, elapsed seconds while streaming, keyed status labels), a pinned composer that grows from 1 to 8 rows, one settings drawer for conversation settings, next-turn parameters and local history, and Load from Chat. The page itself no longer scrolls; only the message list does. Send, Stop, edit, history and privacy behave as before. Retry re-sends a failed last turn through the same guards.
Design notes
models/load-action.ts, which sends the same request the Models screen sends. Aconflictresponse opens the Models capacity dialog. Selecting a model never loads it.screen.tsxchanges only at itsload()call site.startedAt. Version-1 turns import withstartedAt0, and the IndexedDB version stays at 1.Changes during review
Datecan hold renders as unknown instead of crashing the WebUI.startedAtis bounded on import.Validation
unit(58 files, 600 tests),build_bundle.py --verify,check_webui_contract.pyandcheck_binary_assets.py.--verify, the contract and binary-asset checks, and the chat, shell, settings and browser specs. The Chat drawer's sampling link now opens#settings/requests, and the parameter hints use feat(webui): split Settings into scoped tabs with typed live controls #1928's server-default resolution.confirmations.test.tsxthen passed 10 consecutive runs.router-realandperformance(selectors updated).Measurements (before/after)
Mock fixture, 30-turn conversation, page scrolled to the top. Before is main 835a552, after is b9fab1b. The real-binary figures are in the next section.
.chat-messagesonly.chat-messagesonlyReal-binary acceptance
Ran against an isolated bundled
mlxcel-serverbuilt from b9fab1b, withqwen3-0.6b-4bitloaded. Every check passed.chat-real.spec.ts(withMLXCEL_CHAT_REAL_ISOLATED=1): the reply was "Hello! 🌍" and the status label reads Done. Stop worked: the request lease rose above 0, returned to 0, and the status label reads Stopped. There were no CSP violations and no external requests, and axe found no violations. The harness now reads the model's own catalog entry, because a large catalog puts the model past the first page. The short turn is capped at 64 tokens.POST /ui-api/v1/model-actionswith the Models body (action: load,model_id,expected_revision,idempotency_key). Once the model reached Ready, the header's Load button was gone and a 32-token turn answered. Stop mid-stream took the lease back to 0 and the status label reads Stopped.max_tokensshows "Overrides: max_tokens". The badge reopens the drawer on the parameters section, and focus returns to the badge..chat-messagesscrolls, and the page has 797 words (45 when empty).The live run found one defect, now fixed in 5f50a89: on the current conversation row, the hover tint stacked on the current-row tint and dropped the muted metadata to 4.42:1 contrast. The Chat browser spec runs axe in that state; it failed before the fix and passes after.
Merge note: #1930 adds the same end-anchored Drawer variant, as
placement. This PR adds it assidepluswidth. Whichever PR merges second reconciles the Drawer signature and theds-drawer--endCSS.Part of #1910
Closes #1915
CI note (GB10 Activity gate)
The
WebUI installed artifactjob one2a5af2a(run 35453693578) failed only at the "Run Activity performance gate" step's precheck,GPU compute processes are already present before Activity gate, so the gate never measured anything; every other step passed, including the real-router browser harness, the installed-artifact WebUI check and the reverse-proxy check. The precheck has failed for every PR since run 35449824150 was cancelled at 14:59:53Z inside that step, which points at a GPU process left on the runner. This PR changes the Chat screen and does not touch decode or the Activity page, so per the maintainer's decision for this epic it merges with this evidence recorded (gate noise: #1925).