I tabbed onto Retry after a sluggish timeout, and VoiceOver still described the composer as empty. The Network panel told a much less comforting story about that same impatient retry click I made. The retry had posted my unsaved buffer, even though Include current file sat visually unchecked. Have you ever trusted a checkbox that was only decorating the last request you sent?
This walkthrough is a lab reproduction, not a production postmortem with customer telemetry behind it. I wanted an accessible coding chat that could attach the current file, fail slowly, and recover without lying. The failure was quiet, repeatable, and worse than a crashed stream because the UI looked honest.
The interaction that actually failed
The composer stayed focusable. The checkbox looked unchecked. The live region said the request timed out, then offered Retry as a real button. Keyboard users could reach Retry without a pointer, which felt like a win until the POST left the tab.
Here is the exact transition I kept reproducing in the demo:
- Check Include current file, send a prompt, then uncheck the box while tokens stream.
- Let the remote turn hit a timeout instead of a clean
abort, so Retry stays available. - Press Retry without visiting the checkbox again, and inspect the JSON body.
- Hear VoiceOver announce timeout recovery, with no warning that a buffer is leaving the browser.
Would you call that a streaming bug, a forms bug, or a privacy bug? I treated it as all three, because the accessible controls were telling a different story from the payload.
State table before any ARIA debate
I put the state table above widgets on purpose. If the states are wrong, a prettier checkbox cannot save the retry path.
| Phase | Composer | Include file control | Retry | What may leave the browser | Live region |
|---|---|---|---|---|---|
idle |
enabled | reflects next request only | hidden | nothing | idle, not busy |
confirming |
disabled | keyboard operable | hidden | nothing yet | “Confirm whether to include the current file.” |
streaming |
disabled | disabled, shows snapshot | hidden | snapshot captured at send | “Generating answer.” once |
timed_out |
enabled | shows snapshot, not a guess | enabled | still nothing new | “The request timed out. Retry is available.” |
retrying |
disabled | locked to snapshot or a new confirm | disabled | only the snapshot the user confirmed | “Retrying the timed-out request.” |
denied |
enabled | unchecked and announced | hidden | prompt text only | “Current file will not be sent.” |
Notice Retry never reads the live checkbox. Retry reads a frozen snapshot, or it must open a confirm path again. That one rule would have stopped my leak.
Why a slow free endpoint made the bug obvious
Local mocks hide this class of failure because they resolve before anyone unchecks a box. I needed a remote turn that stayed open long enough for a consent change, then died with a timeout instead of a tidy cancel. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I pointed the lab at MonkeyCode as an open-source project with free model access and a free server option, which made the slow timeout easy to trigger without inventing a private cluster.
I am not attaching model names, token ceilings, or hardware claims here, because those details drift and I did not verify them for this article. The useful property for this bug is simple latency you can feel with a keyboard. If your mock answers in forty milliseconds, you will never watch a user uncheck a file while the request is still alive.
Symptom, then the lying object
The first screen-reader pass sounded fine. Timeout was announced once. Focus returned to the composer, not to a toast. Retry was a button in tab order, not a mouse-only overlay. So why did I still flinch when I opened DevTools?
The request body still had this shape:
// Lab reproduction only. This is the broken payload I logged.
{
"prompt": "Explain the timeout path in ChatRequest.",
"files": [
{
"name": "ChatRequest.ts",
"content": "export type ChatRequest = { /* unsaved buffer */ }"
}
],
"retryOf": "req_17"
}
The checkbox in the DOM was unchecked. The React state for includeFile was false. The retry helper had cloned lastRequest, and lastRequest.files was still populated from send time. Which source of truth would you trust in a code review: the widget, the atom, or the blob that actually went to the network?
Root cause: consent lived on the widget, files lived on the request
I had modeled consent like a settings flag, because that is how dark mode and density toggles work. File inclusion is not a theme. It is part of one HTTP request, and it must die with that request.
The broken helper looked roughly like this:
// Broken lab code: retry reuses the last network payload.
function retryLastRequest(last: ChatRequest, liveIncludeFile: boolean): ChatRequest {
return {
...last,
id: crypto.randomUUID(),
retryOf: last.id,
// liveIncludeFile is ignored, so unchecking does nothing on retry
};
}
Two more mistakes sat under that helper, and they only showed up with assistive tech plus a slow timeout.
- The checkbox was not
disabledduringstreaming, so it looked editable while the in-flight snapshot stayed unchanged. - Retry did not re-announce file inclusion, so a screen-reader user could activate recovery without hearing that a buffer would travel again.
Is a sticky Include checkbox convenient? Yes. Is it an accurate control once a request has left the tab? No.
Fix: bind consent to the request id
The repair is a snapshot, not a smarter aria-label. Capture consent when the user sends, store it on the request id, and refuse to serialize files unless that snapshot still says yes.
type ConsentSnapshot = {
requestId: string;
includeFile: boolean;
fileName: string | null;
grantedAt: number;
};
type ChatPhase =
| { status: "idle" }
| { status: "confirming"; requestId: string }
| { status: "streaming"; requestId: string; consent: ConsentSnapshot }
| { status: "timed_out"; requestId: string; consent: ConsentSnapshot }
| { status: "retrying"; requestId: string; consent: ConsentSnapshot }
| { status: "error"; requestId: string; message: string };
function snapshotConsent(requestId: string, includeFile: boolean, fileName: string | null): ConsentSnapshot {
return { requestId, includeFile, fileName, grantedAt: Date.now() };
}
function toNetworkBody(prompt: string, consent: ConsentSnapshot, file: { name: string; content: string } | null) {
if (!consent.includeFile || !file || file.name !== consent.fileName) {
return { prompt, files: [] as const, requestId: consent.requestId };
}
return { prompt, files: [file], requestId: consent.requestId };
}
Retry now has to choose one of two honest paths. It can resend the frozen snapshot after announcing that the same file will be included. Or it can drop into confirming again, move focus to the dialog, and require a keyboard-confirm before any buffer is attached.
function planRetry(phase: Extract<ChatPhase, { status: "timed_out" }>): ChatPhase {
if (!phase.consent.includeFile) {
return { ...phase, status: "retrying" };
}
// Do not silently reuse files just because they exist in memory.
return { status: "confirming", requestId: crypto.randomUUID() };
}
I prefer the confirm path whenever a file would leave the machine. Timeouts are confusing enough. Users should not also have to remember what a checkbox looked like forty seconds ago.
Accessible UI for the snapshot, not for the live box
A request-bound snapshot still needs a visual and spoken representation. I treated the streaming row like a receipt, not like a setting that might change the in-flight turn.
function FileConsent({ phase, includeFile, onToggle }: {
phase: ChatPhase;
includeFile: boolean;
onToggle: (next: boolean) => void;
}) {
const locked = phase.status === "streaming" || phase.status === "retrying";
const snapshotName = "consent" in phase ? phase.consent.fileName : null;
return (
<div className="consent">
<label>
<input
type="checkbox"
checked={locked ? Boolean(snapshotName) && "consent" in phase && phase.consent.includeFile : includeFile}
disabled={locked}
onChange={(event) => onToggle(event.target.checked)}
/>
Include current file in the next request
</label>
{locked && (
<p>
In-flight snapshot: {"consent" in phase && phase.consent.includeFile
? `${phase.consent.fileName} will be sent`
: "no file will be sent"}
</p>
)}
</div>
);
}
Use a real label and a real checkbox. A chip that never receives tab focus is how this pattern quietly fails. Disable the control while a snapshot is in flight so the widget cannot contradict the receipt.
Status text belongs in a polite live region that speaks phase changes, not file contents. Never dump buffer text into aria-live, because that is a privacy incident and a speech flood in the same gesture.
<div role="status" aria-live="polite" aria-atomic="true">
{statusTextFor(phase)}
</div>
On timeout, move focus back to the composer, then let Retry sit next in tab order. Do not trap focus in a skeleton overlay, and do not steal it with a toast that disappears before anyone reads the consent receipt.
Keyboard and screen-reader regressions to run
I did not record a pass/fail matrix as production evidence, so treat this as the lab script. The failing transition is always uncheck during stream → timeout → Retry.
- Tab from the prompt textarea to the checkbox, then to Send, with no pointer.
- While streaming, confirm the checkbox is disabled and the snapshot text is still in the reading order.
- Trigger a timeout, hear one announcement, and confirm focus is in the composer.
- Tab to Retry, activate it, and confirm a blocking confirm when a file would be resent.
- Cancel that confirm and inspect the network panel for an empty
filesarray. - Repeat with captions and a screen reader, because the visual unchecked state is not enough.
Suggested environments for the same transition:
- Windows 11, Firefox, NVDA
- macOS, Safari, VoiceOver
- Windows 11, Chrome, JAWS
- Keyboard only, no assistive technology, watching focus rings
If Retry posts files while the confirm dialog is still open, you are not done. If the live region reads the file body, you are also not done.
What this approach is not
A consent snapshot is an interface guarantee, not a compliance program. It will not tokenize secrets, strip .env files, or replace a legal review. It also will not help if your agent uploads a whole workspace through a different code path that bypasses the composer.
Skip this pattern when you already have a blocking, request-scoped permission dialog with server-side enforcement. Skip it when the product must never send local files, because then the checkbox should not exist. Skip it if you only ship happy-path demos that never time out, since the bug lives on the recovery path.
The free remote option is a debugging tool here, not a privacy control. Slow servers reveal races. They do not decide whether a buffer should travel.
Close the loop on Retry
After the snapshot landed, Retry stopped feeling like a magic restore button and started feeling like a second send. The checkbox finally described the next request only. The receipt described the in-flight one. VoiceOver could narrate timeout recovery without implying that the composer was empty of consequences.
If you are wiring a similar lab and want a remote turn that stays open long enough to uncheck a file, MonkeyCode’s free server option is one straightforward way to reproduce that timeout on purpose. Then keep the network panel open, because the honest UI is the payload, not the checkbox you left behind.
Top comments (0)