DEV Community

babycat
babycat

Posted on

Chat Retry Needs a Retired Turn, Not a Second Stop Button

I tabbed out of the composer after a failed generation and landed on a Stop button that should not have existed. The visible error said the turn had failed, but that control was still sitting in the tab order. I pressed Enter, expecting a quiet no-op, and the retry stream died halfway through a sentence. Have you ever shipped a Retry button that quietly revived a corpse of UI state from the last request?

This was not a model-quality problem, and it was not a sprinkle-aria-live-on-it problem either. It was a state-machine bug that only appeared on the keyboard path after an error. Mouse users clicked the large Retry control and never noticed the old Stop button. Keyboard and screen-reader users walked the transcript and found two in-flight turns sharing one conversation.

The failure, as a state table

I wrote the table before touching more markup, because the interface had been lying in more than one status. If a status cannot answer whether Stop belongs in the tab order, it is not a real status. Read the failed row twice, because that is the row my build violated. Does your chat UI even have a failed row, or only isLoading?

Turn status Composer Stop tabbable? Retry tabbable? Live region Focus after the action
idle enabled no no silent composer
streaming locked yes, one control no polite token updates remain
failed enabled no yes assertive error once Retry or composer
cancelled enabled no optional Retry polite cancelled composer
complete enabled no regenerate only polite completed composer

The broken client violated the failed row in three ways at once. Stop remained focusable beside a brand new Stop from the retry. The live region still advertised aria-busy="true" on an article whose fetch had already died. Which control should Enter activate when two Stop buttons and one Retry share the same toolbar?

What the keyboard path actually did

The prototype handled the happy path well enough for a local demo. Tokens painted into an article, the assistant content grew, and focus stayed in the composer like a decent messenger window. Then the stream failed after a few chunks, which is exactly when chat UI starts improvising states. I reproduced it with a mock that died on chunk four, then later with a real abort from a free shared endpoint.

Here is the exact transition that broke:

  1. The user submits a prompt, and a stream begins with requestId = "turn-14".
  2. The network errors after a few chunks, and the bubble shows a generic failure.
  3. Retry is activated with Enter, and turn-15 starts streaming new tokens.
  4. Tab order still contains Stop for turn-14, Stop for turn-15, then the composer.
  5. The failed article still exposes aria-busy="true" and a leftover live name.

Does that look like a CSS leftover from a loading spinner? It looks like one until you count Stop buttons in the accessibility tree. I asked myself a rude question about ownership: if Stop is visible, which request id does it abort? The component could not answer, because it did not store an id on the button.

Root cause: retry mounted a new turn without retiring the old one

The renderer treated existing assistant markdown as a sign that the turn might still be streaming. Failed turns reused the streaming component, including its Stop button and its busy flag. Retry appended a sibling assistant article instead of committing the failed turn to a terminal status. Why would a boolean named isLoading know the difference between this id failed and some other id is live?

One AbortController ref for the whole thread

I had a single abortRef on the screen, not on the turn. Ghost Stop called abortRef.current.abort() after retry had already replaced the ref. Sometimes that aborted turn-15 and looked like a flaky model instead of a UI bug. Sometimes the ref was already null, so the button clicked, announced nothing, and stayed enabled.

Two symptoms came from one shared mutable slot. A conversation is a list of turns, and each inflight turn needs its own controller. If Stop cannot name its requestId, it should not be in the document. Would you ship a close button that closed a random tab?

A renderer that equated "not complete" with "stoppable"

The template mapped Stop buttons with a check like status !== "complete". Failed and cancelled are also not complete, so they inherited a deadly control. Disabled styling was applied inconsistently, and an undisabled button stayed in the tab order. Unmount the control when the turn is terminal, instead of hiding it with CSS and hoping keyboard users cannot find it.

visibility: hidden is not a state machine. pointer-events: none is not a state machine either. If the accessibility tree can still focus Stop, the turn is still streaming as far as the keyboard is concerned. That is the whole bug in one sentence, and it is easy to miss with a mouse.

Give every turn a terminal status

I threw away the boolean soup of isLoading, isError, and canStop. Each turn now carries a status union and a stable requestId. The conversation machine allows at most one inflight id. If you cannot point to that id, you do not render Stop.

type TurnStatus = "streaming" | "failed" | "cancelled" | "complete";

type ChatTurn = {
  requestId: string;
  role: "user" | "assistant";
  markdown: string;
  status: TurnStatus;
  error?: string;
  abort?: AbortController;
};

