Skip to content

feat(ui): refactor the chat interface around a modern design - #45

Open
gaby wants to merge 34 commits into
pydantic:mainfrom
gaby:claude/web-ui-polish-ux-turiyd
Open

feat(ui): refactor the chat interface around a modern design#45
gaby wants to merge 34 commits into
pydantic:mainfrom
gaby:claude/web-ui-polish-ux-turiyd

Conversation

@gaby

@gaby gaby commented Aug 12, 2026

Copy link
Copy Markdown

Rebuilds the chat interface around what someone actually watches an agent do: what it is thinking, which tools it reached for, what each one cost, and where it needs a decision. The shell, the message column, the tool cards and the failure states were all reworked; Chat.tsx and Part.tsx are left as composition orchestrators with the pieces in their own files, per CLAUDE.md.

Every screenshot below is driven by the deterministic test server rather than a live provider, so each one is reproducible from the same FunctionModel fixtures the E2E suite runs.

Light Dark
Empty chat, light Empty chat, dark

The shell

sidebar → AppHeader → conversation → ChatComposer.

  • AppHeader — sidebar toggle, conversation title (also the tab title, and the app's only h1), new chat, theme toggle, keyboard help
  • WelcomeScreen — an empty chat opens as one centred column with the composer and suggested prompts, instead of a blank page with the input pinned to the floor
  • AssistantTurn / UserBubble — a turn is one avatar-gutter column holding that turn's work and its reply
  • Sidebar — search (above six conversations), pinning, date buckets, rename, per-row menu, and relative times that refresh on their own. On a phone it is a sheet, and choosing a conversation now closes it rather than leaving it over the chat it was used to pick
  • KeyboardShortcutsDialog — one table behind both the dialog and the tooltips, so they cannot contradict each other
Conversation history Keyboard shortcuts
Sidebar with date buckets and a per-row menu Keyboard shortcuts dialog

A turn's work, folded

Thinking and tool calls collect into a single foldable block rather than stacking a card per step above the reply. Folded, it is one line that still names what ran — Worked for 1s · get_weather, calculate. Opened, every step keeps its own card, arguments, result and copy actions: the detail is a click away, not summarised out of existence.

Folded Opened
A turn's work folded into one line The same turn opened, each step on a rail

It opens itself while the work is happening and folds up once the answer lands. Anything needing a person holds it open instead — a pending approval reads "Waiting for your approval", a failed call "Ran into a problem" — and opening it by hand cancels the auto-collapse so it cannot shut under a reader. A whole tool loop stays in one block even though the SDK marks each model step, sources are cited between the calls they came from without splitting it, and the timer counts from the first thinking token rather than from the first tool call. A turn that works, answers, then works again gets a second block, and only the one still being worked on reads as live. A turn that only thinks keeps its own single reasoning line rather than being wrapped in a second fold.

A tool's arguments and result Thinking, as steps
A tool card open on its arguments and result Reasoning opened as steps

Approvals and failures

  • The approval gate names the tool, states plainly that nothing has run yet, and puts the two outcomes side by side. Once decided it shrinks to a one-line record of what was chosen — which survives the tool then failing
  • A failed call reads in place. It used to sit behind a "View Error" button that opened a modal: two clicks and a context switch to reach the one thing that explains the run
  • A failed run separates what broke from how to recover, with the provider's raw text one click away
  • Tool cards carry their state on the left edge, so a run of cards can be scanned for the one that needs attention without reading a word
  • Repeated calls to the same tool collapse into one group; noisy tools can be hidden by name and revealed inline
Waiting on a decision A call that failed
Tool approval prompt A failed tool call with its reason inline

A failed run with Retry and Continue, and the raw provider text expanded

Token usage

The UI reads UIMessage.metadata.usage off assistant messages and shows a per-reply count plus a running conversation total with the breakdown behind a click. Agent.to_web() has no seam to write that metadata today, so agents served that way fall back to a locally-derived estimate, labelled ~. That estimate charges each turn for the conversation it was sent plus what it produced, since a reported total sums per-request usage and every request carries the history again. A partial history — only some replies reporting — is labelled ~ too rather than presented as an exact total it would undercount, and a backend that reports only a total gets the total rather than a breakdown of zeroes.

tests/server/server.py grew a ~20-line UsageEventStream showing what a backend has to write.

Usage, broken down Thinking effort
Token usage popover Thinking effort meter

Composer

Model select, an effort meter built as a radiogroup with roving tabindex rather than a dropdown, builtin tools as inline toggle chips (checkbox items once they overflow into a menu, so their state is not carried by an unlabelled icon), and a stop control — there was previously no way to abandon a run. Submission is blocked until a model the backend still advertises is selected, rather than sending an empty or withdrawn model id.

Markdown

Math was rendering three times over: Streamdown typesets $$…$$ with KaTeX, whose HTML output is class-driven, then sanitises with an allowlist that permits className on code alone — so the MathML copy and the LaTeX source the stylesheet exists to hide both rendered as text beside the visual one. It now typesets to MathML, which browsers lay out natively, with those elements and the layout attributes matrices need allowed through sanitisation. Class names on span are deliberately still stripped: this renders model output, and a reply could otherwise style itself into a full-page overlay out of the app's own utility classes.

Light Dark
Code block, math and links, light Code block, math and links, dark

On a phone

The same turn, and the sidebar as a sheet that closes when a conversation is picked.

Conversation Drawer
A turn on a phone The sidebar as a sheet on a phone

Alignment

Every edge in the conversation column was measured at 1440 and on emulated iPhone/Pixel rather than eyeballed. The composer's padding sat outside its max-w-3xl box while the conversation's sat inside, so the box you type into overhung the column it lands in by 16px a side; the message action icons were inset 7px from the text above them by their own button padding; and the composer changed width when the first message was sent. All three now share one set of edges.

Correctness

Most of the diff's risk is in conversation persistence, and the defects found in review are fixed here: a stream contaminating whichever conversation is now on screen, history written under the wrong id, a load persisted back as a write, a tail lost when leaving within the save throttle, a rename or a pin reverting a concurrent activity stamp, a superseded read landing on top of the conversation that replaced it, a deferred send delivered into the wrong conversation, sending before a history had loaded, a failed read overwritten by the next reply, a deleted conversation restored by the flush that followed it, and a migration that aborted partway leaving what it had already written invisible. Storage moved to IndexedDB with a localStorage migration that keeps each conversation's own timestamp, and every consumer of the conversation list now shares one read rather than opening its own.

Tests

  • tests/e2e/deterministic/ — 73 Playwright specs against FunctionModel fixtures, no API keys
  • tests/headless/ — 77 Vitest tests exercising the wire protocol via TestChat, plus the parsing and usage helpers
  • tests/e2e/offline/ — the built single-file artifact rendered with every non-loopback request aborted, so an asset that stops being inlined fails the run

New fixtures: slow, reasoning, reasoning-tool, failure, approval-error, repeated-approval, two-step, large-output, run-code, total-only-usage, sourced-tools, interleaved.

Popover was pulled from the radix-ui umbrella rather than the granular package the other primitives use, which shipped a second copy of react-dismissable-layer, react-focus-scope and react-primitive; the offline artifact is ~400kB smaller without it. bun.lock is regenerated alongside pnpm-lock.yaml.

AI Usage

  • This refactor was completed through a multi-day task with Claude Opus 5 (MAX effort) and Codex/Codex Security reviewing each commit.

Fixes #10

claude added 30 commits August 11, 2026 00:33
The UI was functional but rudimentary: an empty screen with a floating
text box, assistant answers boxed into an 80%-wide grey bubble, no sense
of where you were, and no way to stop a run once it started.

App shell
- Add a header bar carrying the sidebar toggle, the conversation title
  (also reflected in the tab title), new chat, and the theme toggle.
- Lay the chat out as a proper column inside `SidebarInset`, with a fade
  above the sticky composer.

Conversation
- Add a welcome screen with suggested starting points for empty chats.
- Render an assistant turn as one avatar-gutter column so reasoning,
  tool cards and prose read as a single sequence; user turns stay as
  quiet right-aligned bubbles.
- Replace the bare spinner with a labelled, screen-reader-announced
  thinking indicator, and the raw error line with a recoverable card.
- Message actions now appear on hover, carry tooltips, and copy confirms.

Composer
- Extract ChatComposer; show a real stop control while a run is in
  flight (previously the button was disabled, so a run could not be
  stopped), badge the enabled tool count, and hint the Enter binding.

Tool cards
- New header showing a one-line preview of the call's arguments, so a
  run of tool calls is readable without expanding each card.

Sidebar
- Group conversations by recency with relative times, search once the
  list grows, mark forks, and give delete an accessible name.

Theme
- Harmonise the accent and sidebar hues with the brand green, thin the
  scrollbar, and honour prefers-reduced-motion.

Tests
- Add deterministic specs for the welcome screen, header and composer
  (backed by a new slow FunctionModel), unit tests for the date and
  tool-summary helpers, and retarget the lifecycle specs off CSS class
  selectors onto the delete button's accessible name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
The avatar pulses while its turn is streaming, so a long stretch of tool
calls with no prose still reads as working rather than stuck, and the
conversation region gets an accessible name.

Adds a `reasoning` FunctionModel to the test server and a deterministic
spec for the thinking block, which had no coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
…idebar

Follows the shape the reference chat UIs converge on.

Empty state
- The greeting, composer and starting points now open as one centred
  column instead of a greeting stranded above a floor-mounted input.
  Starting points became chips, since the composer took their space.

Conversations
- Rename and pin from a per-row menu, replacing the bare delete icon.
  Titles were derived from the opening message and stopped describing a
  thread the moment it moved on; pinned threads sit above the date
  buckets. Delete now names the chat it is about to remove.

Composer
- Active builtin tools show as removable pills, so what the next message
  will run with is visible without opening a menu.
- The box grows from one line rather than a fixed 4rem floor, and the
  model select holds its footprint while /api/configure is in flight.

Discoverability
- Keyboard shortcuts dialog, from the header or Cmd/Ctrl+/.

Tests
- Specs for rename and pin; lifecycle specs retargeted onto the row menu.
- tool-grouping now waits for the run to finish before asserting the
  group collapsed — it was racing the auto-collapse and flaking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
…tion

If /api/configure could not be reached the composer sat there with an
empty model select and sending failed silently. It now says so and
offers a retry, and the model skeleton stops pulsing once the request
settles either way.

The builtin tool toggles had no coverage at all — the test server
advertised none. It now advertises `web_search` on the `text` model,
with specs for the pill and for the `builtinTools` field on the wire.
The toggles also gained accessible names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Token usage
- Assistant replies carry their token counts, and the composer shows a
  running total for the conversation with input/output/cache/requests
  behind a click. Exact figures come from `UIMessage.metadata.usage`;
  agents that report nothing get a locally-derived estimate, marked `~`
  rather than dressed up as exact.
- The test server now attaches real usage from the run, so the specs
  exercise the same contract a production backend would use. It doubles
  as the reference implementation, since `Agent.to_web()` hardcodes the
  adapter and reports no usage today.

Tools
- Builtin tools became toggle chips in the composer instead of switches
  behind a dropdown plus a separate row of pills. Which tools the next
  message may use is a decision people revisit constantly, so it belongs
  in the open — one click instead of three, and the enabled set stays
  visible. Past three tools the remainder moves to an overflow menu.

Effort
- The effort dropdown became a meter: five rising bars lit to the current
  level, so the setting reads as an intensity before the control is even
  opened, and each level is described rather than just named.

Also adds the shadcn popover primitive (wired to the per-component radix
package the rest of the folder uses), unit tests for the usage helpers,
and E2E coverage for all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
…reens

With tool chips and the effort meter in the toolbar, a phone-width
viewport pushed the send button clean off the edge and overlapped the
model select. The run controls now scroll sideways with the send button
pinned, and the effort meter drops to bars only below `sm` — the
accessible name still spells the level out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
…reen

Reasoning
- Collapsed, it is one muted line — a lightbulb, how long the model
  thought, a chevron — instead of a bordered card competing with the
  answer under it. It opens while the model is thinking and folds itself
  away once the answer lands.
- Expanded, the thinking reads as the steps the model took: a marker on
  a rail per step, the heading the model gave it, and muted body text.
  Streams with no headings fall back to one step per paragraph, so an
  unstructured trace still gets the same treatment instead of arriving
  as a wall of text.
- Replaces the vendored `Reasoning` element, whose trigger markup and
  duration state were not reachable from outside it.

Message helpers
- Copy, regenerate, edit and the per-reply token counts are now small
  icons that stay on screen rather than 36px buttons that appear on
  hover. Hover does not exist on touch, and a permanently quiet row
  costs less attention than one that materialises under the pointer.

The reasoning fixture now emits headed steps, the way reasoning models
summarise their thinking, so the trace timeline is exercised end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Data integrity
- Switching conversations mid-run left the stream writing: the SDK pushed
  the half-finished reply into whichever conversation was now open, and
  the throttled save persisted it there. Navigation now stops the run.
- The persist effect keyed off the current conversation while the
  throttled snapshot still held the previous one's messages, writing A's
  history under B's id (and the parent's over a fresh fork). Snapshots
  now carry the conversation they belong to.
