DEV Community

babycat
babycat

Posted on

Streaming Chat Needs a Finish Line, Not Mid-Answer Action Buttons

I tabbed into the assistant bubble before the tokens stopped arriving, and the Copy button was already there. NVDA started reading the answer over from the first sentence every time a markdown paragraph reflowed on screen. Had I activated Copy in that moment, I would have pasted half a function into a waiting pull request. Why do so many streaming chats treat the first visible token as if the whole turn were finished?

This write-up is a reconstructed lab failure, not a claim about one vendor dashboard I cannot name. I wanted a small mental model I could re-run after every streaming tweak without standing up a full app. The question was simple enough to sit on a sticky note beside my keyboard during debugging. When is a generative turn actually done for keyboard users, for speech, and for the clipboard?

The failure I could reproduce without a mouse

I started the assistant turn, then I kept my hands on the keyboard and waited for the first heading to appear. Tab moved into a Copy control that looked complete, even though the code fence was still growing. NVDA still parked on the live region restarted the paragraph instead of reading only the new tokens. Does that already feel like a finish line problem rather than a visual timing nit?

Suggested-reply chips mounted under the bubble and stole the next Tab stop while aria-busy was already gone. The Stop control stayed in the tree after I cancelled, and focus never returned to the composer waiting beside it. I could not start a recovery prompt without hunting for a caret that the actions had stolen. Have you shipped a toolbar that appears because text exists, not because the protocol said the turn completed?

Here is the interaction I treated as the failing transition, written so someone else can try it on a local demo:

  1. Focus the composer, submit a prompt that yields a fenced code sample, and do not touch the pointer at all.
  2. When the first markdown heading appears, press Tab once and notice whether Copy or a chip is already reachable.
  3. Keep NVDA or VoiceOver on the assistant live region and listen for a full restart on each flush.
  4. Press the Stop control, then Tab again, and check whether focus is stranded on a disabled button.

If step two reaches an action, the UI has already lied about completeness to the keyboard. If step three restarts the speech, the live region is replacing the node instead of appending speech. If step four drops focus, cancel is a trap rather than a recovery path for the next prompt. Write that failed transition into the bug, including browser, OS, and assistive-technology versions.

A state table before any more JSX

I sketch this table before I reach for React, because the bug is a state bug wearing a friendly button. Visual loading spinners do not help when the accessibility tree advertises actions too early in the turn. Ask yourself which cells a screen reader user can actually perceive while tokens are still moving.

Turn status Composer Stop / Retry Copy / Regenerate Suggested replies aria-busy on log Live region
idle enabled Retry hidden hidden hidden false silent
submitting disabled Stop enabled hidden hidden true "Sending" once
streaming disabled Stop enabled hidden hidden true polite deltas
complete enabled, focused Retry enabled enabled enabled false "Answer complete" once
cancelled enabled, focused Retry enabled hidden hidden false "Generation stopped" once
error enabled, focused Retry enabled hidden hidden false error text once

Notice that Copy, Regenerate, and chips share one rule: they do not exist in the tree until complete. Stop exists only while a request is in flight, and Retry exists only when nothing is in flight. The composer takes focus back on every terminal state, because that is the only control that can start the next turn. Would you ship a Submit button that is enabled while the form schema is still downloading in the background?

I keep this table in the pull request description whenever someone wants to show the actions sooner for "delight." Showing them sooner is how Tab lands on a half-written function and copies it. Delight that lies about completeness is just a keyboard defect with better spacing. The finish line belongs in the state machine, not in a CSS fade.

What the network was actually doing

I reproduced the stream against a free development server so the first byte would sometimes arrive late enough to invite Retry. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access and free server option as the backend for this lab, because a slow first byte makes the retry race easy to see without inventing load-test theater. I am not quoting quotas, model names, or hardware claims here, because those numbers change and I did not measure them for this article.

