I pressed Enter on a short prompt and watched the composer freeze like a stuck elevator door. The stop button took focus, which felt correct, and then nothing visible happened for several seconds. Was the stream dead, or was my loading state lying to every keyboard user in the room? The Network panel kept appending tiny SSE frames, so the socket was alive while the UI played dead.
Then the polite live region finally spoke a curly brace, followed by a quoted key, and the bug stopped looking like downtime. Have you ever trusted a spinner more than the shape of the events feeding it? I had, and the transcript paid for that shortcut.
The hang was a missing phase, not a dead socket
I was wiring an accessible chat client against a streaming completion endpoint on a spare box. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode, an open-source project with free model access and a free server option, as a cheap place to reproduce mixed frames.
The UI treated every SSE chunk as visible text, and it treated the absence of text as still thinking. When the model streamed a tool call first, there was no content string to render, so the spinner never yielded. The composer stayed disabled, Retry never appeared, and VoiceOver eventually narrated punctuation from the tool arguments.
Here is the failure sequence I actually watched, in order:
- I submitted from the textarea with Enter, not with a pointer.
- Focus moved to Stop, which is the right first move for generation.
- No tokens appeared, and the polite live region announced nothing useful.
- SSE frames kept arriving with
tool_callsdeltas and no usablecontent. - After the tool payload finished, the stream sent a closer without a text-only path.
- My reducer left
phase: "streaming"in place, so recovery controls stayed unreachable.
Draw the state table before you restyle the spinner
I should have drawn this table before shipping another animated dot. Do you keep an explicit chat phase, or do you encode it as booleans that fight each other?
| Phase | Composer | Stop | Live region | Focus |
|---|---|---|---|---|
idle |
enabled | hidden | silent | textarea |
streaming-text |
disabled | visible | throttled writing status | Stop |
streaming-tool |
disabled | visible | named tool work, once | Stop |
error |
enabled | hidden | error plus recovery | Retry |
complete |
enabled | hidden | answer ready | textarea |
The bug lived in the missing streaming-tool row. Two booleans cannot represent that row, and they will lie to assistive technology every time a tool frame arrives first.
idle --> streaming-tool --> streaming-text --> complete
\ \
+----> error <------+
If your diagram has no tool lane, the spinner is already a liar. Can a keyboard user tell work from a hang without staring at DevTools?
Reproduce the stream without guessing a model name
You do not need a branded dashboard to debug this class of failure. Spin a tiny SSE server that emits a tool-call delta, then a short content delta, then a done frame. I keep this as a single Node file so the UI cannot blame CORS theater.
node tool-stream-server.mjs
# expected: tool stream on http://127.0.0.1:8787/stream
// tool-stream-server.mjs
import http from "node:http";
const frames = [
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"lookup\"}}]}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"q\\\":\\\"home\\\"}\"}}]}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"Found two headings.\"}}]}\n\n",
"data: [DONE]\n\n"
];
http.createServer((req, res) => {
if (req.url !== "/stream") {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*"
});
let i = 0;
const timer = setInterval(() => {
if (i >= frames.length) {
clearInterval(timer);
res.end();
return;
}
res.write(frames[i++]);
}, 400);
}).listen(8787, () => {
console.log("tool stream on http://127.0.0.1:8787/stream");
});
Point the chat client at that URL and submit from the keyboard only. Does the composer unlock after the last frame, or does Stop remain the only tab stop in the pane?
If you would rather not run a local process, MonkeyCode's free server option is a convenient host for the same fixture. I am not attaching quotas, model names, or uptime promises, because those change and I will not invent them.
Read the wire before you blame React
Open DevTools, filter EventStream, and dump each parsed object on its own line. Ask one rude question of every frame: is this text, a tool, an error, or a closer? My original parser looked like the next snippet, and it is the entire bug in a dozen lines.
function reduceNaive(state, frame) {
if (frame === "[DONE]") {
return { ...state, phase: "complete" };
}
const delta = frame.choices?.[0]?.delta ?? {};
if (delta.content) {
return {
...state,
phase: "streaming",
text: state.text + delta.content
};
}
return state; // tool_calls and empty strings both vanish here
}
See the trap? if (delta.content) is false for a missing field, for null, and for an empty string. Tool-only frames return the previous state, so phase never moves. A later [DONE] can still fire, unless your reader drops keep-alives and then misses the closer. I had both defects in one sitting. Charming, right?
Log a typed event instead of concatenating whatever JSON arrived:
function classify(frame) {
if (frame === "[DONE]") return { type: "done" };
const delta = frame.choices?.[0]?.delta ?? {};
if (delta.tool_calls?.length) return { type: "tool", delta };
if (typeof delta.content === "string") return { type: "text", delta };
if (frame.error) return { type: "error", error: frame.error };
return { type: "ignore" };
}
That typeof check is the reusable technique. Empty text chunks can advance a writing phase without dumping a blank paragraph. Tool chunks can change phase without ever touching the transcript. Errors stop borrowing the spinner that hid them yesterday.
Put the phases in a reducer you can unit test
Booleans like isLoading and hasError collide as soon as a tool call arrives during retry. I now keep an explicit phase union and one transition function. This is the artifact I wish I had pasted into the review, instead of another CSS pulse.
type Phase =
| "idle"
| "streaming-text"
| "streaming-tool"
| "error"
| "complete";
type ChatState = {
phase: Phase;
text: string;
toolName: string | null;
error: string | null;
};
function reduce(state: ChatState, event: ReturnType<typeof classify>): ChatState {
switch (event.type) {
case "tool": {
const name = event.delta.tool_calls[0]?.function?.name ?? state.toolName;
return { ...state, phase: "streaming-tool", toolName: name, error: null };
}
case "text":
return {
...state,
phase: "streaming-text",
text: state.text + event.delta.content,
error: null
};
case "error":
return {
...state,
phase: "error",
error: event.error.message ?? "Stream failed"
};
case "done":
if (state.phase === "error") return state;
return { ...state, phase: "complete", toolName: null };
default:
return state;
}
}
Notice done does not keep the composer disabled after tool work. Notice tool frames do not append JSON onto text. That second rule is the accessibility fix hiding inside the reducer, not in another aria-* attribute.
Wire abort to the same phase enum, or Stop becomes a decorative button:
let abort = null;
async function startStream(prompt) {
abort = new AbortController();
const res = await fetch("http://127.0.0.1:8787/stream", {
method: "POST",
body: JSON.stringify({ prompt }),
signal: abort.signal
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
// parse SSE lines, then reduce(classify(frame))
}
function stopStream() {
abort?.abort();
}
If Stop does not call abort(), you only changed the label. Keyboard users will notice first, because they are already parked on that control.
Announce work, do not narrate punctuation
Why did VoiceOver say a curly brace? I bound aria-live="polite" to the raw token buffer, including tool arguments. Those arguments are punctuation-heavy JSON, and a polite region will cheerfully read {, then ", then q. Sighted users still get a spinner story. People using the virtual cursor get a ransom note.
Use a dedicated status node, and throttle it to phase changes. Keep the transcript as ordinary document text, not a live region for every delta.
<div id="status" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="transcript" aria-live="off"></div>
<label for="composer">Message</label>
<textarea id="composer"></textarea>
<button type="button" id="stop">Stop generating</button>
<button type="button" id="retry" hidden>Retry</button>
function statusFor(state) {
switch (state.phase) {
case "streaming-tool":
return `Working on ${state.toolName ?? "a tool"}. Stop generating is available.`;
case "streaming-text":
return "Writing a reply. Stop generating is available.";
case "error":
return `${state.error}. Retry is available.`;
case "complete":
return "Answer ready.";
default:
return "";
}
}
Update #status when phase changes, not when another argument character arrives. Append visible prose to #transcript and let people read it in document order. Have you tested that with the virtual cursor, or only with the spinner in a large Chromium window?
Keep Stop mounted, then give the keyboard a way out
A tool-call stream is still a cancelable generation. Hide Stop too early and you trap people in a disabled textarea. Leave Stop mounted after complete and you offer a control that does nothing. I now toggle from the same phase enum, including focus.
function renderControls(state) {
const streaming =
state.phase === "streaming-text" || state.phase === "streaming-tool";
stopButton.hidden = !streaming;
composer.disabled = streaming;
retryButton.hidden = state.phase !== "error";
if (streaming) stopButton.focus();
if (state.phase === "error") retryButton.focus();
if (state.phase === "complete") composer.focus();
}
Pointer users will click Stop. Keyboard users need the same abort without a hover target. Screen reader users need the status text to mention that Stop exists, because a newly shown button is easy to miss during a quiet tool phase.
Expected UI states, if you are keeping a checklist beside the fixture:
-
streaming-tool: composer disabled, Stop visible, status names the tool, no JSON in the transcript. -
streaming-text: same controls, transcript grows, status does not repeat every word. -
error: composer enabled, Retry focused, status speaks the failure once. -
complete: composer focused, Stop gone, status says the answer is ready.
Environment QA matrix
Please reproduce the exact transition, not a happy-path screenshot. I care about this sequence: idle to streaming-tool to streaming-text to complete. Force the fixture server above, then fail the transition on purpose.
| Environment | Transition to fail | Pass if |
|---|---|---|
| NVDA + Firefox, Windows | first tool frame | status announces once, no JSON spelling |
| VoiceOver + Safari, macOS | tool work in progress | Stop remains a tab stop, composer stays disabled |
| VoiceOver + iOS Safari |
complete after tool then text |
focus returns to composer, Stop is gone |
| Keyboard only, Chromium | Enter submit, Escape abort | Retry is reachable after a thrown error |
This is a plan, not a scorecard from a lab I did not run. If your production stream never emits tool calls, the matrix will stay green and still miss production. Point the client at the fixture until the first non-text frame misbehaves in public.
What this does not fix, and who should skip it
This reducer will not rescue a server that closes the socket without a closer frame. It will not sanitize tool arguments before you execute them on behalf of a user. It will not replace a broader design critique of agent chrome, and extra ARIA will not make an inaccessible transcript semantic.
Skip this approach if you only render fully buffered answers after done. Skip it if the product forbids tool calls and never will. Skip it if you cannot abort the fetch, because a Stop button without AbortController is theater.
Free endpoints are useful for this class of bug because mixed frames become cheap to capture. They are the wrong place to pretend you measured latency, quota headroom, or hardware. I will not quote allotments I cannot verify on the day you read this.
If you want a low-friction box for the fixture, MonkeyCode's free model access and free server option are there to try. Bring the state table with you, and watch the first non-text frame like a hawk.
Top comments (0)