- A conversation whose stored messages failed to load, or had none, kept
  the previous one's messages mounted under its title.
- Sending after a stopped tool call left the orphaned call in history,
  which pydantic-ai rejects — `handleContinue`'s cleanup, which the plain
  send path was missing.

Crashes and dead ends
- An unmapped tool state threw mid-render and, with no error boundary,
  blanked the app; it degrades to a neutral row.
- An empty model list from /api/configure threw on `models[0].id`.
- A blank first message rendered every surface with an empty title.
- The sidebar search kept filtering after its input unmounted, hiding
  every remaining conversation with no way to clear it.
- Copy threw outside a secure context, past its own catch.
- A tool enabled on one model kept being sent after switching to a model
  that does not offer it, with no chip left to switch it off.
- An effort level that no longer exists displayed as Medium while the
  stale value kept going out on the wire; it is clamped on read now.
- The effort popover never closed on select and declared a radiogroup it
  did not implement — it closes, and arrows move between levels.
- Reasoning split on blank lines tore fenced code blocks in half, and a
  line with two bold spans became one heading full of asterisks.

Behaviour the sidebar promised
- `timestamp` was written once at creation, so a thread used minutes ago
  read "20d ago" and sorted below untouched ones. The store now records
  last activity, and emits its own change event rather than trusting
  every caller to remember.

