Skip to content

feat(frontend): palette search, screen-reader announcements, page-level Escape, calmer polling and scrolling - #320

Merged
dovvnloading merged 14 commits into
mainfrom
feat/frontend-ux-b
Sep 29, 2026
Merged

dovvnloading merged 14 commits into
mainfrom
feat/frontend-ux-b

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

Nine frontend defects, mostly accessibility and navigation:

  • The command palette could only find the eight newest chats, and its theme item turned "system" into "dark".
  • A screen reader was never told that a response started, finished, stopped or failed.
  • Escape stopped a response only while the composer had focus, and useHotkey re-registered every render, fired on every auto-repeat and during IME composition, and matched AltGr (Ctrl+Alt) as Ctrl, so AltGr+K opened the palette while typing.
  • Scripted smooth scrolling ignored prefers-reduced-motion.
  • The plain and virtualized transcripts disagreed on "at the bottom" (80px vs Virtuoso's 4px), and following a streamed reply assigned scrollTop on every frame under a scroll-behavior: smooth container, restarting an animation each time.
  • Message times showed only the clock time.
  • Code blocks could not wrap, GFM task items showed a bullet and a checkbox, and a dropped image left no trace.
  • Dead code sat in api/, hooks/ and the stylesheet.
  • Polling restarted (and fired an extra request) whenever its interval changed, asked a ready llama.cpp runtime every two seconds, and never slowed down when the backend was failing.

Root cause

Each was a local omission, confirmed against the current code before changing it. The ones worth stating: the palette rendered chats.slice(0, 8), and cmdk can only filter rendered items; useVisiblePolling put intervalMs in its effect dependencies and ran the callback at the top of the effect, so any interval change was also "start polling"; both poll callbacks swallowed their own errors, so nothing upstream could see a failure; and cmdk derives an item's identity from its text, so untitled chats (all "New Chat") share one highlight.

Change

One commit per concern:

  • FE-19 formatMessageTime(value, now) adds month and day for an earlier day this year and the year for an earlier year (viewer's locale and zone); the <time> carries the full date and time in title.
  • FE-20 A Wrap toggle beside Copy, one preference for every block, remembered in localStorage (session-only if storage is blocked); task-list CSS; images are still never loaded but a placeholder names the alt text with the source in title.
  • FE-16 useVisiblePolling re-arms its timer on an interval change without running the callback; runs once on being enabled; counts a throw or rejection as a failure and, with backoff, doubles the wait from the third consecutive failure up to 30 s, resetting on success. llama.cpp is polled every 2 s only while the runtime is downloading, starting or stopping (or a reply is generating) and every 15 s otherwise; one check follows each generation that ends. The execution poll now sees its failures (the callers that refresh after an action keep swallowing them).
  • FE-18 / FE-40 One NEAR_END_PX for both transcripts (atBottomThreshold on Virtuoso); scrollToBottom(behavior) is instant by default and smooth only for "Jump to latest"; a small useMediaQuery hook makes both the smooth jump and Virtuoso's smooth follow fall back under reduced motion.
  • FE-39 usePageEscape stops a running response from anywhere on the page, but not when something already handled the key, a dialog/menu/listbox is open, focus is in another text field, or the key is a repeat/composition/modified. useHotkey holds its handler in a ref, ignores repeats and composition, and requires Alt to be up for a modifier combo.
  • FE-37 ResponseAnnouncer: a visually hidden polite status that says "Response started", "Response complete, N tokens", "Response stopped" or "Response failed", driven by the chat store so an outcome the stream host publishes is heard whichever page was showing. The transcript itself stays non-live, and nothing is announced per token.
  • FE-22 With text typed, all chats are searched (scored, best first, at most 50 shown, debounced 120 ms so a long history is not scored on every key), heading "Chats" instead of "Recent chats", query forgotten on close. Chat rows use the id as the cmdk value and the title as a keyword (see Limits), so several chats with the same title are separate reachable rows. The theme item cycles system, light, dark and shows the next one.
  • FE-13 Removed: useRafBatchedText (the stream hook has its own batcher), the client methods diagnostics, shutdown, executionStatus, startCodeExecution, startScratchCompute, startRecipeImageTransform, stageAttachment, streamExecution with the two tests that only exercised them, ChatPage's second copy of the generation defaults, and the CSS for .rounded-picker, .input-with-icon, .field-value, .gguf-download-status, .execution-task-capabilities. streamJob loses a stray void. A new test fails for any tokens.css class no component names (with a checked allow-list).
  • The screenshot script tools/screenshots/capture_showcase.mjs pressed "Toggle theme"; it now presses "Change theme" and reaches light and back to dark through the new cycle.

Compatibility and rollback

No API, contract or persisted-data change. New localStorage key cortex.codeWrap (a boolean). The palette item is renamed "Toggle theme" to "Change theme" (two App tests and the screenshot script updated). Deleted client methods mirror backend routes that still exist. Everything reverts with git revert of the individual commits; none depends on another except that the FE-22 commit needs nextThemePreference from lib/theme.ts, which it adds.

Checks

Run from the worktree, after the final rebase onto origin/main unless noted:

  • npm ci, npm run typecheck, npm run lint: clean.
  • npm test -- --run: 62 files, 858 tests passed (baseline before this branch: 58 files, 741 tests).
  • npm run test:coverage: passed (857 tests, measured before the final test-only commit), statements 89.55%, branches 84.43%, functions 88.4%, lines 92.06% (floors 84/79/83/87, not changed).
  • npx playwright test on command-palette, virtualized-transcript, accessibility, theme, chat and composer: 26 passed. Run before the last three commits (the screenshot script, the post-generation status check, and the real-overlay test) and not repeated after. attachments, chat-groups, execution and real-api were not run.
  • Pre-push hook (scripts/check.ps1, quick tier) on both pushes: all 11 checks passed (ruff, actionlint/zizmor, mypy, pytest, artifact-boundary review, contract drift, tsc, eslint, vitest). It ran on Python 3.14.0 only; no Python file changed, so the 3.10/3.12/3.13 matrix was not exercised.
  • Each new behaviour test was run against the previous source and failed there: FE-19/20 (19 failures across the three files), FE-16 (9 hook and 2 App tests), FE-18/40 (7), FE-37/39 (16), FE-22 (16), the CSS orphan test (it named 11 classes). The tests that pass on either side are the negative ones (for example "leaves Escape to an open dialog") and reports an approval that went through even when the refresh after it fails, which is a guard for the new split of the execution refresh.

Security / data-loss / concurrency

  • Images are still never fetched: the placeholder is text, and the source appears only in a title.
  • Escape only stops a response (the kept answer is unaffected, and the stop path already ignores a late stop). It is not sent from text fields or over dialogs, so it cannot discard an edit.
  • The announcer never reads message content or the failure reason, only a status and a token count, and does not duplicate the composer's alert.
  • A polling callback that rejects is now always handled, never an unhandled rejection. Stale results of an old effect instance are ignored once it is torn down (stopped).
  • No logging was added.

Limits

  • Not verified with a real screen reader or inside the desktop WebView2 shell: the live region, the instant scroll behaviour and reduced motion are tested through jsdom, a mocked matchMedia and Playwright's Chromium. capture_showcase.mjs was only syntax-checked; it needs the staged showcase server.
  • FE-39 is partial: the new shortcuts (new chat, focus composer, toggle sidebar) and key hints on palette items are not done. FE-37 is partial: the shortcut that focuses the last assistant message (and tabIndex={-1} on articles) is not done. Both were outside the scope given for this change.
  • FE-13 is partial. Kept on purpose: cancelJob and listHuggingFaceGGUFFiles (the download-cancel and file-listing work will use them), createChat and health (client.test.ts uses them as request vehicles), useUiStore.dismissToast (toast work), and LocalSetup/SystemStatusCard (no importer today, but the new-chat screen work may use them; README.md contains no sentence about a setup screen, so there was nothing to remove there). The theme-* class on AppShell is unused, but dropping its prop means editing the very long <AppShell ...> line in App.tsx, which parallel chat-deletion work also edits. ChatPage's duplicate generation defaults were the only remaining one; MessageComposer already imported the shared table.
  • The palette's chat filter uses cmdk's own scoring (defaultFilter) through a custom filter that scores chat rows on their title keyword rather than their id value. Debouncing means a match for an older chat appears about 120 ms after typing pauses, during which "No results." is suppressed. The 50-result cap is not announced to the user.
  • The overlay check in usePageEscape is a role selector (dialog, alertdialog, menu, listbox); a future overlay without one of those roles would need adding. A test with the real Base UI dialog and the cmdk palette pins the two that exist.
  • Mutation testing was not run (not available here). The coverage floors were not raised.

Plan item: FE-22, FE-37, FE-39, FE-40, FE-18, FE-19, FE-20, FE-13, FE-16

🤖 Generated with Claude Code

dovvnloading and others added 14 commits September 29, 2026 04:40
…e full time on hover

A message showed only its clock time, so reopening an old thread repeated one
time of day on every turn with no way to tell which day. formatMessageTime now
adds the month and day for an earlier day this year and the year for an earlier
year, in the viewer's locale and time zone, and the time element carries the
full date and time in its title.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…here an image was dropped

Fenced code could only scroll sideways: the toolbar now has a Wrap toggle that
applies to every block and is remembered in local storage (falling back to the
session when storage is blocked). GFM task lists showed a bullet and a checkbox;
the marker is now removed and the checkbox styled. Images are still never loaded,
but where one was dropped there is now a placeholder naming its alt text, with
the source on hover, instead of nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…and back-off on failure

Changing a poll's interval restarted its effect and ran the callback at once, so
every switch between the fast and idle cadence cost an extra request; it now only
re-arms the timer. The llama.cpp status was polled every two seconds for as long
as a GGUF model was selected; it is now polled every two seconds only while the
runtime is downloading, starting or stopping or a reply is generating, and every
fifteen seconds otherwise. A poll that fails three times in a row now doubles its
wait up to thirty seconds and returns to normal on the first success, and a
callback that rejects is no longer left as an unhandled rejection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…reduced motion for scripted scrolling

The plain transcript treated the last 80px as the bottom while Virtuoso used its
4px default, so following a reply behaved differently either side of forty
messages. Both now share one constant. Following streamed output assigned
scrollTop on every frame under a stylesheet that smooth-scrolls, restarting an
animation each time; it now scrolls instantly and only the reader's own Jump to
latest animates. Neither the smooth jump nor Virtuoso's smooth follow honoured
prefers-reduced-motion, which only the stylesheet did; a small useMediaQuery hook
now lets both fall back to instant scrolling.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rtcuts ignore AltGr, repeats and IME

Escape only stopped a response while the composer had focus, although the
shortcuts dialog advertises it without that condition. A page-level handler now
stops it while a response is running, leaving Escape to open dialogs, menus and
listboxes, to other text fields, and to anything that already handled the key.
useHotkey re-registered its listener on every render, fired again for every
auto-repeat, fired during IME composition, and matched AltGr (reported as
Ctrl+Alt) as Ctrl, so AltGr+K opened the palette while typing and holding Ctrl+K
made it flicker. It now holds the handler in a ref, ignores repeats and
composition, and requires Alt to be up for a modifier combo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A screen reader was never told that a response began or ended: the streaming
bubble is not a live region (and should not be, or every token would be read),
the composer status reverts, and the pending bubble is gone at the first token.
A visually hidden polite status now says Response started, Response complete
with the token count when known, Response stopped, or Response failed, following
the chat store so an outcome published by the stream host is heard whichever
page was showing. A failed job's completion after its failure is not announced a
second time, and an outcome already waiting when the page opened is not read out
again. Includes page-level tests for Escape from the body, from the composer
(once), and with an overlay or another field open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the theme through system

The palette rendered only the eight newest chats, so cmdk could never find an
older one although the placeholder promised chat search. With text typed it now
searches all chats (scored, best first, at most fifty shown, and paused for 120ms
of typing so a long history is not scored on every key), with the heading
changing between Recent chats and Chats, and the text is forgotten when the
palette closes. Chat rows now carry their id as the cmdk value and their title as
a keyword, so several chats with the same title are separate, reachable rows
rather than one shared highlight. The theme item cycled system into dark; it now
goes system, light, dark and names the theme it would switch to.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n CSS, and a duplicate defaults table

Deletes what has no call site: the useRafBatchedText hook (the stream hook has
its own batching helper that is the one in use), the client methods diagnostics,
shutdown, executionStatus, startCodeExecution, startScratchCompute,
startRecipeImageTransform, stageAttachment and streamExecution with the two tests
that only exercised them, and the CSS for .rounded-picker, .input-with-icon,
.field-value, .gguf-download-status and .execution-task-capabilities. ChatPage no
longer keeps a second copy of the six generation defaults; the composer already
falls back to the one in lib/generationParams. streamJob's return type loses a
stray void. A new test fails for any class in tokens.css that no component names
(with a short, checked allow-list for classes the source builds or a library
emits), so the next orphan is caught when it is made.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e item

The command palette item is now Change theme and cycles system, light, dark, so
the screenshot script that pressed Toggle theme once to reach light and once to
return to dark no longer found the item and would have ended on light. It now
presses Change theme once (system, which its light colour-scheme emulation
resolves to light) and twice to return to dark. Not run here: it needs the staged
showcase server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
With the runtime polled every fifteen seconds while it is idle or ready, a reply
that finished between two polls left the model picker saying the model was not
loaded until the next one. A generation that ends now triggers one status check
(only while a GGUF model is selected), which also keeps the one-request-per-poll
accounting intact: starting a generation only changes the interval.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…le a response runs

The page-level Escape rule is tested with synthetic dialog, menu and listbox
nodes; this adds a test with the real shortcuts dialog (Base UI) and the real
command palette (cmdk), checking that Escape closes each without stopping the
response and that the same key stops it once both are closed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Two textual conflicts, both resolved by keeping both sides:

- frontend/src/app/runtimeAvailability.test.ts: keep this branch's
  llamacppPollInterval suite and main's inventory-loading suite.
- frontend/src/styles/tokens.css: keep main's new GGUF picker, toast and
  new-chat-guide rules; drop the .gguf-download-status rule this branch had
  already removed as dead, since main's extracted GGUFDownloadForm still does
  not use it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Main's Undo toast holds a delete that only goes to the backend when the toast
closes, so a keypress that closes or acts on it is not free. The page-wide
Escape (stop a response from anywhere) now leaves the key alone while focus is
inside the toast region (data-keeps-escape), as it already does for dialogs,
menus, listboxes and text fields.

Tests: the hook ignores a notification target and takes Escape elsewhere; with
the real ToastProvider beside ChatPage, Escape on a focused Undo button neither
stops the response nor touches the toast, and stops it from anywhere else; the
response announcer and an error or status toast are each read once. Also waits
for the composer to take focus back before the text-field Escape test focuses
its own field, which raced under load.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…etion

The palette searches every chat; it must agree with the sidebar while a deleted
chat waits out its Undo window. Covers the recent list and search during the
window, the chat coming back after Undo, and staying gone after the delete is
sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@dovvnloading

Copy link
Copy Markdown
Owner Author

Merged origin/main into this branch (merge commit b58393d5, plus two follow-up commits; branch head 78dfa029). Plain merge, no rebase or force-push. frontend/package-lock.json is unchanged against main (git diff origin/main -- frontend/package-lock.json is empty), and git diff origin/main --stat lists only this pull request's files.

How each conflict was resolved

Only two files conflicted textually; both were resolved by keeping both sides.

Semantic checks on the files that merged cleanly

  • Command palette (FE-22): App.tsx passes main's visibleChats to CommandPalette, so a chat hidden by the Undo window is not offered and comes back after Undo. This was not pinned by any test, so I added one (below).
  • Theme cycle (FE-22): toggleTheme uses nextThemePreference on top of main's settings save; AppShell gets the theme prop alongside main's visibleChats.
  • Announcer (FE-37) and toasts: generation failures never raise a toast in main (the reason is in the composer's alert), and the toasts main added are separate events, so the announcer and the toasts already read once each. Added tests that pin that.
  • Page-wide Escape (FE-39) and main's toast: main's Undo toast is a status with a focusable Undo button, and closing the toast is what sends the delete, so Escape must not act on it. The page-wide Escape already yielded to dialog, alertdialog, menu, listbox (which covers main's delete confirmation and the model picker) and text fields; it did not yield to focus inside a toast. It now does: ToastProvider's region carries data-keeps-escape and usePageEscape ignores an Escape whose target is inside it. Nothing else about the toast changed (I deliberately did not make Escape dismiss a toast, because dismissing an Undo toast commits the delete).
  • Reduced motion and at-bottom (FE-40, FE-18): main did not touch MessageList; ChatPage merged cleanly and still calls the shared scroll rule, with jumpToLatest the only smooth scroll. Existing MessageList and ChatPage.renderCost tests cover it.
  • Polling (FE-16): useModelJobs follows model jobs over the job event stream, not through useVisiblePolling, so the calmer runtime polling cannot starve it. App.modelJobs.test.tsx (which selects a GGUF model, so the runtime poll is active) and App.pollRender.test.tsx both pass on the merged tree.

Tests covering both pull requests' behaviour

  • frontend/src/app/App.paletteDelete.test.tsx (new): palette recent list and search omit a chat during its Undo window, offer it again after Undo, and still omit it after the delete is sent.
  • frontend/src/features/chat/ChatPage.a11y.test.tsx (extended): with the real ToastProvider, Escape on a focused Undo button neither stops the response nor touches the toast, and stops the response from anywhere else; the announcer and an error toast are each read once (announcer stays a polite status, the error toast and composer alert are the only alerts); a status toast does not enter the announcer.
  • frontend/src/hooks/usePageEscape.test.ts: the hook ignores a notification target and takes Escape elsewhere.
  • frontend/src/app/runtimeAvailability.test.ts: both suites, side by side.
  • Existing suites from both sides that exercise the merged files: App.chatDelete, App.drafts, App.modelJobs, App.pollRender, ToastProvider, AppShell, CommandPalette, MessageList, orphanCss.
  • Mutation check on the two new behaviours: passing unfiltered chats to the palette, and removing the notification check from usePageEscape, each fail the new tests (3 tests failed under the mutants, all pass restored).
  • One existing test was hardened, not the behaviour: "leaves Escape to another text field" focused its field before the composer took focus back after sending, and failed once under load; it now waits for that first (same approach as focusThePage).

Checks run (from frontend/ unless noted)

  • npm ci, npm run typecheck, npm run lint: clean.
  • npm test -- --run: 77 files, 1047 tests passed (was 76 files, 1041 tests on the plain merge, before the added tests).
  • npm run test:coverage: passed, floors held (statements 91.29%, branches 86.66%, functions 90.53%, lines 93.34%).
  • Repo root: python -m ruff check backend tests tools main.py app_factory.py scripts passed; python -m mypy reported no issues in 97 source files.
  • Pre-push hook (full quick tier) passed, 11 of 11 checks, including backend pytest, contracts, artifact-boundary review, and the vitest run above.
  • Not run: Playwright e2e (not part of the quick tier).

Rollback: revert the two follow-up commits (b28320a5, 78dfa029) to return to the plain textual merge, or revert the merge commit to return to the branch as reviewed.

@dovvnloading
dovvnloading merged commit 9b81420 into main Sep 29, 2026
11 checks passed
@dovvnloading
dovvnloading deleted the feat/frontend-ux-b branch September 29, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant