I was halfway through a streaming reply when the caret kept blinking and nothing new arrived. Had the model stalled, or had the remote socket already closed on me? I pressed Tab, expecting a Retry control, and focus jumped straight into the browser chrome instead.
That is the failure I want to unpack here. A dropped stream that looks alive is worse than a visible error, especially when you cannot use a mouse. This write-up is a debugging retrospective of that frozen cursor, from the first silent symptom to a typed retry path you can keyboard-test.
The afternoon the cursor kept lying
I had wired a small chat surface to a remote inference endpoint so I could watch idle disconnects without inventing a fake delay. The last tokens said “then you should”, the caret kept pulsing, and the composer stayed disabled like a responsible streaming UI. VoiceOver announced nothing. Was I supposed to wait, refresh, or guess that the server had gone away?
Mouse users could click a faint Retry chip that I had absolutely positioned on the bubble. Keyboard users never met that chip. Screen-reader users never heard that the stream had died. I had built a recovery path that only existed for pointer input, which is how this class of bug survives code review.
I see the same pattern whenever a frontend treats reconnect as an implementation detail. The network layer retries, the bubble keeps a typing glyph, and the product never settles into an error a person can act on. If your agent loop is “just if-statements in a trench coat,” the UI loop is often a silent while (socket) that never tells the keyboard what happened.
What the UI claimed versus what was true
I wrote the mismatch down before I touched styles or ARIA. If you skip that table, you will “fix” the spinner and leave the focus trap intact.
| Surface | What it claimed | What was actually true |
|---|---|---|
| Assistant bubble | Still generating | The reader had already ended |
| Composer | Disabled because a turn is live | No turn was live; abort never ran |
| Retry chip | Visible recovery |
position: absolute and tabindex missing |
| Status region | Quiet, so nothing changed | Disconnect never copied into the live region |
| Focus | User can resume typing | Tab order skipped the only recovery control |
Ask yourself the rude question I asked in the recording: if I unplug the pointer, can this page still recover? If the answer is no, you do not have a retry. You have decoration.
Keep a state table next to the composer
I stopped storing isLoading: boolean after this incident. A boolean cannot represent “partial text exists, socket died, retry is allowed.” The typed union below is the artifact I now paste before I write markup.
type DisconnectReason = "idle-timeout" | "network" | "abort";
type StreamUi =
| { status: "idle" }
| { status: "streaming"; startedAt: number; partial: string }
| {
status: "disconnected";
reason: DisconnectReason;
partial: string;
retryable: true;
}
| { status: "error"; message: string; retryable: boolean }
| { status: "complete"; text: string };
Expected UI states, in the order a keyboard user should feel them:
- idle — composer enabled, no live region chatter, Stop hidden.
- streaming — Stop in tab order, polite “generating” announced once, caret optional.
- disconnected — caret removed, Retry and Edit-partial in tab order, assertive status.
- error — same recovery controls, plus a specific message you can read twice.
-
complete — composer enabled, focus returned to the text field, no leftover
aria-busy.
Notice what is missing? There is no “silently reconnecting” state that keeps the caret. If you need a reconnect attempt, it still has to be a named state with a deadline and an announcement. Otherwise you are lying with animation.
Reproducing the idle drop
I did not start with a production agent graph. I started with a single-file page and a reader I could kill on a timer. You should be able to paste this, serve it, and Tab through the failure without a backend at all.
For a hosted path I later pointed the same UI at MonkeyCode’s free model access and free server option, because I wanted a socket that could go quiet for real. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The stub below is the version you can run offline; the hosted path only changes the fetch URL.
<!-- save as disconnect-retry.html and serve it locally -->
<main>
<h1>Disconnect retry drill</h1>
<div id="log" aria-live="polite" aria-atomic="true"></div>
<div id="status" aria-live="assertive" aria-atomic="true"></div>
<article id="bubble" aria-labelledby="bubble-label">
<h2 id="bubble-label">Assistant</h2>
<p id="partial"></p>
<p id="caret" hidden class="caret">Generating</p>
</article>
<form id="composer">
<label for="prompt">Message</label>
<textarea id="prompt" rows="3">Explain retry UX in one paragraph.</textarea>
<button type="submit" id="send">Send</button>
<button type="button" id="stop" hidden>Stop</button>
<button type="button" id="retry" hidden>Retry from partial</button>
</form>
</main>
Serve it with anything that will not invent extra headers:
python3 -m http.server 4173
# then open http://127.0.0.1:4173/disconnect-retry.html
The reader that created my bug looked roughly like this. I am labeling it as a reproduction, not as production telemetry.
const ui = {
status: "idle",
partial: "",
controller: null,
};
const statusEl = document.querySelector("#status");
const partialEl = document.querySelector("#partial");
const caretEl = document.querySelector("#caret");
const sendBtn = document.querySelector("#send");
const stopBtn = document.querySelector("#stop");
const retryBtn = document.querySelector("#retry");
const promptEl = document.querySelector("#prompt");
function render() {
const streaming = ui.status === "streaming";
const disconnected = ui.status === "disconnected";
caretEl.hidden = !streaming;
stopBtn.hidden = !streaming;
retryBtn.hidden = !disconnected;
sendBtn.disabled = streaming;
promptEl.disabled = streaming;
partialEl.textContent = ui.partial;
if (disconnected) {
statusEl.textContent =
"The stream disconnected. Retry is available in the composer.";
retryBtn.focus();
}
}
async function readWithIdleLimit(url, body, ms) {
ui.controller = new AbortController();
const idle = setTimeout(() => ui.controller.abort("idle-timeout"), ms);
try {
const res = await fetch(url, {
method: "POST",
body,
signal: ui.controller.signal,
});
if (!res.body) throw new Error("No readable body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
ui.partial += decoder.decode(value, { stream: true });
render();
}
ui.status = "complete";
statusEl.textContent = "Reply finished.";
promptEl.focus();
} catch (err) {
const aborted = err?.name === "AbortError";
ui.status = "disconnected";
ui.reason = aborted ? "idle-timeout" : "network";
} finally {
clearTimeout(idle);
ui.controller = null;
render();
}
}
The first version of my demo never called render() in finally. Guess what the caret did? It kept blinking because the streaming branch was the only place that knew how to hide it. That is the whole bug in one missed cleanup.
Three bugs stacked into one frozen cursor
I kept finding new symptoms until I listed the stack in order. If you only patch the last one, the first two will still trap a keyboard user.
1. The reader died without a named UI state
fetch rejecting is not a user-facing state. My catch logged AbortError and returned, which left isStreaming === true in React because nothing dispatched a disconnect. Have you ever filtered those aborts as “expected,” then wondered why the Stop button never re-enabled Send?
The reusable technique is boring and reliable: every exit from read() must map onto complete, disconnected, or error. No implicit fall-through. I now put that mapping in finally, not in the happy-path done branch.
2. Retry lived outside the tab order
The chip was a div with onClick, painted over the bubble so it could sit on the last line of tokens. Beautiful in a screenshot. Invisible to Tab. Pointer-independent actions are not a slogan here; they are the difference between a recovery control and a hover souvenir.
I moved Retry into the composer form, after Stop, before the next Send. Same visual emphasis if you want it, but the focus order is now:
[ prompt textarea ] -> [ Send ] -> [ Stop while streaming ] -> [ Retry after disconnect ]
If Retry is not in that line, I treat the feature as unfinished. Would you ship a form submit that only works on double-click?
3. The live region never received the failure
I had aria-busy="true" on the transcript from an older experiment, and I had a polite log that only echoed tokens. Disconnect did not copy a sentence into an assertive region. Screen readers therefore heard a story that stopped mid-clause and never resumed. Users asked, reasonably, whether the app had crashed.
The rule I use now: token text can be polite and throttled; state changes that enable a new button are assertive and atomic. “Generating” is not the same utterance as “The stream disconnected. Retry is available.”
Semantic retry, not a chip you never Tab to
Here is the markup I actually want in the composer after a drop. It is not clever. It is reachable.
<div role="group" aria-label="Recover from disconnected stream">
<p id="disconnect-copy">
The connection ended after “then you should”. You can retry or edit the prompt.
</p>
<button type="button" id="retry" aria-describedby="disconnect-copy">
Retry from partial
</button>
<button type="button" id="edit-partial">Move partial into the composer</button>
</div>
And the focus rule, which I keep next to the reducer:
- Entering
streaming— move focus to Stop, once. - Entering
disconnectedorerror— move focus to Retry, once. - Entering
completeoridle— move focus back to the textarea. - Never steal focus on every token. That is how you make the page unusable.
I also clear aria-busy in the same render that hides the caret. A busy transcript with a dead reader is the accessibility version of a spinner over a crashed request.
A tiny state machine you can unit-test without a browser
If you do not want to click through VoiceOver on every pull request, extract the transitions. This is deliberately small so you can paste it into a test file.
export function nextStreamUi(state, event) {
switch (event.type) {
case "SEND":
return { status: "streaming", startedAt: event.now, partial: "" };
case "TOKEN":
if (state.status !== "streaming") return state;
return { ...state, partial: state.partial + event.chunk };
case "DISCONNECT":
if (state.status !== "streaming") return state;
return {
status: "disconnected",
reason: event.reason,
partial: state.partial,
retryable: true,
};
case "RETRY":
if (state.status !== "disconnected" || !state.retryable) return state;
return { status: "streaming", startedAt: event.now, partial: state.partial };
case "FINISH":
if (state.status !== "streaming") return state;
return { status: "complete", text: state.partial };
default:
return state;
}
}
Proposed tests, labeled as such because I want you to run them in your own runner:
-
TOKENafterDISCONNECTmust not append. That is how duplicate sentences appear on reconnect. -
RETRYfromcompletemust no-op. Otherwise a late click duplicates the turn. -
DISCONNECTfromidlemust no-op. Otherwise a stale abort paints a ghost Retry.
That last case is the cousin of the Stop-button lie: an abort that arrives after you already settled will reopen a disabled composer if you are not strict.
Environment-specific QA I want you to run
I am not going to pretend I certified every engine. Here is the matrix I use for this exact transition: streaming → idle abort → retry focused → composer editable.
| Environment | Transition to fail on purpose | What must happen |
|---|---|---|
| Chrome + keyboard only | Abort at 800ms | Retry receives focus, Tab does not escape to chrome |
| Firefox + keyboard only | Abort during first token | Partial text stays, caret gone |
| Safari + VoiceOver | Abort after two sentences | Assertive status reads once, not on every rerender |
| NVDA + Chrome | Retry, then Escape | Focus returns to textarea, Send enabled |
| Reduced motion | Same abort | No infinite caret animation |
Invite yourself to record the versions. Browser, OS, and assistive-technology numbers belong in the bug, plus the exact transition that failed. “Retry is broken” is not a report. “VoiceOver on macOS 14, Safari 17, abort after the first comma, status spoken twice” is a report.
Limitations, and who should not copy this
This pattern is for a single in-flight turn with a human waiting on a composer. It is the wrong shape for a batch of background jobs that should retry without stealing focus. It is also the wrong shape if your product must not send the partial prompt back to a remote host without consent.
Privacy belongs in the retry path, not in a footer. If Retry resubmits the same prompt to a free remote server, say that in the status text. I keep a local stub for the keyboard drill so I can test focus without shipping the prompt anywhere. When I do point the demo at a hosted free model and free server, I treat that as a network choice, not as a reason to hide the disconnect.
Do not use this approach if you need guaranteed uptime numbers, named model routing, or a SLA. I am not claiming any of those. A free server can go quiet; that is exactly why the UI needs a settled retry instead of a patriotic caret.
Also skip this if your “agent” is actually a multi-step tool loop that needs a blocking dialog for approvals. I already learned, the hard way, that a chip on the bubble is where keyboard users go to die. Disconnect retry and tool confirmation are different states. Smash them together and your live region will narrate the wrong story.
What I would still test tomorrow
Would a second Tab after Retry land on “Move partial into the composer,” or would it skip to the next landmark? Does the assertive region fire again if I retry and disconnect a second time? Can I cancel the retry request with Stop, and does Stop leave the reader dead instead of spinning?
Those are the questions that keep this out of principle-only accessibility writing. The frozen cursor was not a taste problem. It was a missing state, a control outside tab order, and a live region that never heard the socket die.
If you want a hosted free model and free server for the same disconnect drill, MonkeyCode is the open-source stack I used once the stub stopped surprising me. Keep the retry in the composer, announce the drop, and only then argue about tokens.
Top comments (0)