Also: token estimate computed only when it is the number shown, correct
carry at 1000k, memoised reasoning parse and tool summaries, one theme
toggle, an h1 on every screen, status labels kept for screen readers,
`radix-ui` per the registry instead of a local edit to a vendored file,
the prohibited page.route spec removed, and regression tests for the
conversation-switching, title, formatting and parsing bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Tool cards
- The expanded card is now labelled bands — Arguments, Result, Error —
  each with its own copy action, instead of one undifferentiated dump.
  A payload is the thing people take somewhere else, so copying it is
  where the payload is.
- The left edge carries the state (running, awaiting approval, failed),
  so a long run can be scanned for the card that needs attention.

Failed tool calls
- The message reads in the card it belongs to. It used to sit behind a
  "View Error" button that opened a modal — two clicks and a context
  switch to read the one thing explaining why the run went that way.
- The collapsed row leads with the reason rather than the arguments,
  which are still one click below.

The approval gate
- This is the one point where the run stops and the answer is the
  user's, so it no longer looks like the rest of the card: an amber
  band that names the tool, says plainly that nothing has run yet, and
  puts the two outcomes side by side. Once decided it shrinks to a
  one-line record.

Failed runs
- The failure is separated from the recovery: one plain line for what
  went wrong, the provider's raw text behind a Details disclosure, a
  copy action, and Retry/Continue where the eye lands last. The card
  aligns with the reply it belongs to rather than the page edge.

Adds a `failure` FunctionModel that fails a run with a realistic
multi-line provider error, and specs for the inline tool error, the
richer approval prompt, and both halves of the failure card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
…se the dark sidebar

The assistant actions row overflowed its own turn by 20px and slid under
whatever followed — visible as the failure card sitting on top of the
copy icon. The vendored `Response` carries `size-full`, which stretched
inside the turn's flex column; overriding the height keeps the row where
it belongs. Measured before and after: the row now ends 20px above the
card instead of 12px inside it.

Copy actions for Arguments, Result and the error text moved from a row
above the label into the top-right corner of the box they copy, the way
a code block's does — next to the content rather than floating above it,
where which block it belonged to was ambiguous. The failure card's raw
details got the same treatment.

The dark sidebar carried a green cast: at that surface area even 0.014
chroma reads as a tint. It is a near-neutral panel now; the light
sidebar and the brand-tinted active row are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Six defects found reviewing the UI rework:

- `saveMessages` stamped every write as activity, so the localStorage
  migration rewrote each restored conversation's timestamp to the
  migration time and collapsed the sidebar into one "Just now" bucket.
  It now takes `{ touch }`, and the migration opts out.
- A failed history load left `loadedConversationId` null, and the save
  effect skips null ids — everything typed afterwards was silently
  discarded. The catch now claims the id so writes resume.
- Enter in the composer calls `requestSubmit()` even mid-run, and the
  orphaned-tool-part branch would then truncate the turn the SDK is
  streaming into. `handleSubmit` returns early while busy.
- A long single-line provider error was ellipsized with no Details
  toggle, putting the rest of the text out of reach; the toggle now
  keys off whether anything was actually elided.
- Fork siblings ordered by `timestamp`, which now moves with activity.
  Entries carry `createdAt` for orderings that must stay stable.
- Renaming wrote back the entry captured when the row menu opened, so a
  rename during an active run reverted that conversation's position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Reading a conversation is no longer a write. The throttled save fired on the
echo of a load, so merely opening a thread rewrote its whole history back to
IndexedDB and stamped it as activity — it jumped out of "Older" to the top of
the sidebar without a word being sent. The load's array is now recognised and
skipped.

Leaving a conversation flushes first. `useThrottle` drops its pending write
whenever the value changes, and clearing the messages on a switch is such a
change, so a tail streamed within 500ms of navigating away was lost along with
the reply's usage metadata.

Also:

- `touchConversation` read and wrote in two transactions; a rename or pin
  landing in between was read before the change and written back after it,
  reverting it. Both now happen in one `readwrite` transaction.
- A failed activity stamp reported itself as "your browser storage may be
  full" and rejected the save, though the history had already committed.
- Retrying, or modifying the first message, empties the transcript for a frame
  on its way to re-sending it, which threw up the welcome screen and remounted
  the composer under the caret.
- Sending a message with a tool call still unanswered drops the whole trailing
  turn, which is easy to trigger by typing instead of answering an approval
  prompt. It now says so.
- Cmd/Ctrl+Shift+O fired from under an open dialog, navigating away and
  leaving the dialog mounted over a conversation it no longer belonged to.
- The header resolved its title before the conversation store had been read,
  flashing "Untitled chat" in the tab and the bar on every reload.
- `custom-scrollbar` only styled a child scroller, so the sidebar — which is
  its own scroller — never got the thin themed bar.
- The Mac spelling of the new-chat binding rendered as ⌘ShiftO, not ⇧⌘O.
- An approved tool that then failed lost the record of who let it through.
- A tool card whose input was still streaming showed an empty Arguments band
  with a Copy button that put the word "undefined" on the clipboard.
- The token estimate re-stringified every historical tool payload on every
  streamed chunk; per-message counts are now cached by parts identity.
- A conversation where only some replies report usage was summed and shown as
  an exact total, undercounting it. It reads as approximate now.
- Relative times never refreshed, so a row said "Just now" for hours and kept
  yesterday's heading past midnight.
- Popover came from the `radix-ui` umbrella rather than the granular package
  every other primitive here uses, which shipped a second copy of
  react-dismissable-layer, react-focus-scope and react-primitive. The offline
  artifact drops ~400kB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Driving the app through its states turned up a rendering defect that has
been there the whole time: every reply containing display math showed the
equation three times over. Streamdown typesets `$$…$$` with KaTeX, whose
HTML output is entirely class-driven, and then sanitises the result with
GitHub's allowlist, which permits `className` on `code` and nothing else.
Stripped of its classes, the stylesheet's hooks are gone — so the MathML
copy and the LaTeX source it exists to hide render as text beside the
visual one, and `$$E = mc^2$$` read as "E=mc2E = mc^2E=mc2".

It now typesets to MathML and lets those elements through sanitisation, so
the browser lays the equation out natively and nothing depends on classes
surviving. Allowing class names on `span` would have fixed the HTML path
too, but this renders model output — a reply could then style itself into
a full-page overlay out of the app's own utility classes. MathML elements
carry no such power, which is what makes this the narrow fix. Rendering
goes through a `Markdown` wrapper so no call site can fall back to the
bare primitive and quietly get garbled math again.

Also:

- A failed run left an X on the send button, which reads as "cancel" on a
  control that still sends. The failure has a card of its own with Retry
  and Continue on it; the composer keeps the paper plane.
- Sending was possible with no model configured — the button is disabled
  now, and Enter, which bypasses it, says why.
- "Hide this tool" sat before the card's own disclosure control in the
  DOM, so the first key a keyboard user pressed on a tool card made it
  disappear.
- The flush on navigation wrote back conversations that were only read,
  putting them at the top of the sidebar for having been opened.
- Tool inputs were serialised for collapsed cards, so reopening a
  conversation walked every payload it holds.
- `touchConversation` now freezes `createdAt` before moving `timestamp`
  off it: entries written before that field existed have only that one
  moment left where their creation time is still readable, and fork
  ordering reads it.
- The localStorage migration dispatched a store-wide refresh per entry,
  costing every subscriber a full re-read as the store grew.

The markdown fixture gained a link and inline code, and a new spec covers
math typesetting plus the attributes sanitisation has to let through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Deleting the conversation you are looking at navigates away, and leaving a
conversation flushes what was on screen — so the flush landed *after* the
delete and wrote the history straight back. The sidebar row was gone, but
the URL still served the whole conversation, which is not what "this
action cannot be undone" promised. A delete now suppresses later writes
for that id; ids are nanoids and never reused, so nothing legitimate is
blocked. The regression test fails without the guard.

A history read that is still in flight when the next navigation starts no
longer installs its result. IndexedDB reads can finish out of order, so a
large history left behind could land on top of the small one now on
screen — the previous conversation's messages under this one's title, and
anything typed next saved against the stale id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
- A backend may report only `totalTokens`; the breakdown is optional in the
  shape the UI reads. Rendering it anyway put "0 in, 0 out" on a reply that
  cost real tokens, so the total stands in when no split was reported. The
  test server grows a `total-only-usage` model that reports that shape, and
  a spec covers it.
- The deletion tombstone was recorded before the delete ran, so a failed
  delete left the conversation silently unwritable for the rest of the
  session — every later save for that id returning quietly. It is recorded
  in the transaction's `oncomplete` now, which is still ahead of the
  navigation whose flush it exists to beat.
- `bun.lock` still declared `radix-ui` and was missing
  `@radix-ui/react-popover` and `rehype-sanitize`, so a frozen Bun install
  could not reproduce this tree. Regenerated with `--lockfile-only`, and
  `rehype-sanitize` now carries a caret like every other dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
A turn that thought and called four tools stacked five cards above the
reply, so the answer started below the fold and the shape of every turn
was whatever the agent happened to do. Its thinking and tool calls now
collect into a single foldable block, the way chat.qwen.ai condenses its
activity.

The difference is what opening it gives you. Qwen summarises what it did
in prose; here every step keeps the card it always had, with its
arguments, its result and its copy actions. The detail is one click away
rather than summarised out of existence, and the folded line still names
the tools that ran — "Worked for 3s · get_weather, calculate" — so the
common case needs no click at all.

It opens itself while the work is happening, because that is when
watching it is worth something, and folds up once the answer lands.
Anything that needs a person holds it open instead: a pending approval
reads "Waiting for your approval" in amber, a failed call "Ran into a
problem", and neither can be folded away by the timer. Opening it by hand
also cancels the auto-collapse, so it cannot shut under someone reading
it.

A turn that only thinks is left alone — the reasoning line is already one
folded row, and wrapping it would stack two rows saying the same thing.

Specs reach tool cards through a new `showActivity` helper, which waits
for the fold before opening it rather than racing the timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
…ame edges

Measured every edge in the conversation column rather than eyeballing it,
at 1440 and on emulated iPhone/Pixel. Three things were off:

- The composer was 16px wider on each side than the message column: its
  padding sat outside its `max-w-3xl` box while `ConversationContent`'s sat
  inside, so on a wide screen the box you type into overhung the column it
  lands in. Both now carry the same `px-4` inside the same max-width box,
  and the banner that shares that space does too. Composer and messages
  land on 480..1216 at 1440, and 16..374 on an iPhone 13.
- The message action icons were inset 7px from the text above them — a
  `size-7` button around a `size-3.5` icon pads the glyph by exactly that.
  Both rows are pulled back by 7px, so the copy glyph sits on the prose's
  left edge and the user row's last glyph on the bubble's right edge.
- The composer moved when the first message was sent: the welcome screen
  padded it a second time from its own container. That padding moved onto
  the welcome screen's own children, so the composer occupies the same box
  before and after the first send.

One residual, stated rather than hidden: in a desktop window narrow enough
that `max-w-3xl` stops binding *and* the platform draws classic
scrollbars, the scrollbar's gutter shifts the message column by 10px and
the composer, which is outside the scroller, cannot follow. It is exact on
every touch device and at any width where the max-width binds.

Also fixes five defects raised in review, which touch the same files:

- Sending was possible while a conversation's history was still being
  read, so the request went out without it and the read then landed on
  top of the reply. Submission waits for the load.
- A deferred send (fork, retry, modify) carried no conversation id, so
  navigating away before its read finished delivered it into whichever
  conversation loaded next. Each one now names its target.
- `TurnActivity` paired deduplicated tool names against per-call states by
  index, so a repeated call reported the wrong live tool.
- The delete tombstone was recorded on commit, leaving a window where a
  save started in between could queue behind the delete and write the
  history back. It is recorded up front and rolled back if the delete
  fails.
- `ReasoningBlock` could fold itself shut over a reader who reopened it
  during the one-second grace period, which `TurnActivity` already
  guarded against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
…entries

On a phone the sidebar is a sheet covering the whole screen, and picking a
conversation only changed the URL — so the drawer stayed up over the chat
it had just been used to choose. `Sidebar` hides the sheet's own close
button, which left tapping the overlay as the undiscoverable way out. An
in-app navigation now closes it; a modified click still opens a new tab
and leaves the drawer alone.

New chat pushed a history entry even when already on one, so pressing it
twice buried the previous conversation behind identical `/` entries and
Back appeared not to work. Both the header button and the sidebar link
skip the push when the destination is where we already are.

