diff --git a/apps/web/src/features/conversation/components/Status.tsx b/apps/web/src/features/conversation/components/Status.tsx
index 1d3472e..cd285cd 100644
--- a/apps/web/src/features/conversation/components/Status.tsx
+++ b/apps/web/src/features/conversation/components/Status.tsx
@@ -1,7 +1,8 @@
import type { ReactNode } from 'react'
-import { AlertIcon } from '../../../shared/icons.tsx'
+import { AlertIcon, StopIcon } from '../../../shared/icons.tsx'
+import { Badge } from '../../../shared/ui/Badge.tsx'
-/** A quiet line about a reply, such as that it stopped or waits its turn. */
+/** A quiet line about a reply, such as that it waits its turn or lost its stream. */
export const statusLine = 'text-muted text-ui'
interface Props {
@@ -31,7 +32,15 @@ export function Status(props: Props) {
)
case 'cancelled':
- return
Stopped before it finished.
+ // A small amber badge with the Stop button's square, tucked under where the reply broke off.
+ return (
+
+
+
+ Stopped
+
+
+ )
default:
return null
}
diff --git a/apps/web/src/shared/ui/Badge.tsx b/apps/web/src/shared/ui/Badge.tsx
index 1101ba6..0a366de 100644
--- a/apps/web/src/shared/ui/Badge.tsx
+++ b/apps/web/src/shared/ui/Badge.tsx
@@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
import { cn } from '../cn.ts'
interface BadgeProps {
- /** Green for what is live or good, red for what failed, grey otherwise. */
- tone?: 'good' | 'danger'
+ /** Green for what is live or good, amber for what needs noticing, red for what failed, grey otherwise. */
+ tone?: 'good' | 'warning' | 'danger'
title?: string
className?: string
children: ReactNode
@@ -17,6 +17,7 @@ export function Badge(props: BadgeProps) {
className={cn(
'inline-flex items-center whitespace-nowrap rounded-badge bg-hover px-1.75 py-px font-semibold text-caption text-muted',
props.tone === 'good' && 'bg-accent-soft text-accent-strong',
+ props.tone === 'warning' && 'bg-highlight text-highlight-ink',
props.tone === 'danger' && 'bg-danger-soft text-danger',
props.className,
)}
diff --git a/apps/web/tests/features/conversation/components/MessageView.test.tsx b/apps/web/tests/features/conversation/components/MessageView.test.tsx
index 50fcda8..bf5d626 100644
--- a/apps/web/tests/features/conversation/components/MessageView.test.tsx
+++ b/apps/web/tests/features/conversation/components/MessageView.test.tsx
@@ -423,7 +423,7 @@ describe('MessageView', () => {
}}
/>,
)
- expect(screen.getByText('Stopped before it finished.')).toBeInTheDocument()
+ expect(screen.getByText('Stopped')).toBeInTheDocument()
})
it('shows images inline and files by name', () => {
diff --git a/specs/web-ui.md b/specs/web-ui.md
index d563b72..e14726a 100644
--- a/specs/web-ui.md
+++ b/specs/web-ui.md
@@ -88,7 +88,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query
- Sources are shown after the parts as a numbered list under their count, such as "3 sources", so a reader sees at a glance what the reply drew on. Each is a link named by its title, followed by its site, or named by its site alone when it has no title, with the full URL on hover. A streaming reply's sources join the list as they arrive. Past seven sources, the rest fold behind a "+3 more" badge at the end of the list, which shows them in place and becomes "Show less", while the count still says how many there are in all. A list that would hide only one source shows all eight instead. Opening it moves focus to the first link it showed, and the list starts folded each time it is drawn. The search results in a web search step fold the same way, through the same `useFold` in `src/parts/fold.ts`.
- Images are shown inline, loading and decoding only as they near the screen, and files as their names.
- A pending reply shows its stream step by step, as the final record would show it. While the last block of steps is still going, its summary says what the model is doing now: "Thinking…", "Running web_search…", or "Reading the results…". A pending reply's header is its status, rather than a line repeating the model's name below it. Before any part arrives, and for a pending reply with no stream, as on the shared page, the model's name breathes. A reply just sent shows nothing for its first 400ms, so one that starts quickly never flashes a waiting state. Once a part streams in, `LiveAuthor` shows the name plainly. It reads the stream query like `ReplyStream`, so a delta rerenders only the header and the stream. While the turn waits for the rate limit, a line under the header says so.
- - Steps are timed where time is spent, as chat apps time thinking. While reasoning streams, its step and the block's summary count up, as "Thinking… 8s", and when it ends the step reads "Thought for 12s" and the finished reply's footer button totals it, as "Thought for 12s · web_search". A running tool counts up the same way, and its time goes once it finishes. The record holds no times, so they are measured in the browser while the reply streams and kept in memory by reply and step, so they carry over from the stream to the finished record. A reply not watched while it streamed, as after a reload, reads "Thought". An errored reply says it failed and why, with a "Try again" button beside it that regenerates it with the same model, as Regenerate does, and is disabled while a regenerate is on its way. A cancelled one says it stopped before it finished.
+ - Steps are timed where time is spent, as chat apps time thinking. While reasoning streams, its step and the block's summary count up, as "Thinking… 8s", and when it ends the step reads "Thought for 12s" and the finished reply's footer button totals it, as "Thought for 12s · web_search". A running tool counts up the same way, and its time goes once it finishes. The record holds no times, so they are measured in the browser while the reply streams and kept in memory by reply and step, so they carry over from the stream to the finished record. A reply not watched while it streamed, as after a reload, reads "Thought". An errored reply says it failed and why, with a "Try again" button beside it that regenerates it with the same model, as Regenerate does, and is disabled while a regenerate is on its way. A cancelled one carries a small amber Stopped badge, with the Stop button's square, under where it broke off.
- The message element carries `data-status`, and live steps `data-live`, `data-current`, and `data-state`, so a theme can style each state.
- **Message actions.** On user messages, edit, which opens the composer prefilled and sends a sibling, copy, and details. Editing another message starts the composer over from that message. On assistant replies, copy, regenerate, which regenerates with the same model, and More, a menu holding the rest: quote, which adds the text selected inside the reply when the menu opened, or the whole reply when nothing in it was selected, to the composer as a quote chip and focuses it, and says which it will quote; regenerate with, which shows the models in the menu's place and regenerates with the one picked at once; and details. Details, as a button or from More, opens a card of what the message's record says: the model, effort, tokens, status, when it was written in full, its record key, and where it is stored. Regenerate with and details each take the menu's place under a button back to it, and each view starts with focus on its first control. Escape or a click outside closes the menu or card and returns focus to its button. While generating, stop. The actions are icon buttons named by their labels, grouped as the message's actions, and sit at the message's edge, so a reply's start its footer and the user's end theirs, with the most used nearest the edge. A reply's steps button comes after them. The actions show on hover and on keyboard focus, while a menu or card is open, always on the latest reply, since its actions are the ones most often wanted next, and always on a touch screen, which has no hover. The shared view shows details as each message's only action.
- **Composer.** A textarea that grows with its text up to a cap, then scrolls, whose placeholder instructs rather than asks: "Ask anything" in a new chat, and "Reply" under a message, and nothing while an open chat's messages are on their way, so a chat opened on a reload never says one and then the other, and a chat just started goes from "Ask anything" to nothing to "Reply" as it opens, and a row under it with an attach button, a model select from `/api/models`, and Send. Attach and Send are icon buttons at either end of the row, a paperclip that opens the hidden file input and an up arrow, so the main action always sits in the corner. The model select sits just before Send. Once its list holds eight entries or more, about as many as it shows before scrolling, it opens with a Search models box, which finds a model by its name, its ID, or its provider, as the model select in Preferences does. For a model with the reasoning capability, a quiet effort select, the same one as in Preferences, sits beside it, between it and Send, and shows the chosen level's pie and name. "Default" in the model select names the model the server will fall back to, as "Default · Claude Sonnet", once the catalog offers it, and says "Default model" otherwise. The theme grows the textarea with `field-sizing: content`, and where a browser lacks it, `useAutoGrow` sets its height from its text. The effort is sent only while its select is shown. It starts on the effort last chosen in the composer on this device, kept in `localStorage` as a convenience, or on Auto. The attach button accepts images only for models with vision, plus any type an ingester supports. Files also attach when pasted into the textarea or dropped anywhere on the conversation, which marks itself with `data-dropping` while files are dragged over it. A paste that carries text as well as files pastes as text, since office apps copy a picture of the copied text alongside it. Pasted and dropped files skip the attach button's filter, so the server refuses a type no ingester reads, and an image for a model without vision is refused before upload. Each file uploads as soon as it is added and shows as a chip above the textarea, with a local preview for an image or a file icon, its name over its kind and size, as "PDF · 1.2 MB", with "Uploading…" in their place until its upload finishes, and a button that removes it. Removing a file while it uploads cancels the upload. A failed upload drops its chip and says which file failed and why. Send waits until every upload has finished, and sending lets go of the attachments it sent, keeping any added while it was on its way. Quotes from a reply show above the attachments, each as a block quote across the card cut to two lines, the whole text on hover, and a button that removes it, so the user sees what the model will be shown apart from what they wrote. Sending puts each quote at the start of the message as a Markdown block quote, in the order they were added, above the text, and lets go of the quotes it sent, keeping any added while it was on its way. Send is disabled while there is nothing to send, no text but spaces, no quotes, and no attachments. Once the composer holds any text, quote, or attachment, a Clear button, an × in the top corner of the box, as a field's own clear button rather than among the controls under it, empties the textarea, removes every quote and attachment, cancelling uploads still running, clears the composer's error, and focuses the textarea. It is disabled while a message is on its way, since sending clears the composer itself. Each paragraph in the textarea runs in the direction of its own text, as Persian runs right to left, while the box keeps the page's direction, so text either way stops short of Clear. Enter sends, and Shift+Enter adds a newline. On a touch screen, which has no hover, Enter adds a newline too and Send sends, as phone keyboards lead people to expect, and an Enter that an input method uses to pick its words, as for Japanese, never sends. Send is disabled while a message is on its way, and Enter does nothing then, so a message is sent once. Until an open chat's messages arrive, Send is disabled and Enter does nothing too, since the message would not yet know which one it answers. While the reply at the end of the branch runs, Stop, a square icon, takes Send's place and Enter does nothing, and that reply's own actions leave Stop to the composer. A composer editing an earlier message says so above the box, that sending adds the message as a new version, with a Cancel button, and Escape in the textarea cancels too. Its Send's tooltip says "Send as a new version", and it sends as usual. The file input clears after each pick, so choosing the same file again attaches it again. The model select offers "Default model" only when the server can choose a model without one, following chat-turns: an earlier completed reply on the branch names a model, or the user's preferences or the admin set a default. Otherwise the composer asks for a model, and sending waits until one is chosen. When `/api/models` lists no models at all, Send is disabled and a note points to Settings to add an API key.