Last Tuesday I tabbed into a streaming agent chat and typed a harmless question about a local config file. The model started streaming tokens, then a pale toast asked whether a read_file tool could run. Did I notice that prompt while my caret still blinked inside the composer? I pressed Enter again because the textarea still looked hungry, and the agent read a file I never meant to grant.
This write-up is a reconstructed lab on a single-file page, not a named-product incident report with fake metrics. I wanted a reusable debugging path from symptom to root cause, because agent UIs now emit tool calls in the same stream as prose. If your confirmation lives outside the tab order, the next key still belongs to the composer. Have you ever watched a permission chip lose to a still-enabled textarea?
The failure was a focus race, not a smarter model
People keep saying agents assume too much, and the model often does exactly that. But the frontend assumed something worse: that a toast plus a live region equals informed consent. Keyboard users then hit Enter in the composer, which the app treats as “yes, run the tool” or as a second user turn. Either reading is a product bug, and neither one shows up in a happy-path screenshot.
I logged every transition with a tiny state machine instead of sprinkling more ARIA on the toast. The table below is the first artifact I trust, because pictures of chips do not show caret location. If a row cannot name the focused node, it is not a permission flow yet.
| State | Composer | Tool UI | Live region | Focus |
|---|---|---|---|---|
idle |
enabled | hidden | silent | composer |
streaming |
disabled | hidden | polite tokens | stay put |
tool_pending (broken) |
still enabled | toast, not tabbable | still announcing | composer |
tool_locked (fixed) |
inert | modal dialog | paused | first dialog control |
executing |
disabled | status only | assertive once | status, then composer |
denied |
enabled | history note | assertive once | composer |
Notice how tool_pending looks almost finished during a visual QA pass. The toast is on screen, the copy is correct, and a pointer user can click Allow. So why did my Enter key approve the wrong thing? Because focus never left the textarea, and the toast was never a dialog.
Debugging technique: a focus timeline, not another spinner
I stopped asking “is the toast visible?” and started asking “what element holds focus at each token?” That sounds obvious, yet streaming UIs fight you, because the DOM mutates while you Tab. I recorded a timeline with focusin bubbling on document, plus a queue of tool-call events from a fake stream. Can you guess the first surprise? The toast mounted during a focusin on the composer, so the browser never fired a second focusin for the prompt.
Checks I now run on every agent surface
- Snapshot
document.activeElementon every stream event, not only on click. - Freeze the live region when a tool call arrives, or leftover tokens bury the prompt.
- Count tab stops after mount; a toast with no tab index is a silent wall.
- Press Enter and Escape as if they were API contracts, then record the landed state.
- Refuse auto-approve timers; a countdown is not consent for a file or network tool.
I also drew the intended machine before writing markup, because CSS cannot rescue a missing gate. The stream is allowed to talk only while no side-effect tool is waiting.
idle -> streaming -> tool_locked -> executing -> idle
\-> tool_locked -> denied -> idle
\-> error
streaming -> (tool event) MUST pause token announcements
Root cause, in the order I actually found it
First, the confirmation was a toast. It used a role="status" region so pointer users saw it, while keyboard users never entered it. Second, the composer stayed enabled, so Enter still meant “send” during a permission beat. Third, the token live region stayed polite and talkative, so a screen reader never treated the prompt as the next task. Fourth, a five-second auto-approve tried to be helpful for demo gifs, which is how a file tool ran while I was still reading.
None of that is an ARIA shortage. It is a missing gate in the state machine. Would a louder live region have saved me? Probably not, because the caret still owned Enter, and the Stop button still meant “cancel generation,” not “deny this file.” Mixing those two jobs is how you get a second control that looks like a retry from last month’s chat bugs.
What Enter meant in each broken beat
- In
streaming, Enter in the composer queued a second user turn on top of unread tokens. - In
tool_pending, Enter never reached Allow or Deny, because those nodes were not focusable. - After auto-approve, Enter landed in a composer that had already granted
read_file. - Escape closed nothing useful, because a toast is not a modal with a cancel contract.
The fix: lock focus, pause the stream chrome, require an explicit answer
The repair is a modal tool-call dialog that opens on tool_locked, moves focus to a safer button, and sets the transcript inert. I pause polite token announcements until Allow or Deny settles the turn. I do not auto-approve, and I do not reuse Stop as Deny, because Stop already means “cancel generation” in this UI family. Deny writes a retired tool turn into the transcript, then returns focus to the composer.
Expected UI states
- Streaming prose: composer disabled or read-only, Stop visible, tokens in a polite region.
-
Tool locked: native dialog with
aria-modalbehavior, Allow/Deny, filename in the heading, background inert. - Executing: dialog closes, a single assertive status, composer still disabled until the tool returns.
- Denied: dialog closes, history shows “Tool skipped,” composer enabled, focus back in the textarea.
- Malformed tool payload: dialog stays until dismissed; do not fail open into Allow.
Default focus goes to Deny, not Allow. That feels slightly rude in a marketing gif, and that is the point. A file tool is not a cookie banner.
Minimal reproduction
The page below is labeled as a lab fixture. It fakes a stream that ends in read_file JSON so you can watch focus without a paid model. Paste it into an .html file, start a local static server, and Tab through the Send control.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Tool-call focus lock lab</title>
<style>
:root { font-family: system-ui, sans-serif; }
body { margin: 1.5rem; max-width: 42rem; }
#log { min-height: 8rem; border: 1px solid #444; padding: 0.75rem; }
#composer { width: 100%; min-height: 4rem; }
dialog::backdrop { background: rgb(0 0 0 / 45%); }
.row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
</style>
</head>
<body>
<h1>Agent transcript</h1>
<p id="status" aria-live="polite"></p>
<div id="chrome">
<div id="log" aria-live="polite" aria-relevant="additions"></div>
<label for="composer">Message</label>
<textarea id="composer"></textarea>
<div class="row">
<button id="send" type="button">Send</button>
<button id="stop" type="button" disabled>Stop</button>
</div>
</div>
<p>State: <span id="stateLabel">idle</span></p>
<p>Focus: <span id="focusLabel">-</span></p>
<dialog id="toolDialog" aria-labelledby="toolTitle">
<h2 id="toolTitle">Allow read_file?</h2>
<p>The agent wants to read <code id="toolArg">unknown</code>.</p>
<div class="row">
<button id="allow" type="button">Allow</button>
<button id="deny" type="button">Deny</button>
</div>
</dialog>
<script>
/** @typedef {"idle"|"streaming"|"tool_locked"|"executing"|"denied"} AgentState */
const els = {
log: document.getElementById("log"),
status: document.getElementById("status"),
chrome: document.getElementById("chrome"),
composer: document.getElementById("composer"),
send: document.getElementById("send"),
stop: document.getElementById("stop"),
dialog: document.getElementById("toolDialog"),
toolArg: document.getElementById("toolArg"),
stateLabel: document.getElementById("stateLabel"),
focusLabel: document.getElementById("focusLabel"),
allow: document.getElementById("allow"),
deny: document.getElementById("deny"),
};
/** @type {AgentState} */
let state = "idle";
let timer = 0;
let announceTokens = true;
function setState(next) {
state = next;
els.stateLabel.textContent = next;
const locked = next === "tool_locked";
const busy = next === "streaming" || next === "executing" || locked;
els.composer.disabled = busy;
els.send.disabled = busy;
els.stop.disabled = next !== "streaming";
els.chrome.toggleAttribute("inert", locked);
if (next !== "streaming") announceTokens = false;
}
function line(text) {
const p = document.createElement("p");
p.textContent = text;
els.log.appendChild(p);
}
document.addEventListener("focusin", () => {
const active = document.activeElement;
els.focusLabel.textContent = active ? (active.id || active.tagName) : "none";
});
function fakeStream() {
setState("streaming");
announceTokens = true;
els.status.textContent = "Streaming";
const chunks = ["Checking the repo layout.", " I need a file to answer."];
let i = 0;
timer = window.setInterval(() => {
if (state !== "streaming") return;
if (i < chunks.length) {
if (announceTokens) line(chunks[i]);
i += 1;
return;
}
window.clearInterval(timer);
openToolGate({ path: "config/dev.env" });
}, 400);
}
function openToolGate(call) {
setState("tool_locked");
announceTokens = false;
els.status.textContent = "Tool confirmation required";
els.toolArg.textContent = call.path;
els.dialog.showModal();
els.deny.focus(); // safer default
}
function closeGate() {
if (els.dialog.open) els.dialog.close();
}
els.send.addEventListener("click", () => {
if (state !== "idle" && state !== "denied") return;
line("You: " + (els.composer.value || "What is in the env file?"));
els.composer.value = "";
fakeStream();
});
els.stop.addEventListener("click", () => {
window.clearInterval(timer);
closeGate();
setState("idle");
els.status.textContent = "Generation stopped";
els.composer.focus();
});
els.allow.addEventListener("click", () => {
closeGate();
setState("executing");
els.status.textContent = "Running read_file";
window.setTimeout(() => {
line("Tool result: DEV=1 (redacted)");
setState("idle");
els.status.textContent = "Tool finished";
els.composer.focus();
}, 600);
});
els.deny.addEventListener("click", () => {
closeGate();
line("Tool skipped: read_file");
els.status.textContent = "Tool denied";
setState("idle");
els.composer.focus();
});
els.dialog.addEventListener("cancel", (event) => {
event.preventDefault();
els.deny.click();
});
</script>
</body>
</html>
Broken variant for the same lab: replace showModal() with a toast div, leave the composer enabled, and keep announceTokens = true. That is the Enter-key failure from the opening story. Do not ship the broken variant; keep it as a regression fixture beside the dialog.
Keyboard and screen-reader regressions I actually care about
I do not claim a conformance badge. I claim these transitions either hold or they do not, and I want the failing beat named in the bug.
- Tab from Send into the dialog after a tool event; Tab must not land in the inert transcript.
- Shift+Tab at the first dialog control should cycle inside the dialog, not vanish into chrome.
- Enter on Deny must not send a new chat turn, even if the textarea still looks focused on screen.
- Escape maps to Deny, never to Allow, and never to a half-closed toast.
- After Allow, focus must not remain on a disconnected button node that React already unmounted.
- While the dialog is open, the transcript live region must not keep narrating leftover tokens.
Environment notes worth attaching to a bug
If you reproduce this, send the browser, OS, and assistive-technology versions plus the exact transition that failed. A clip of the focus label in this page is more useful than a Lighthouse score. I did not run a full AT matrix for this lab, so treat the list as a checklist, not as evidence I already passed VoiceOver, NVDA, and TalkBack on every engine.
Where a free stream host actually helped
I needed a disposable stream of tool-call events so I could refresh the dialog dozens of times without burning a paid quota. 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, which is enough to host this single-file lab and replay a tool-call stream while you watch document.activeElement. I am not attaching model names, token ceilings, hardware claims, or permanence promises, because those change and I did not benchmark them here.
If you already have a local mock, you do not need that host. The state machine is the point, and a static file already exposes the Enter-key race. Use the free server only when you want the same dialog sitting in front of a real streamed tool-call payload.
Limitations, and who should not copy this
Native <dialog> still differs across browsers when you nest popovers or restore focus into a virtualized transcript. inert on a huge chat list can get expensive if you re-render every token as a React node; pause the token source instead of wrapping an enormous tree on each chunk. This gate is for tools with side effects: files, shell, email, payments. Do not block a harmless search_docs call behind a modal if your product already sandboxes that tool.
Skip this pattern if you are building a headless batch agent with no human in the loop. Skip it if your “agent” never emits tool calls from the browser. Skip it if you were about to add a default-yes countdown to make a demo gif smoother. That countdown is how I granted read_file with my face still in the composer.
The next time a streaming agent asks for a tool, ask one rude question before you ship the toast: where is focus, and which key still means Send?
Top comments (0)