The active row is no longer `pointer-events-none`. That existed to stop
re-navigation, which the guard above now handles, and it meant tapping the
conversation you are already in did nothing at all on a phone — including
not dismissing the drawer in front of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Seventeen shots covering both themes and every feature the branch adds,
for the pull request description to link.

They are captured against the deterministic test server rather than a live
provider, so the replies, tool calls, token counts and failures in them are
the same `FunctionModel` fixtures the E2E suite runs — the README next to
them lists which fixture produced which shot, and how to retake them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
A failed load marked the conversation loaded so the save effect would arm.
That was the wrong trade: `messages` is empty after a failed read, so the
next reply was saved over the history still sitting in storage — a
transient read error turned into permanent data loss.

The conversation now stays unloaded, which the composer already refuses to
send into, and a banner says why and offers the read again. The regression
test provokes it by making the messages store throw, and checks the
history comes back untouched after a retry. It keeps throwing until the
test says otherwise rather than failing once, because React's development
StrictMode runs the load effect twice and a one-shot failure was papered
over by the second attempt.

Also widens the MathML allowlist. Rendering a spread of TeX through KaTeX
and diffing the attributes it emits against the schema turned up three it
strips: `columnspacing` (every matrix, `aligned` block and `array` carries
it, and without it the columns fall back to default spacing), `notation`
(the box `\boxed` draws) and `xmlns`. The lowercase names already there do
survive sanitisation — confirmed in the rendered DOM — so only the missing
ones were added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
The SDK marks the boundary between model steps with a `step-start` part.
`Part` renders nothing for it, but the grouping pass treated it as content
and flushed the run — so an agent that calls a tool, reads the result and
calls another got a foldable block per step, stacked above the reply.
Exactly the shape the single block exists to prevent, and invisible to the
suite because no fixture ran a real tool loop.

A `two-step` fixture now does: call, then a second call that depends on it,
then the answer. Without the fix its spec sees two blocks.

Also, from the same review round:

- The activity block's auto-collapse timer re-checks the manual toggle when
  it fires, not just before it is scheduled. A second is long enough for
  someone to open the block, and the timer then shut it under them — the
  reasoning block already guarded this.
- A deferred send is abandoned when navigation leaves the conversation it
  was queued for. Scoping it stopped the wrong delivery, but the stale ref
  stayed behind: it suppressed the welcome screen on the next empty chat,
  and would have fired the old prompt if its target were reopened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
A `ForkNavigation` is mounted under every user turn, and each one opened its
own subscription to the conversation store. An active run stamps activity
every 30 seconds, so a long chat answered each of those events with one
full `getAll()` and one state update *per turn* — dozens of reads and
dozens of renders while streaming. The hook now backs onto a single shared
read through `useSyncExternalStore`: one listener, one query, every
consumer on the same snapshot.

A refresh that fails no longer empties the list. It replaced the cache with
`[]`, so a transient read error on any change event cleared the sidebar and
turned the header into "Untitled chat" until something else happened to
fire. The last good list is kept.

Renaming or pinning now goes through `patchConversation`, which reads and
writes in one transaction. Both used to write the whole entry back from a
snapshot the sidebar had rendered from — so a rename racing a run's
activity stamp restored the old timestamp and dropped an active
conversation back down the list. Only the changed fields travel now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
A turn that reasons before calling anything starts with no tools, and the
activity block used to skip wrapping in that case — so the reasoning line
rendered on its own, and the block appeared only once a tool arrived. That
reparented the reasoning, remounting it, and both timers restarted from
zero: a turn that thought for thirty seconds and then ran a tool for two
reported "Worked for 2s", with the thinking beneath it reduced to
"Thinking completed".

The block is now mounted from the first thinking token and stays put. With
no tools to group it renders nothing of its own — same markup, no rail, no
line — so a turn that only thinks looks exactly as it did, and nothing
below it is torn down when the first call lands.

A `reasoning-tool` fixture thinks for ~2.7s before calling anything. Its
spec asserts both the block and the reasoning fold report at least two
seconds; without the fix the block reports one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Deleting the conversation you are viewing pushed `/` on top of it, so the
deleted id sat one Back press away. Going back opened it as an empty chat
that looked usable — and the guard keeping a deleted conversation deleted
then discarded every message typed into it, silently, until a reload
showed nothing had been saved. The navigation replaces that history entry
now, and a deleted id reached any other way redirects to a new chat rather
than pretending to be one.

The activity block also under-reported a turn that paused for approval.
Streaming stops while the decision is pending and starts again on the
continuation, and each stop overwrote the duration rather than adding to
it — twenty seconds of work before an approval and two after read as
"Worked for 2s". Intervals accumulate now; the wait for the human is still
excluded, since the clock only runs between a start and the next stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Two defects from review of 6756c0c.

The delete confirmation had a dialog-wide Enter handler that called
handleConfirmDelete regardless of which control had focus. Focus opens on
Cancel, so the first keystroke on the freshly opened dialog permanently
deleted the conversation from the safe control. The handler is gone; Enter
is left to the focused button's native behavior, and focus is placed on
Cancel explicitly so reordering the footer cannot put an irreversible
action under the opening keystroke.