The sequence that hurt was boring in DevTools and loud in the accessibility tree after the first flush. The fetch stayed pending long enough that a patient user waited, while an impatient keyboard user activated Retry beside the still-empty bubble. Two AbortController instances then wrote into the same message id and the live region thrashed. The Copy button mounted on the first flush from whichever stream won the render. Have you watched aria-busy flip false because the UI equated "we have text" with "we are done"?

I now log four timestamps for every turn, even in a demo that never leaves localhost:

  • submitAt when the composer fires and the machine enters submitting
  • firstByteAt when the stream yields a chunk and status becomes streaming
  • firstActionMountAt if any interactive chrome mounts, which should stay null until complete
  • terminalAt when status becomes complete, cancelled, or error

If firstActionMountAt is before terminalAt, the finish line moved and keyboard users can steal a partial answer. If two submitAt values share a message id, retry raced abort and the live region will double-speak. Those two invariants caught more bugs than another spinner ever did in this lab. Timestamps are cheaper than arguing about how a button felt.

Root cause: two clocks, one live region

The renderer had one clock, and the network had another, and they only met inside setState after each chunk. Markdown was parsed on every flush, which meant the code block became a new <pre> on every token batch. A Copy button lived inside that template, so the commit created a new button whenever the fence grew another line. The live region wrapped the entire bubble, so each replace looked like a brand new message to the screen reader.

Suggested chips used tokens.length > 0 as proof the model had an idea, which is not the same as proof the turn finished. I had also cleared aria-busy when text.length > 0, because empty felt like loading and text felt like arrival on the visual canvas. That heuristic is how visual design leaks into semantics and then into Tab order. Arrival of text means streaming is underway, not that Copy is honest yet.

Completion is a terminal event from the protocol, or an abort, or a mapped HTTP error you decided to show. Mixing those clocks is the whole bug, and the buttons were only witnesses. There is a useful analogy if you think in video players rather than chat bubbles and token counters. You would not show a Download button on the first decoded frame, and you would not advertise chapters while the manifest is still buffering.

Streaming language UI deserves the same finish line, even when the frames are tokens and the toolbar is a chip row. Agent products now stream citations, tool cards, and follow-up prompts in the same bubble as the prose. If those controls mount on first paint, you have built a generative toolbar with no ready state. Keyboard users will find it; pointer users may never notice the lie.

A typed turn machine

I want the status to be a union, not a handful of booleans that can all be true together during a retry. The lab machine below is a proposed implementation, not production telemetry from a live customer app. Copy it into a TypeScript file and drive it with fake chunks before you wire any model. If a late chunk can still enable Copy, the guards are wrong.

type TurnStatus =
  | "idle"
  | "submitting"
  | "streaming"
  | "complete"
  | "cancelled"
  | "error";

type ChatAction = "copy" | "regenerate" | "replyChip" | "stop" | "retry";

interface TurnState {
  status: TurnStatus;
  messageId: string | null;
  text: string;
  error: string | null;
  abort: AbortController | null;
}

function actionsFor(status: TurnStatus): ChatAction[] {
  switch (status) {
    case "submitting":
    case "streaming":
      return ["stop"];
    case "complete":
      return ["copy", "regenerate", "replyChip", "retry"];
    case "cancelled":
    case "error":
      return ["retry"];
    default:
      return [];
  }
}

function reduceTurn(
  state: TurnState,
  event:
    | { type: "SUBMIT"; messageId: string }
    | { type: "FIRST_BYTE" }
    | { type: "DELTA"; chunk: string }
    | { type: "COMPLETE" }
    | { type: "CANCEL" }
    | { type: "FAIL"; error: string }
): TurnState {
  switch (event.type) {
    case "SUBMIT":
      state.abort?.abort();
      return {
        status: "submitting",
        messageId: event.messageId,
        text: "",
        error: null,
        abort: new AbortController(),
      };
    case "FIRST_BYTE":
      if (state.status !== "submitting" && state.status !== "streaming") {
        return state;
      }
      return { ...state, status: "streaming" };
    case "DELTA":
      if (state.status !== "streaming") return state;
      return { ...state, text: state.text + event.chunk };
    case "COMPLETE":
      if (state.status !== "streaming") return state;
      return { ...state, status: "complete", abort: null };
    case "CANCEL":
      state.abort?.abort();
      return { ...state, status: "cancelled", abort: null };
    case "FAIL":
      return {
        ...state,
        status: "error",
        error: event.error,
        abort: null,
      };
  }
}
Enter fullscreen mode Exit fullscreen mode

