Technology Sep 04, 2026 · 12 min read

Agent Clarifying Questions Need a Focus Handoff, Not a Noisier Live Region

Last Tuesday I asked a browser agent to draft an accessible confirmation dialog, and it started streaming tokens immediately. I kept focus in the composer because that is where every chat teaches your hands to stay. Halfway through the answer, a clarifying card asked which heading level I wanted, bu...

DE
DEV Community
by babycat
Agent Clarifying Questions Need a Focus Handoff, Not a Noisier Live Region

Last Tuesday I asked a browser agent to draft an accessible confirmation dialog, and it started streaming tokens immediately. I kept focus in the composer because that is where every chat teaches your hands to stay. Halfway through the answer, a clarifying card asked which heading level I wanted, but nothing spoke. Have you ever submitted a follow-up while the agent was still waiting on a yes or no?

I hit Enter in the textarea, sent a half thought, and watched the card collapse without an error. VoiceOver was still reciting leftover tokens from the stream, so the question never interrupted that noise. Keyboard focus never left the composer, which meant the radios existed only for a pointer I was not using. This is the retrospective of that silent state change, not another aria-live pep talk.

The failure, in the order it happened

The visual transcript looked busy and healthy, which is exactly how this class of bug hides. Tokens kept painting into a markdown bubble, then a pale card slid in above the composer with two choices. I never tabbed to it because Tab from a textarea usually jumps to Send, not to a sibling that was injected later. Why would I hunt for a control that never announced itself as the new task?

Here is the sequence I could reproduce with a stubbed stream, without blaming any particular model vendor.

  1. Composer focused, aria-live="polite" attached to the streaming bubble.
  2. Token chunks replace the live-region text several times per second.
  3. The agent switches intent and renders a clarifying card as markdown paragraphs.
  4. The live region still holds the last token string, so the question is overwritten.
  5. Enter in the composer submits a user message and the card unmounts.
[ streaming bubble | aria-live polite | FOCUS stays here in <textarea> ]
[ clarifying card  | two unlabeled <p> tags | not in tab order yet     ]
[ Send button ]

The pointer path still worked, which is how this shipped past a happy-path demo. Keyboard and speech users got a different product: a composer that swallowed the agent's only question. That is not a content problem. That is turn-taking UI with one control too few.

Put the state table up front

I stopped tweaking ARIA first and wrote the turn states the interface actually has. If a state cannot answer where focus lives, it is not a state yet, it is a spinner costume. Does your chat distinguish streaming from awaiting_user, or do both look like "the model is thinking"?

Turn state Composer Question card Announcement Focus target
idle enabled, submits hidden none composer
streaming disabled, no Enter submit hidden once: "Generating answer" composer (do not jump)
awaiting_user disabled, no Enter submit visible fieldset once: "Question needs an answer" legend or first radio
error enabled hidden error text retry or composer
cancelled enabled hidden "Generation cancelled" composer

Notice what is missing on purpose: per-token announcements. Streaming text is a visual progress channel, not a speech channel, unless you like a novel read one syllable at a time. The clarifying turn is the one that must steal the microphone, because it is the only turn that cannot complete without the keyboard.

A single-file reproduction