Reasoning-step splitting toggled its fence flag on any line that looked
like a fence, ignoring the delimiter character and width. A ````-fenced
block closed at the ``` example inside it, and the blank lines that
followed tore the code across several steps. The opening marker is now
retained and only a compatible closer (same character, width >= opening)
ends the block.

Covered by two E2E specs for the dialog's keyboard paths and two unit
tests for nested and mismatched fences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Two P2 findings from review of b4f231d.

The model effect only ever ran on the first configuration, so a refetch
that came back without the selected model — or with no models at all —
left the id in place. The select is hidden at that point, so there was no
way to correct it, and the send button stayed enabled because the id was
still a non-empty string: the next message went out under a model the
backend no longer advertises. The selection is now resolved against every
configuration, keeping a deliberate choice that is still on offer and
falling back to the first entry (or none) when it is not.

Builtin tools past the inline limit were plain menu items whose on/off
state lived in an unlabelled check icon, so a screen reader read an
enabled tool exactly like a disabled one. They are checkbox items now,
carrying role and aria-checked.

The test server advertises builtin tools per model instead of all-or-
nothing, so a spec picks the toolbar it wants by picking a model:
`markdown` now declares four tools and overflows, while `text` keeps its
single chip and every other spec's composer is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Two P2 findings from review of 08dcdc8.

The activity grouping skipped `step-start` by name, so any other part the
message column draws nothing for still ended the run. Sources are
collected into the strip above the turn, and a provider that cites them
emits them between the calls they came from — which split a tool loop
into one foldable block per invisible marker. The pass now skips whatever
`Part` renders nothing for, which is the property that actually matters.

The localStorage migration suppresses its per-entry `conversations-changed`
events so N conversations do not cost N growing re-reads, and notified
once at the end. A batch that threw partway never reached that line, so
everything already written stayed invisible to any reader that had
already read the store — until some later write happened to notify. The
notification now also fires on the way out of a failed migration.

The test server can interleave a `source-url` chunk between two tool
calls (`sourced-tools`), and both fixes have regression specs; each was
confirmed to fail with its fix reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Three P2 findings from review of 6a1fae2.

A turn that works, answers, then works again renders two activity blocks,
and both were handed the message's streaming flag. The first sat there
spinning "Working" and accumulating duration it was no longer spending.
The parts are now laid out before anything renders, so a block can tell
whether anything follows it: only the last one of a streaming reply is
live.

A fence closer was matched on delimiter and width alone, so a line like
```not-a-close inside a fenced block ended it and the next blank line
split the code. An opening fence may carry an info string; a closing one
may not.

`fetch` resolves for a 4xx or 5xx as happily as for a 200, so an error
body from /api/configure was cast to a configuration and stored as a
successful result: the banner offering a retry never appeared, and the
first read of `models` threw during render. Status and shape are both
checked now, in `lib/config.ts` where they can be tested directly.

New fixture `interleaved` covers the two-block turn. Each fix was
confirmed to fail its test when reverted — the streaming one needed a
single sample taken while the run is live, since a retrying assertion
passes either way once the answer lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Two P2 findings from review of 2f27039.

The fallback token estimate counted each message once, while a reported
total sums per-request usage and every request re-sends the history. The
same widget therefore showed a long conversation as a fraction of what
the identical conversation reports when a backend does report. Each turn
is now charged for the prefix it was sent plus what it produced. Still an
approximation and still labelled one: it cannot see the system prompt,
and a turn that loops through tools is several requests rather than the
one counted here.

Reasoning steps only read a paragraph's first line as a title, but
Markdown does not require a blank line before a heading — models run
their sections together, and everything after the first heading stayed
buried in its body. Paragraphs are now split at every heading outside a
fenced block, with the fence tracking shared between the two passes so
they cannot disagree about where the code is.

A third finding, about the welcome suggestion's caret, does not
reproduce: committing the new value resets a textarea's selection to the
end of it, so the caret lands correctly either way. Measured at 12 of 12
under both implementations. Left the code as it was and added the spec
that pins the behavior down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
claude added 3 commits August 12, 2026 06:44
They were only needed to illustrate the pull request, and the images in
its description are pinned to the commit that added them, so they keep
rendering with the files gone from the branch tip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Four P2 findings from review of 4b36b5b.

A URL with no conversation behind it — a bookmark to a chat cleared from
this browser, or a mistyped id — opened as an empty chat and accepted
messages, but nothing created an entry for it: the reply was stored under
an id the sidebar had never heard of and vanished as soon as it was
navigated away from. Sending there now inserts an entry, via a new
insert-only `ensureConversationEntry` so a conversation that does exist
cannot lose its title, pin or place in the list.

A partially-reported conversation summed only the turns that reported, so
opening a long history and getting one reported reply showed that reply's
tokens as the whole conversation's cost. The unreported turns are
estimated and added on, and the popover breaks the total into what was
reported and what was estimated.

Shift-click means "open a window" the way ctrl/cmd means "open a tab",
and the interception guard only knew about the latter, so it navigated
the tab the user was keeping.

Answering an approval sends the run back to `submitted` with the
assistant turn still on screen and already showing its own live activity.
The standalone placeholder added a second avatar and a second "Thinking"
underneath it until the response arrived; it now renders only when there
is no assistant turn being continued.

The `approval-slow` fixture delays in `chat()` rather than inside the
stream: the adapter opens the stream immediately, which moves the client
to `streaming` before a slow model has said anything, and `submitted` is
the state the placeholder lives in. All four specs were confirmed to fail
with their fix reverted — the approval one only after that change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
Four cleanup reviews over the whole diff; this is what they agreed on.

The streaming fold — open while content arrives, close once it lands,
never under someone who opened it — was implemented twice, in the
reasoning trace and in the activity block around it. The copies had
already drifted: only one accumulated across intervals, so a reasoning
trace interrupted by an approval reported its last leg alone. Now one
`useStreamingDisclosure` hook, with the accumulating behaviour.