The important guard is DELTA ignoring chunks unless status is already streaming after a first byte. A late chunk from an aborted request cannot resurrect Copy or a chip row in the accessibility tree. SUBMIT aborts any previous controller before opening a new one, which is how Retry stops being a fork of the same bubble. I still keep the previous assistant text on CANCEL, because wiping the bubble feels like data loss to someone who already heard part of it.

Withholding actions until complete

The view layer should ask actionsFor(status), not text.length, when it decides which buttons exist. Here is a proposed React sketch that keeps the Copy button out of the tree during flight. Treat it as a reproduction, and keep the focus calls in the same commit as the status change. If focus and status drift across two effects, you will recreate the stranded Stop button.

function AssistantTurn({ state, onCancel, onRetry, onCopy }: Props) {
  const composerRef = useRef<HTMLTextAreaElement>(null);
  const actions = actionsFor(state.status);
  const busy =
    state.status === "submitting" || state.status === "streaming";

  useEffect(() => {
    if (
      state.status === "complete" ||
      state.status === "cancelled" ||
      state.status === "error"
    ) {
      composerRef.current?.focus();
    }
  }, [state.status]);

  return (
    <section>
      <div aria-busy={busy} aria-live="polite" aria-relevant="additions">
        <p>{state.text}</p>
        {state.status === "submitting" ? (
          <p>Waiting for the first token.</p>
        ) : null}
      </div>

      {actions.includes("stop") ? (
        <button type="button" onClick={onCancel}>
          Stop generating
        </button>
      ) : null}

      {actions.includes("copy") ? (
        <button type="button" onClick={() => onCopy(state.text)}>
          Copy answer
        </button>
      ) : null}

      {actions.includes("retry") ? (
        <button type="button" onClick={onRetry}>
          Retry
        </button>
      ) : null}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two details matter more than the JSX shape when you paste this into a sandbox. aria-relevant="additions" asks supporting browsers to treat new text as additions when you append, which only works if you do not replace the whole paragraph node on every token. Focus returns to the composer on terminal states, so Stop never becomes a grave for the tab caret after cancel. Pointer users can still click Copy after completion; keyboard users simply do not see a lie in the middle of the stream.

If you render markdown, parse it into a non-interactive preview while busy is true and the fence is still growing. Mount the interactive code chrome, copy controls, and in-bubble links only after complete lands in the machine. Partial markdown is a document under construction, not a toolbar, and toolbars steal Tab. That split is the finish line expressed in components instead of in a design-token comment.

Live regions that announce deltas, not rewrites

Replacing innerHTML on every token is the speech equivalent of rewinding a cassette every two seconds during a sentence. I keep a visually hidden delta node for announcements and a visible node that can re-render markdown without being live. That split is the whole trick, and it is still a proposal you should test on your own machine with your reader. Do not trust a single browser's politeness queue as a specification.

function StreamAnnouncement({
  status,
  latestDelta,
}: {
  status: TurnStatus;
  latestDelta: string;
}) {
  const completePhrase =
    status === "complete"
      ? "Answer complete."
      : status === "cancelled"
      ? "Generation stopped."
      : status === "error"
      ? "Generation failed."
      : "";

  return (
    <div className="sr-only" aria-live="polite" aria-atomic="false">
      {status === "streaming" ? latestDelta : completePhrase}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

aria-atomic="false" is a request, not a guarantee, so I still throttle deltas to sentence boundaries before swapping the hidden node. Flushing every token will drown the speech queue on a long function body and hide the closing sentence. I buffer until a newline or a period, then I swap latestDelta and leave the visible markdown free to update faster. Visual speed and speech speed are allowed to disagree; Tab order is not allowed to disagree with complete.

During submitting, I announce waiting once, not on every spinner frame that CSS decides to pulse. After complete, I announce once, then I let the user read the visible bubble with virtual cursor keys at their own pace. Mixing those modes is how people hear the same preface twelve times and never hear the closing sentence they needed. If your reader is still talking when chips appear, the chips arrived too early.

Cancel, retry, and the slow first byte

A free server that pauses before the first byte is a gift for this test, because real users will retry while submitting looks empty. The failure is not the pause; the failure is mounting Retry and Stop at the same time, or leaving Retry bound to the old message id. My rule is that Retry is just Submit with the same prompt and a new message id, never a second writer on the old id. Stop is Cancel on the current id only, and it must abort before the next Submit reduces state.

I also disable the composer while submitting or streaming, which prevents a second Enter from forking the turn into duplicate live regions. Some products keep the composer open for queueing another question, and that is a different machine with a queue announcement. If you queue, say so in the live region, and do not reuse the in-flight bubble as a second answer tray. Mixing queue UX with in-place streaming is how duplicate answers appear for screen reader users who never saw a second spinner.

When Stop fires, I abort the fetch, reduce to cancelled, and move focus to the composer in the same turn. I do not auto-retry after a user cancel, because that is a consent bug wearing a helpful badge. If the stream errors, I put the message in the live region and leave Retry adjacent to the composer in tab order. Recovery should be one Tab away, not a scavenger hunt through leftover action chrome.

A keyboard and screen-reader QA matrix

Please do not trust this article as a conformance claim for any production domain I did not audit. Run the failing transition on your stack, and write the versions into the bug instead of a screenshot of a spinner. I use this matrix as a checklist for the lab demo, and I expect the recorded versions to change with every OS update. The pass column is the finish line in plain language.

Environment What I exercise Pass if
NVDA + Firefox on Windows Submit, Tab during stream, Stop, Retry No action except Stop during flight; speech does not restart the whole answer; focus returns to composer
VoiceOver + Safari on macOS Same path, plus rotor to buttons Copy is absent until complete; "Answer complete" is spoken once
Keyboard only, Chrome, no AT Tab order before and after complete Suggested chips are not in tab order until complete
Slow first byte (throttled network) Retry during submitting Only one in-flight controller; no duplicate bubbles

Record the exact transition that failed, not a vibe about jank or polish in the bubble. "Tab after first heading on NVDA, Firefox, Windows" is a bug you can reopen next week. "Feels janky" is not a cell in the state table and will not survive code review. If you cannot name the status you were in, you are not done reducing the machine.

Who should not copy this pattern

Do not use this finish-line model if your product streams token-by-token editing inside a real document editor with its own caret. Those surfaces need a different live-region strategy, because the user is already inside the text and speech would fight the caret. Do not hide Copy forever after complete if your audience pastes constantly; hiding is only for the in-flight window. Do not treat this as a WCAG certificate, because I did not audit a production domain here and assistive-technology behavior still forks.

Teams that auto-mount tool-confirmation dialogs mid-stream also need an extra state, like awaiting_tool, with focus moved into the dialog and back to the composer. Blending tool cards with streaming is another two-clock bug, only with a modal personality. If you cannot draw that extra row in the table, you are not ready to ship the card. Ship the prose first, then give the tool UI a named status of its own.

Limitations

This lab never measured tokens per second, and it never claimed a model ranking or a durability promise for any free tier. Free model access and a free server only gave me a slow, honest first byte and a stream I could abort from the browser. Sentence-boundary throttling will feel laggy if you expect speech to track every token, which I do not want. aria-live behavior still differs across browser and screen-reader pairs, so the QA matrix is mandatory rather than decorative.

Bring your own composer, keep the finish line in the client, and refuse to mount Copy until the protocol says the turn is over. If the first byte is slow, that is a loading announcement, not a reason to invent a second set of actions. I still want the clipboard to tell the truth. Don't you?

Top comments (0)