Skip to content

feat(webui): rebuild Chat as a conversation view - #1931

Merged
inureyes merged 16 commits into
mainfrom
feature/issue-1915-chat-conversation-view
Sep 19, 2026
Merged

inureyes merged 16 commits into
mainfrom
feature/issue-1915-chat-conversation-view

Conversation

@inureyes

@inureyes inureyes commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

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

  • There is one "New conversation" button, in the page header. A second button with the same name breaks strict role locators in four specs.
  • The model picker sits in the conversation header, because the ui-common Select always opens its dropdown downward. Its groups are expressed as ordered options (Ready chat models first) with a state description on each, because the Select has no option groups.
  • Load goes through a confirmation, then models/load-action.ts, which sends the same request the Models screen sends. A conflict response opens the Models capacity dialog. Selecting a model never loads it. screen.tsx changes only at its load() call site.
  • History payloads are now version 2 and add startedAt. Version-1 turns import with startedAt 0, and the IndexedDB version stays at 1.
  • Status labels are Generating, Done, Stopped, Did not finish and Failed, so the raw status words never appear.

Changes during review

  • The Drawer adapter wraps Tab at the controls that are usable when the key is pressed. Before, Tab escaped both Chat drawers while a response streamed.
  • An imported time outside the range a Date can hold renders as unknown instead of crashing the WebUI. startedAt is bounded on import.
  • Retry is disabled whenever Send would be refused. When clearing the overrides removes the badge that opened the settings drawer, focus falls back to the settings button on close. The model picker now sorts its options only when the catalog or selection changes.

Validation

  • Passing at b9fab1b: typecheck, lint, check:theme-selectors, unit (58 files, 600 tests), build_bundle.py --verify, check_webui_contract.py and check_binary_assets.py.
  • Playwright Chromium at b9fab1b, one spec per run: chat 5, shell 14, browser 29, models 8, settings 3, activity 3, theme 23, ui-common-components 14, ui-common-shell 12, all passing.
  • Passing after merging main with feat(webui): split Settings into scoped tabs with typed live controls #1928 (e2a5af2): typecheck, lint, theme selectors, unit (62 files, 641 tests), --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.
  • A test for the Clear All focus race failed 3/3 before the fix. confirmations.test.tsx then passed 10 consecutive runs.
  • Not runnable locally: router-real and performance (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.

Viewport Page height, empty Page height, 30 turns Composer visible without scrolling Scrolling elements Words, empty
1440x900 before 967 1405 no (bottom 1113) transcript box + page 93
1440x900 after 900 900 yes (bottom 803) .chat-messages only 59
390x844 before 1268 1600 no (bottom 1280) transcript box + page 93
390x844 after 844 844 yes (bottom 743) .chat-messages only 46

Real-binary acceptance

Ran against an isolated bundled mlxcel-server built from b9fab1b, with qwen3-0.6b-4bit loaded. Every check passed.

  • chat-real.spec.ts (with MLXCEL_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.
  • Load from Chat: with the model unloaded, selecting it and opening the confirmation sent no request. Confirming sent exactly one POST /ui-api/v1/model-actions with 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.
  • At 1440 and 390: one click in the list switches conversations. At 390 the list opens as a Drawer from the header and closes when you pick a conversation. Setting max_tokens shows "Overrides: max_tokens". The badge reopens the drawer on the parameters section, and focus returns to the badge.
  • At 1440x900 with 30 real turns, the page stays 900 px tall, the composer is visible, only .chat-messages scrolls, 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 as side plus width. Whichever PR merges second reconciles the Drawer signature and the ds-drawer--end CSS.

Part of #1910

Closes #1915

CI note (GB10 Activity gate)

The WebUI installed artifact job on e2a5af2a (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).

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
@inureyes inureyes added status:review Under review type:enhancement New features, capabilities, or significant additions priority:high High priority area:architecture Architecture and code structure changes labels Sep 19, 2026
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.
@inureyes

Copy link
Copy Markdown
Member Author

Implementation review

Reviewed 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; screen.tsx changes only at the load() call site; history v1/v2 validation and the Clear All refocus fix hold (confirmations.test.tsx passed 10 of 10 runs).

Fixed on this branch

  • Tab left the aria-modal Chat drawers while a response streamed (HIGH): the shared Drawer records its Tab cycle ends at open, and the trailing controls are disabled later, so Tab reached the shell behind the drawer at 1440 and 390 px. The adapter now wraps at the controls usable at key press (9310990), with a unit test and a chat.spec assertion that both fail with the wrap disabled.
  • Retry stayed enabled when no Ready chat model was selected and then did nothing (MEDIUM): it is now disabled exactly when Send is (d2b2c49).
  • The capacity dialog eviction path from Chat had no test (MEDIUM): added (f20d568). Bundle regenerated (a07feb5).

Remaining

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, eslint, theme selectors, vitest (39 files, 430 tests), Playwright chat 5/5, shell 14/14, ui-common-shell 12/12, ui-common-components 14/14, browser 29/29, bundle --verify

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.
@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review

Fixed on this branch:

  • MEDIUM, 73dd8a4: an imported history with updatedAt past 8.64e15 (history accepts any safe integer) or an elapsedMs large enough to push the completion time past it made toISOString / Intl.DateTimeFormat throw during render. With no error boundary the whole WebUI unmounted on confirming the import or opening the conversation. isoTime and formatClock in time.ts now treat such a time as unknown, and startedAt is also bounded to 8.64e15 at validation. The updatedAt and elapsedMs bounds are unchanged, so saved payloads keep loading. Three new tests failed before the fix.
  • LOW, 14520ee: ModelPicker rebuilt and re-sorted its options on every 50 ms flush. They are now memoized on catalog, selection and locale. The measured cost was about 36 us per rebuild for 200 entries.
  • c1eee08: regenerated bundle. --verify passes and the initial JS gzip is 165,458 bytes.

Reported, not changed (LOW):

  • composer.tsx:63 builds an Intl.NumberFormat on every render, which means every flush.
  • chat.tsx:300: TurnParameters (9 fields) re-renders on every flush inside the closed drawer. Memoizing the element on defaults, parameterDraft and locale would skip that.
  • chat.tsx:37: useCompact calls matchMedia on every render.

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, canLoad and eviction candidates when answered, and selecting never sends a request; the v1/v2 import checks exact key sets; no new browser storage; no window.confirm; the timers are cleaned up.

Verification: typecheck, eslint (0 warnings), vitest for chat, models, design-system, main and i18n (418 passed), Playwright chat.spec.ts (5 passed), build_bundle.py --verify.

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.
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 19, 2026
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.
@inureyes
inureyes merged commit 18fda73 into main Sep 19, 2026
17 of 18 checks passed
@inureyes
inureyes deleted the feature/issue-1915-chat-conversation-view branch September 19, 2026 16:12
inureyes added a commit that referenced this pull request Sep 19, 2026
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
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): rebuild Chat as a conversation view with a conversation list and a pinned composer

1 participant