I was keyboard-deep in a chat composer, waiting on a suggested patch, when the network panel blinked twice. The in-browser agent had already attached the open buffer before I could refuse the request. No dialog moved focus, and no status text named the file that just left the tab. Have you ever only learned about a context leak because DevTools had the Request tab open?
That silent-fetch failure is not a model-quality problem, and it is not a streaming-markdown problem either. It is a consent control that never became a tab stop, then lost the race against the first tool call. I treated it as a debugging retrospective, from the silent fetch back to a typed state machine you can actually operate with a keyboard.
The interaction that failed
I reproduced the bug in a single-file chat shell that can optionally send the current editor selection to a local completion endpoint. The pointer path looked fine because a tiny toast said "Using page context" for about two seconds. Keyboard users never reached that toast, while screen-reader users only heard the transcript tokens update instead. If you only test with a mouse, you will ship this exact race.
Here is the exact transition that broke, and I still use it as the first replay in the debugger:
- Focus sits in the composer after Enter, with the caret blinking on an empty follow-up line.
- The agent decides it needs page context, then POSTs the current selection without waiting.
- A toast renders in the corner with a pointer-only Dismiss control that is not a tab stop.
- Tokens arrive in an
aria-liveregion, so the toast copy never wins the speech queue. - Escape still maps to "stop generating," which cannot undo the buffer that already left the tab.
Would a checklist that only asks "is there a message" have caught any of that? It would not, because the message never became operable.
State table before any ARIA
I write the state table before I reach for a dialog role, because a role cannot invent states that the reducer does not own. This is the machine I should have drawn on the first pass, before the toast CSS went in.
| State | Visible UI | Focus owner | Announcement | Network |
|---|---|---|---|---|
idle |
composer only | composer | none | none |
requesting |
modal consent | Deny first | "The agent wants the current selection." | blocked |
allowed |
context chip plus composer | composer restored | "Selection attached. You can remove it." | may send |
denied |
composer plus retry hint | composer restored | "Page context was not sent." | no body |
failed |
error plus retry | Retry | "Context consent failed. Retry or continue without it." | aborted |
Notice that allowed is not the default, and denied is not a disabled composer. Why would we punish a refusal by trapping someone outside the input they still need?
stateDiagram-v2
[*] --> idle
idle --> requesting: NEED_CONTEXT
requesting --> allowed: ALLOW
requesting --> denied: DENY / Escape
requesting --> failed: FAIL
failed --> requesting: retry
denied --> requesting: NEED_CONTEXT
allowed --> denied: RESET / remove chip
Symptom to root cause
I debug this class of bug with the same four probes every time, because they fail in different layers and they do not need a new tracing product. Each probe answers one question the toast never could.
Probe 1: keyboard-only, mouse unplugged
Tab order never entered the toast, which I confirmed by watching the focus ring stall in the textarea. The Dismiss control was a <div onClick>, so it was not a tab stop at all. Have you shipped a "message" that a keyboard user cannot even land on?
Probe 2: document.activeElement on every transition
I logged document.activeElement from the reducer subscriber, then printed the tag name beside each state change. Focus stayed in the textarea while the fetch started, which is how a silent send feels snappy and also how it becomes invisible. Snappy is not the same as operable, right?
Probe 3: screen-reader speech log
VoiceOver spoke each streamed token from the live region and skipped the toast entirely, which the speech log made painfully obvious. NVDA did the same once the polite live region started batching those tokens into a queue. The consent copy never had a chance to win that queue, so privacy UI became decoration.
Probe 4: the consent flag race
localStorage still held pageContext: true from a previous demo session on the same origin, which I only noticed after wiping cookies failed to change the POST body. The UI treated that flag as durable consent, even though the selection hash had changed under it. Did we consent to this buffer, or to some other buffer last Tuesday?
Root cause, in one line you can put on a sticky note: consent was a boolean side effect, not a blocking, focus-managed, scope-limited state with a deny path.
The fix: a consent gate with an abort owner
The analogy I use is a locked door between the composer and the network stack, and it has to stay locked during the first tool call. Tokens may wait in the hallway, but they do not walk through until a person operates that door. Cancellation still belongs to the in-flight request, yet deny has to win if the dialog is still open. Who owns abort if Escape means two different things?
Below is a proposed single-file reducer you can paste into a React demo, and I am labeling it as a reproduction rather than a conformance claim. I have not certified it against every browser and assistive-technology pair, so please treat the matrix later as a hunt list.
type Scope = "selection" | "file";
type ConsentState =
| { status: "idle" }
| { status: "requesting"; scope: Scope; returnFocusTo: HTMLElement | null }
| { status: "allowed"; scope: Scope; selectionHash: string }
| { status: "denied"; scope: Scope }
| { status: "failed"; message: string; returnFocusTo: HTMLElement | null };
type ConsentEvent =
| { type: "NEED_CONTEXT"; scope: Scope; returnFocusTo: HTMLElement | null }
| { type: "ALLOW"; selectionHash: string }
| { type: "DENY" }
| { type: "FAIL"; message: string }
| { type: "RESET" };
function consentReducer(state: ConsentState, event: ConsentEvent): ConsentState {
switch (state.status) {
case "idle":
case "denied":
case "failed":
if (event.type === "NEED_CONTEXT") {
return {
status: "requesting",
scope: event.scope,
returnFocusTo: event.returnFocusTo,
};
}
return state;
case "requesting":
if (event.type === "ALLOW") {
return {
status: "allowed",
scope: state.scope,
selectionHash: event.selectionHash,
};
}
if (event.type === "DENY") return { status: "denied", scope: state.scope };
if (event.type === "FAIL") {
return {
status: "failed",
message: event.message,
returnFocusTo: state.returnFocusTo,
};
}
return state;
case "allowed":
if (event.type === "RESET" || event.type === "DENY") {
return { status: "denied", scope: state.scope };
}
return state;
default:
return state;
}
}
The dialog markup has to match those states, or the reducer is just theater with extra TypeScript. Deny is the first tab stop because accidental Enter should not upload a file.
function ConsentGate(props: {
state: ConsentState;
onAllow: () => void;
onDeny: () => void;
onRetry: () => void;
}) {
const denyRef = useRef<HTMLButtonElement>(null);
const headingId = useId();
const descId = useId();
useEffect(() => {
if (props.state.status === "requesting" || props.state.status === "failed") {
denyRef.current?.focus();
}
}, [props.state.status]);
if (props.state.status !== "requesting" && props.state.status !== "failed") {
return null;
}
const isFailed = props.state.status === "failed";
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
aria-describedby={descId}
className="consent-gate"
>
<h2 id={headingId}>Share page context with the agent?</h2>
<p id={descId}>
{isFailed
? props.state.message
: `The agent wants the current ${props.state.scope}. Deny stays the default.`}
</p>
<button ref={denyRef} type="button" onClick={props.onDeny}>
Deny
</button>
{!isFailed ? (
<button type="button" onClick={props.onAllow}>
Allow once
</button>
) : (
<button type="button" onClick={props.onRetry}>
Retry consent
</button>
)}
</div>
);
}
Restore focus in the parent, not inside the dialog, because the dialog unmounts on allow and deny. I keep returnFocusTo on the requesting state so a later render cannot guess the composer.
useEffect(() => {
if (state.status === "allowed" || state.status === "denied") {
queueMicrotask(() => composerRef.current?.focus());
}
}, [state.status]);
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (event.key === "Escape" && state.status === "requesting") {
event.preventDefault();
dispatch({ type: "DENY" });
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [state.status]);
A few implementation rules I now refuse to skip, because each one maps to a probe that already failed:
- Deny is the first tab stop, so a leftover Enter from the composer cannot allow by accident.
- Escape calls the same
DENYevent as the button, so pointer and keyboard share one abort owner. - "Allow once" stores a selection hash, and it never writes a durable boolean for a different buffer.
- The composer region is
inertwhile the dialog is open, which keeps Tab from wandering into streamed links. - After allow or deny, focus returns to the composer, not to a toast, and not to the transcript.
- A separate polite live region announces only the state change, never the token stream that is still painting.
What I wired around the fetch
The network function has to ask the gate, not the other way around, which is the inversion the original toast never made. This is the proposed client wrapper, still labeled as a reproduction you should run locally.
async function completeWithConsent(opts: {
prompt: string;
selection: string;
selectionHash: string;
getState: () => ConsentState;
dispatch: (event: ConsentEvent) => void;
signal: AbortSignal;
}) {
const state = opts.getState();
const allowed =
state.status === "allowed" && state.selectionHash === opts.selectionHash;
if (opts.selection && !allowed) {
opts.dispatch({
type: "NEED_CONTEXT",
scope: "selection",
returnFocusTo: document.activeElement as HTMLElement,
});
throw new DOMException("Consent required", "AbortError");
}
const body = allowed
? { prompt: opts.prompt, selection: opts.selection }
: { prompt: opts.prompt };
const response = await fetch("/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: opts.signal,
});
if (!response.ok) {
opts.dispatch({ type: "FAIL", message: `HTTP ${response.status}` });
throw new Error("completion failed");
}
return response.body;
}
Throwing AbortError on missing consent looks aggressive, and that is the point of a gate that owns the body. The composer stays enabled, the dialog owns focus, and a later Allow can re-enter the same prompt without rewriting history. Is a disabled composer really recovery, or is it just a locked door with no handle?
Where a free completion endpoint actually helped
I needed slow streams and fast streams of the same prompt so the dialog could not hide behind a skeleton. Paid keys make that rehearsal annoying when you are iterating on focus restoration, not on model quality.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I rehearsed the gate against MonkeyCode's free model access and free server option so consent still blocked the body when the first token was delayed. That is the only product dependency in this write-up, and you can replace the endpoint with any local completion server if you already have one. If you want a throwaway host for the same consent rehearsal, that free server option is one convenient path.
Expected UI states
These are the states I now screenshot beside the reducer, because a passing unit test still will not show you the focus ring.
- Idle: the composer is the only tab stop in the transcript footer, and no chip claims context is attached.
- Requesting: the dialog is centered, the background is inert, Deny is focused, and the fetch has not started.
- Allowed: a chip reads "Selection attached," focus is back in the composer, and Remove is a real button.
- Denied: there is no chip, a polite announcement fires once, and the prompt stays editable.
- Failed: Retry consent and Continue without context are both tab stops, and neither disables the input.
If the chip is a non-focusable span, you have reintroduced the toast with better copy. Can a keyboard user remove the selection without hunting through the transcript?
Keyboard and screen-reader regressions
I keep this matrix next to the demo, and I record the exact transition that failed instead of writing "tested with a screen reader." Please reproduce with your browser, operating system, and assistive-technology versions.
| Environment | Transition | Expected | Failure I am hunting |
|---|---|---|---|
| Chromium + NVDA / Windows |
idle → requesting
|
Deny spoken with the dialog name | live region stealing the speech queue |
| Safari + VoiceOver / macOS |
requesting + Escape |
deny, then composer focus | Escape stopping the wrong abort owner |
| Firefox + keyboard only |
allowed → remove chip |
composer focus, chip gone | chip is a non-focusable span |
| Mobile Safari + VoiceOver |
failed → retry |
focus moves to Retry | focus lost to the browser chrome |
Name the transition in the bug, not just the control, or the next patch will "fix" the toast color and leave the race.
Limitations, and who should not copy this
This gate is not a privacy program, a contract review, or a substitute for ignoring sensitive buffers in the editor. It will not help you if the model already lives in a privileged desktop process that can read files without the page. Do not use a modal consent pattern for every token; use it when scope changes, not when the caret moves inside the same hash.
Skip this approach when your agent never sends page context, when legal consent already happens before the editor loads, or when you are writing research strategy instead of a control. Also skip it if your "dialog" is a slide-over without aria-modal, because that pattern reintroduces the toast bug with extra CSS. A pretty overlay that you cannot tab into is still a silent fetch.
What I would check tomorrow morning
- Does Deny receive focus before any network byte leaves the tab, including preflight?
- Does Escape deny even while a generating label is visible behind the inert background?
- Does Allow once die with the current selection hash, instead of surviving a file switch?
- Does the live region announce one sentence per state, not one sentence per streamed token?
- After deny, can you still submit the prompt with an empty context body and an enabled composer?
If any answer is no, you do not have a consent control that a keyboard user can operate. You have a toast that lost a race, and the model is already holding a buffer nobody agreed to share. I still catch myself wanting to just send the selection because the answer gets sharper, then I tab once with the mouse unplugged and leave the buffer where it belongs.
Top comments (0)