The demo below is labeled as a local stub. It does not call a production model, and it does not prove conformance. It only makes the race obvious: a timer pretends to stream, then flips into awaiting_user while focus is still in the textarea. Load it, start the stub, and keep your hands on the keyboard the whole time.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Clarifying-card focus handoff demo</title>
  <style>
    body { font: 1rem/1.4 system-ui; max-width: 40rem; margin: 2rem auto; }
    textarea, button, [role="status"], [role="alert"] { display: block; width: 100%; margin: 0.5rem 0; }
    .card { border: 2px solid #222; padding: 1rem; margin: 1rem 0; }
    textarea:disabled { background: #f3f3f3; }
  </style>
</head>
<body>
  <h1>Accessible clarifying turn</h1>
  <div id="status" role="status" aria-atomic="true"></div>
  <div id="alert" role="alert" aria-atomic="true"></div>
  <div id="stream" aria-hidden="true"></div>
  <div id="questionHost"></div>
  <label for="composer">Message</label>
  <textarea id="composer" rows="3"></textarea>
  <button type="button" id="send">Send</button>
  <button type="button" id="cancel">Cancel generation</button>

  <script>
    /** @typedef {"idle" | "streaming" | "awaiting_user" | "error" | "cancelled"} Kind */
    /** @type {{ kind: Kind, questionId?: string }} */
    let turn = { kind: "idle" };
    const statusEl = document.getElementById("status");
    const alertEl = document.getElementById("alert");
    const streamEl = document.getElementById("stream");
    const host = document.getElementById("questionHost");
    const composer = document.getElementById("composer");
    const send = document.getElementById("send");
    let timer = 0;

    function announceStatus(text) { statusEl.textContent = ""; statusEl.textContent = text; }
    function announceError(text) { alertEl.textContent = ""; alertEl.textContent = text; }

    function render() {
      const waiting = turn.kind === "streaming" || turn.kind === "awaiting_user";
      composer.disabled = waiting;
      send.disabled = waiting;
      if (turn.kind !== "awaiting_user") host.innerHTML = "";
    }

    function startStream() {
      turn = { kind: "streaming" };
      streamEl.textContent = "";
      announceStatus("Generating answer");
      render();
      let n = 0;
      timer = window.setInterval(() => {
        n += 1;
        streamEl.textContent += " token" + n;
        // Broken behavior for comparison: uncomment to starve the question.
        // statusEl.textContent = streamEl.textContent;
        if (n === 8) {
          window.clearInterval(timer);
          askQuestion();
        }
      }, 80);
    }

    function askQuestion() {
      turn = { kind: "awaiting_user", questionId: "heading-level" };
      announceStatus("Question needs an answer");
      host.innerHTML = `
        <form class="card" id="qform">
          <fieldset>
            <legend id="qlegend" tabindex="-1">Which heading level should the dialog use?</legend>
            <label><input type="radio" name="level" value="h2" /> h2</label>
            <label><input type="radio" name="level" value="h3" /> h3</label>
          </fieldset>
          <button type="submit">Continue</button>
        </form>`;
      render();
      document.getElementById("qlegend").focus();
      document.getElementById("qform").addEventListener("submit", (event) => {
        event.preventDefault();
        const picked = new FormData(event.currentTarget).get("level");
        if (!picked) {
          announceError("Choose a heading level to continue");
          return;
        }
        turn = { kind: "idle" };
        announceStatus("Answer received, ready for the next message");
        render();
        composer.focus();
      });
    }

    send.addEventListener("click", () => {
      if (turn.kind !== "idle") return;
      startStream();
    });
    composer.addEventListener("keydown", (event) => {
      if (event.key === "Enter" && !event.shiftKey) {
        event.preventDefault();
        if (turn.kind === "idle") startStream();
      }
    });
    document.getElementById("cancel").addEventListener("click", () => {
      window.clearInterval(timer);
      turn = { kind: "cancelled" };
      announceStatus("Generation cancelled");
      render();
      composer.focus();
    });
    document.addEventListener("keydown", (event) => {
      if (event.key === "Escape" && turn.kind === "awaiting_user") {
        turn = { kind: "cancelled" };
        announceStatus("Question dismissed");
        render();
        composer.focus();
      }
    });
  </script>
</body>
</html>

If you uncomment the starved live-region line, the status node keeps echoing tokens and the later question announcement is easy to miss. That is the whole bug in one assignment. Should a progress string ever share the node that must carry the next required action?

How I replayed the timing without burning a paid quota

Streaming races are origin-sensitive, because cold connections, chunk buffering, and UI commit timing refuse to line up the same way twice. I needed a disposable host that could stream slowly enough for the card to appear mid-keystroke, and I did not want that experiment glued to a production key. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option were the throwaway origin I used to restage the same streaming to awaiting_user flip, then I copied the failure back into the stub above so the article stays runnable offline.

The product is not the fix. The fix is the state table, the second live region, and the focus handoff. If you already have a local mock that can pause a stream and inject a question, you do not need another host at all.

What the root cause actually was

Three independent mistakes stacked, which is why "add aria-live" kept failing in review. The first mistake was treating token text as status text, so polite announcements queued behind a paragraph that never finished. The second was rendering the question as markdown p tags, so there was no fieldset, no legend, and no tab stop. The third was leaving the composer enabled, so Enter still meant Send while the agent meant Answer me.

I also had a focus-loss variant that looked like the opposite bug. An early patch called card.focus() on a div without tabindex="-1", which silently did nothing in Chrome, and then a later patch focused the first radio before the name was announced. Have you watched VoiceOver say "radio button, one of two" with no legend, and trusted the user to guess the question? That is not an interruption. That is a riddle.

Root cause, compressed:

  • One live region served tokens, status, errors, and questions.
  • Interactive turns were painted, not focused, and not semantically a form.
  • The composer kept the submit keystroke that belonged to the new turn.
  • Cancel did not restore focus, so Escape felt like the page died.

The fix: typed turns, two channels, one handoff

I keep role="status" for milestones and role="alert" for failures, and I never write token leftovers into either node. The streaming bubble can stay in the DOM for eyes, with aria-hidden="true" if you already announced "Generating answer" once. When the turn becomes awaiting_user, I render a real fieldset, announce once, and move focus to the legend that has tabindex="-1". Pointer users can still click radios. Keyboard users are already inside the question.

The composer disables for both streaming and awaiting_user, which answers the Enter question without a special-case key map. Escape cancels only the interactive turn and returns focus to the composer, which is the same recovery I want after a network error. Retry is just idle plus the last user message, and it must allocate a new logical turn so a dead timer cannot resurrect a dismissed card.

Implementation notes I actually rely on:

  • Clear then set textContent on live regions so repeated identical strings still fire.
  • Use aria-atomic="true" so speech gets the whole question status, not a suffix.
  • Do not aria-live the fieldset itself; focus plus a short status is enough.
  • Keep Send disabled while waiting, or you will recreate the original Enter bug.
  • Return focus after Continue, otherwise the next stream starts from the leftover radio.

Is a modal dialog better? Sometimes, if the question is blocking and short. A modal still needs the same state machine, because a dialog opened during streaming can trap focus on a half-rendered label. The card pattern stays in flow, which is kinder when the transcript is the document. Pick one and type it. Do not mix a toast, a card, and a dialog for the same turn.

Keyboard and screen-reader regressions

I do not ship this without a matrix, and I do not treat a green Lighthouse score as speech coverage. Fill the last column with the exact build you ran, because AT plus browser plus OS is the unit, not "VoiceOver" as a brand name. The transition that failed for me was streamingawaiting_user with focus in the textarea and Caps Lock VO on.

Environment Transition Expected Observed (fill in)
Chrome + VoiceOver + macOS streamingawaiting_user Status once, focus on legend
Safari + VoiceOver + macOS Tab from legend First radio, then Continue
Firefox + NVDA + Windows Enter in composer during stream No submit
Keyboard only, no AT Escape on question Card gone, composer focused
Screen magnification Card appears Card not offscreen above a sticky header

Regression scripts I keep next to the demo:

  1. Start stub, do not touch the mouse, wait for the question, speak the legend.
  2. Tab through radios, submit without a choice, hear the alert, stay in the form.
  3. Escape, confirm composer focus, Send should work again.
  4. Repeat with the starved live-region line enabled, and notice speech never reaches the question.

If your team only click-tests the card, you will reintroduce the bug the first time someone wires markdown rendering back in. Ask whether the clarifying UI is a form in the accessibility tree, not whether it looks like a card in Figma.

Limitations, and who should not copy this

This pattern does not certify WCAG, and it does not replace a real design review for multi-step agent plans. It also steals focus, which is hostile if you fire awaiting_user for optional suggestions the user never requested. I would not use a focus handoff for inline citations, token progress, or tool-call heartbeats. Those are status. They can wait.

Skip this approach when your agent never asks questions and only streams a final bubble. Skip it in native shells that already route focus through platform alerts. Skip it if your "card" is three questions long, because a legend plus two radios is not a wizard, and you will need a different stepper with its own recovery. And please do not announce every token to make the interface feel alive. Alive is not the same as usable.

The reusable debugging move is smaller than the demo. When an AI surface fails, freeze the turn type, print focus, and print the live-region text at the moment of the transition. If those three lines disagree, you do not have an agent-quality problem. You have a chat that cannot take turns with a keyboard.

DE
Source

This article was originally published by DEV Community and written by babycat.

Read original article on DEV Community
Back to Discover

Reading List