`chat-db` had three copies of the same read-modify-write transaction
body, differing only in the decision inside. They are three wrappers
around one `updateConversation` now, which puts the tombstone guard and
the notify-on-write rule in one place and makes "does this one toast?"
an argument rather than a hand-copied difference. The activity stamp is
also cached in memory: a streaming reply opened a transaction twice a
second to discover the stamp was too fresh to rewrite, ~118 of every 120
writing nothing.

The queue-a-deferred-send dance was copy-pasted across four call sites,
each restating the order the send effect depends on; it is one
`queueSend` now. The queued record carried `model` and `builtinTools`
that nothing ever read — the transport takes them from their refs at
request time — so they are gone.

Two error banners were the same markup with the same class string, and
`Chat.tsx` can render both at once, so drift showed up stacked on one
screen. Both are `RetryBanner` now, which also absorbs the wrapper that
was re-stating the width the banner already had.

Smaller: tool payloads had a second serializer beside the one in
`tool-output-code`, and the output copy text was rebuilt on every render
of every open card — every streamed chunk, for a string only the copy
button reads. The activity grouping re-derived tool names it had already
computed into `descriptors`, several times per part per chunk. Usage
walked the messages twice, parsing every reply's metadata a second time.
`EffortMeter` laundered `value` through an index round-trip.

No behaviour changes: same suites, same results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCV5mNp5wYs58fEfnvDz7i
@gaby

gaby commented Aug 12, 2026

Copy link
Copy Markdown
Author

Ping @DouweM @dsfaccini

Carries forward the one thing this branch did not already cover from
 pydantic#37, now closed as superseded: the on-screen keyboard. `svh` sizes the
shell to the small viewport, but the keyboard does not resize that
viewport without `interactive-widget=resizes-content`, so the composer
was still covered while typing.

- useConversations: re-read on resubscribe. Dropping the last subscriber
  removes the `conversations-changed` listener but left `loaded` true,
  so a later remount served whatever the gap had made stale.
- chat-db: handle `tx.onabort`. IDB aborts a transaction when a request
  handler throws and fires `abort` alone, with no `error` — the promise
  never settled, so the caller's `catch` never ran and the await hung.
- config: validate the elements, not just the arrays. A well-formed
  envelope full of junk reached the model select as blank options
  instead of putting the retry banner in front of it.
- The delete confirmation keeps its entry through the close animation;
  clearing it alongside `open` blanked the name, so the last thing seen
  of a delete was a dialog asking about "Untitled chat".
- Mobile-layout E2E at 390x844: the sidebar is reachable from the header,
  closes on a selection, and the composer stays inside the viewport. The
  rest of the suite runs at desktop width, which is how the shell below
  `md` broke unnoticed before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dsfaccini

Copy link
Copy Markdown
Contributor

This comment was posted by Claude Code using claude-opus-5 on behalf of David.

Reviewed, and pushed the findings to the branch as b91e9b1 rather than leaving them as a list. #37 (mobile sidebar + viewport units) is closed as superseded by this PR — the one thing it fixed that this branch did not is carried over in that commit.

Carried over from #37. svh sizes the shell to the small viewport, but the on-screen keyboard does not resize that viewport without interactive-widget=resizes-content in the meta tag, so the composer was still covered while typing. Added to index.html.

Correctness

  • useConversations.ts — when the last subscriber unmounts, the cleanup removes the conversations-changed listener but leaves state.loaded true. A later remount then hits if (!state.loaded) refresh() and serves the snapshot from before the gap, permanently. Not reachable today, since AppSidebar and AppHeader are always mounted, but it is a trap for the first consumer that mounts conditionally. Now re-reads whenever the first subscriber arrives.
  • chat-db.ts updateConversation — IDB aborts a transaction when a request handler throws, and fires abort alone with no error event. With only tx.onerror wired, the promise never settled: patchConversation would hang and the caller's catch never run. decide cannot throw as written, so this is latent, but a hung await is an expensive way to find that out. tx.onabort handled here and in deleteConversation, where it also rolls back the deletedConversations guard.
  • config.ts isRemoteConfig checked array-ness only, so {models: [{id: 'a'}], builtinTools: []} passed validation and reached the model select as options with no name — indistinguishable from each other, and sending one produces a request the backend rejects. Rejecting the response instead puts the retry banner this PR added in front of it. Elements are now checked; three cases added to tests/headless/config.test.ts.

Polish

  • The delete confirmation cleared conversationToDelete alongside open, which blanked the name mid-animation — the last thing seen of a delete was a dialog asking about "Untitled chat". The entry now outlives the dialog.

Coverage

  • Every spec ran at desktop width, so the shell below md — rebuilt in this PR around a header and an off-canvas sheet — had no coverage at all. tests/e2e/deterministic/mobile-layout.spec.ts runs at 390x844 and asserts the trigger is reachable outside the drawer, that the sheet closes on a selection, and that the composer stays inside the viewport.

Verified on the pushed head: typecheck, typecheck:test, lint, 79 headless tests, 78 deterministic E2E and the offline artifact suite all pass.

Two things left alone deliberately. useMinuteTick re-renders the whole conversation list every 60s for the life of the tab even when the sidebar is collapsed — the tradeoff is documented in the code and gating it costs more than it saves. And src/lib/markdown-plugins.ts allowing MathML through the sanitiser while withholding class on span is exactly right: a reply that could name utility classes could style itself into a full-page overlay. Worth keeping that reasoning where it is.

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.

Add copy icons to all message bubbles

3 participants