type ChatMachine =
  | { phase: "idle"; turns: ChatTurn[]; inflight: null }
  | { phase: "streaming"; turns: ChatTurn[]; inflight: string }
  | { phase: "recoverable"; turns: ChatTurn[]; inflight: null; recoverFrom: string };
Enter fullscreen mode Exit fullscreen mode

Stop renders only when phase === "streaming", and aria-controls equals inflight. Retry renders only when phase === "recoverable". That pairing sounds strict, and it is strict on purpose. Can a ghost Stop exist if the phase forbids it? Only if you bypass the machine and render from a leftover turn field.

The retry reducer retires first

Retry is not call fetch again and hope React reconciles the right bubble. Retry is a commit that retires the failed assistant article, then opens a new streaming article with a new id. I keep the original user prompt as a single user turn so the transcript does not hear the question twice. Should a screen reader really hear the same prompt stacked like a stutter?

function retireTurn(
  turns: ChatTurn[],
  requestId: string,
  status: "failed" | "cancelled",
  error?: string
): ChatTurn[] {
  return turns.map((turn) => {
    if (turn.requestId !== requestId) return turn;
    turn.abort?.abort();
    return { ...turn, status, error, abort: undefined };
  });
}

function retryFrom(machine: ChatMachine, failedId: string): ChatMachine {
  if (machine.phase === "streaming") {
    return machine; // ignore double Enter while a stream is live
  }

  const requestId = crypto.randomUUID();
  const abort = new AbortController();

  return {
    phase: "streaming",
    inflight: requestId,
    turns: [
      ...retireTurn(machine.turns, failedId, "failed"),
      {
        requestId,
        role: "assistant",
        markdown: "",
        status: "streaming",
        abort,
      },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice the early return when a stream is already live. Double-activating Retry with Enter is a classic keyboard failure, and swallowing the second activation is kinder than mounting a third article. The retired turn keeps its error text so screen-reader users can still read what went wrong. Its Stop button is gone because status is failed and abort is undefined.

Ignore stale chunks after you retire a turn

AbortController rejects the fetch, but the browser may already have buffered SSE bytes. If the reader loop appends those bytes without checking identity, a failed article starts speaking again after you announced the error. I guard both the loop and the reducer, because either hole is enough to revive a corpse. Have you logged request ids next to markdown length while clicking Stop?

function applyChunk(
  machine: ChatMachine,
  requestId: string,
  chunk: string
): ChatMachine {
  if (machine.inflight !== requestId) return machine;

  return {
    ...machine,
    turns: machine.turns.map((turn) => {
      if (turn.requestId !== requestId || turn.status !== "streaming") return turn;
      return { ...turn, markdown: turn.markdown + chunk };
    }),
  };
}

async function pumpStream(
  turn: ChatTurn,
  dispatch: (chunk: string) => void
) {
  const response = await fetch("/stream", {
    method: "POST",
    signal: turn.abort?.signal,
    headers: { Accept: "text/event-stream" },
  });

  if (!response.ok || !response.body) {
    throw new Error(`Stream HTTP ${response.status}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    if (turn.status !== "streaming") break;
    dispatch(decoder.decode(value, { stream: true }));
  }
}
Enter fullscreen mode Exit fullscreen mode

The inflight !== requestId check is the one I missed first. Without it, a late chunk from turn-14 writes into whichever article your map function finds, or worse, into turn-15. Visual tokens lie about ownership when two articles are growing in the same frame. The reducer has to refuse work that belongs to a retired id.

Focus, Escape, and the live region

Unmounting ghost Stop is not the whole accessibility job. You still owe people a focus place, a cancel key, and an announcement that does not replay the entire thread. I used four rules and a tiny focus helper. None of these rules require a new ARIA role on the transcript itself.

  1. Unmount Stop on terminal turns; do not leave a disabled duplicate unless it is the only way to preserve focus.
  2. If focus was on Retry when the new stream started, move it to the new Stop control.
  3. If focus was in the composer, leave it there so typing is not stolen mid-thought.
  4. Bind Escape to cancel only while phase === "streaming", and announce "Generation stopped" once.
function moveFocusAfterRetry(args: {
  previousActive: Element | null;
  nextStop: HTMLButtonElement | null;
  composer: HTMLTextAreaElement;
}) {
  const { previousActive, nextStop, composer } = args;
  if (
    previousActive instanceof HTMLElement &&
    previousActive.dataset.action === "retry"
  ) {
    nextStop?.focus();
    return;
  }
  if (!composer.contains(document.activeElement)) {
    composer.focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

For announcements, I keep one polite status node outside the transcript, not a live region wrapped around every token. Token painting stays in a regular article with aria-busy tied to the inflight id. Failure uses a single assertive sentence, then the region returns to polite. If you leave assertive on during retry, the next few tokens will interrupt each other like a broken auctioneer.

Escape handling belongs on a keydown listener at the chat layout, not only on the Stop button. Users cancel from the composer, and a button-only shortcut will miss them. Clear the inflight id in the same tick you abort, so a second Escape cannot resurrect a ghost controller. Do we need the whole thread re-read when one turn fails? Almost never.

A small reproduction you can paste

The following sketch is a proposed single-file driver, not a production chat SDK. Wire form submit, Retry, Stop, and Escape to the machine above, then watch the button count. I want the accessibility tree to show zero Stop controls in failed, and exactly one in streaming. If the count is ever two, the lab has failed before any model answer matters.

<section aria-labelledby="chat-title">
  <h1 id="chat-title">Retry lab</h1>
  <p id="status" aria-live="polite" aria-atomic="true"></p>
  <ol id="log"></ol>
  <form id="composer-form">
    <label for="composer">Message</label>
    <textarea id="composer" name="prompt"></textarea>
    <button type="submit">Send</button>
    <button type="button" id="stop" hidden>Stop generating</button>
  </form>
</section>
<script type="module">
  // Plug retryFrom / applyChunk here.
  // After a forced fail, count document.querySelectorAll("[data-role=stop]").
  // Expected: 0 before retry, 1 after retry, 0 after the retry fails too.
</script>
Enter fullscreen mode Exit fullscreen mode

Expected UI states for that lab:

  • After fail: error text in the list, Retry visible, Stop hidden, composer enabled.
  • After retry: one empty assistant article busy, Stop visible, Retry not in tab order.
  • After cancel: polite "Generation stopped", Stop hidden, focus in the composer.
  • After stale chunk: markdown length on the failed article does not change.

Force the first failure with a mock that throws after four chunks, because you want a deterministic keyboard path. Then Tab from the composer through the transcript and count Stop. If you cannot describe that walk in one sentence, the machine is still leaking controls.

Reproducing the network half without lying in the UI

A local mock that errors on chunk four is the honest first tool, because it is deterministic. I still wanted one run against a real stream boundary, where abort, HTTP errors, and partial bodies show up without my timers. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project with free model access and a free server option. I used that option only as a disposable network boundary for this client-side retry lab. I am not listing model names, quotas, or uptime, because those claims go stale and they are not the bug. If you already have a mock that can fail, keep it; swap in that free server only when the ghost Stop is gone and you need a genuine abort.

QA matrix I want you to run

I am not attaching a conformance badge, because I cannot see your browser, operating system, or assistive technology. Please record versions next to the exact transition that failed. The interesting path is fail, then retry, then Tab, not the first-token happy path. Will your matrix still pass if Retry is activated from the keyboard twice?

Transition Keyboard expectation Screen reader expectation
stream → fail Tab never lands on Stop Assertive error, busy false on the failed article
fail → retry Exactly one Stop, old Retry gone or not tabbable "Retrying the previous prompt" once
retry → Escape Only the inflight id aborts "Generation stopped", focus returns to composer
stale chunk after fail Failed markdown stays frozen No second token announcement on the dead turn
double Enter on Retry Second press ignored while streaming No duplicate assistant articles

Try it with the pairs you actually have: Chromium plus NVDA, Firefox plus NVDA, Safari plus VoiceOver. If Tab still finds two Stop buttons, the machine is not the source of truth yet. If announcements concatenate the old error with new tokens, your live region is still shared across request ids. Send the failing transition, not a screenshot of the happy path.

Limitations, and who should not copy this blindly

This pattern assumes one inflight generation per transcript, which matches most product chats and fights ghost controls. Multi-pane agent dashboards that run parallel tool streams need a list of inflight ids, each with its own Stop and its own article. Voice-primary UIs may park focus on Stop during streaming; this lab parks on the composer so typing is not stolen.

Do not point a free shared server at prompts that contain secrets, customer data, or API keys. The state machine does not redact anything, and a disposable endpoint is still someone else's process. I also do not replace the failed article in place, so error history grows; if you collapse errors, keep a textual summary or you will hide the reason Retry exists.

This is not a WCAG certificate, and it is not a benchmark of any model host. It is a debugging record of a ghost Stop button, a shared abort ref, and a retry path that forgot to retire the previous turn. If your chat can fail, it can fail while someone is holding a keyboard. Does your Retry control know which request it is allowed to start?

Top comments (0)