diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx
index d199b65fc9a..4f60e29ca25 100644
--- a/packages/web-shell/client/App.test.tsx
+++ b/packages/web-shell/client/App.test.tsx
@@ -156,6 +156,8 @@ const {
blocks: [] as unknown[],
messages: [] as unknown[],
latestChatEditorProps: null as ChatEditorTestProps | null,
+ latestToolApprovalKeyboardActive: null as boolean | null,
+ latestAskUserQuestionKeyboardActive: null as boolean | null,
latestScheduledTasksProps: null as {
onRunPrompt?: (
prompt: string,
@@ -766,8 +768,30 @@ mockComponent('./components/dialogs/RewindDialog', 'RewindDialog');
mockComponent('./components/messages/AgentsMessage', 'AgentsMessage');
mockComponent('./components/messages/MemoryMessage', 'MemoryMessage');
mockComponent('./components/messages/AuthMessage', 'AuthMessage');
-mockComponent('./components/messages/ToolApproval', 'ToolApproval');
-mockComponent('./components/messages/AskUserQuestion', 'AskUserQuestion');
+// Record keyboardActive so app-level tests can assert the overlay is told to
+// grab focus when it becomes topmost (the actual focus lives in the real
+// components, covered by their own unit tests).
+vi.doMock('./components/messages/ToolApproval', async () => {
+ const React = await import('react');
+ return {
+ ToolApproval: (props: { keyboardActive?: boolean }) => {
+ testState.latestToolApprovalKeyboardActive = props.keyboardActive ?? null;
+ return React.createElement('div', {
+ 'data-web-shell-permission-panel': '',
+ });
+ },
+ };
+});
+vi.doMock('./components/messages/AskUserQuestion', async () => {
+ const React = await import('react');
+ return {
+ AskUserQuestion: (props: { keyboardActive?: boolean }) => {
+ testState.latestAskUserQuestionKeyboardActive =
+ props.keyboardActive ?? null;
+ return React.createElement('div', { 'data-web-shell-ask-panel': '' });
+ },
+ };
+});
mockComponent('./components/messages/TasksStatusMessage', 'TasksStatusMessage');
mockComponent('./components/messages/BtwMessage', 'BtwMessage');
mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay');
@@ -904,6 +928,8 @@ beforeEach(() => {
testState.blocks = [];
testState.messages = [];
testState.latestChatEditorProps = null;
+ testState.latestToolApprovalKeyboardActive = null;
+ testState.latestAskUserQuestionKeyboardActive = null;
testState.latestScheduledTasksProps = null;
testState.latestGoalsProps = null;
sidebarTokens.length = 0;
@@ -1350,10 +1376,14 @@ describe('App session callbacks', () => {
editorFocus.mockClear();
act(() => vi.runOnlyPendingTimers());
+ // The editor isn't refocused while an approval is pending; instead the app
+ // tells the approval overlay to take focus (keyboardActive), so a stray
+ // keystroke can't send a message past the pending approval.
expect(editorFocus).not.toHaveBeenCalled();
- expect(document.activeElement).toBe(
+ expect(
document.querySelector('[data-testid="approval-overlay"]'),
- );
+ ).not.toBeNull();
+ expect(testState.latestToolApprovalKeyboardActive).toBe(true);
});
it('does not show missing-session state for non-404/410 errors', async () => {
@@ -4178,7 +4208,10 @@ describe('App session callbacks', () => {
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
- it('moves focus to the approval overlay when it appears', async () => {
+ it('marks the approval overlay keyboard-active when it appears', async () => {
+ // Focus itself is owned by ToolApproval/AskUserQuestion (covered by their
+ // own tests); the app's job is to render the overlay and tell it to grab
+ // focus (keyboardActive) once it's the topmost surface.
const { rerender } = renderApp();
await flush();
@@ -4188,9 +4221,31 @@ describe('App session callbacks', () => {
await Promise.resolve();
});
- const overlay = document.querySelector('[data-testid="approval-overlay"]');
- expect(overlay).not.toBeNull();
- expect(document.activeElement).toBe(overlay);
+ expect(
+ document.querySelector('[data-testid="approval-overlay"]'),
+ ).not.toBeNull();
+ expect(testState.latestToolApprovalKeyboardActive).toBe(true);
+ });
+
+ it('marks the ask-user question overlay keyboard-active when it appears', async () => {
+ // Symmetric to the ToolApproval case: guards against askUserOverlayVisible
+ // being mis-derived (e.g. from pendingToolApproval) so the question overlay
+ // would never pull focus.
+ const { rerender } = renderApp();
+ await flush();
+
+ await act(async () => {
+ testState.blocks = [
+ makePendingPermissionBlock({ toolName: 'ask_user_question' }),
+ ];
+ rerender();
+ await Promise.resolve();
+ });
+
+ expect(
+ document.querySelector('[data-testid="approval-overlay"]'),
+ ).not.toBeNull();
+ expect(testState.latestAskUserQuestionKeyboardActive).toBe(true);
});
it('closes the panel on Escape from outside the sidebar', async () => {
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index 236d1a00226..720c3699b00 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -2536,30 +2536,24 @@ export function App({
showApprovalModeDialog,
mainView,
]);
- // Once the effect above uncovers the approval, the overlay is the topmost
- // surface but the just-unmounted panel Back button dropped focus to
.
- // Move focus onto the overlay when it becomes visible so keyboard/AT users
- // land on it. Only for ToolApproval: it drives keyboard entirely through a
- // window listener, so focusing its (tabindex=-1) wrapper is safe and gives AT
- // a landing spot without confirming (Enter arms first, confirms second — a
- // focused button would confirm on the first press). AskUserQuestion instead
- // manages its own focus across its options/input, so stealing focus to the
- // wrapper would break its arrow-key navigation.
- const approvalOverlayRef = useRef(null);
+ // Whether each approval overlay is the topmost (visible, uncovered) one. The
+ // overlay components consume this as `keyboardActive`: when it flips true — on
+ // appearance, or once a panel/dialog that was covering it closes — they pull
+ // keyboard focus to their own safe-default option. Focus handling now lives in
+ // ToolApproval/AskUserQuestion (their keyboard handling is focus-scoped), so
+ // the app no longer focuses the wrapper element directly.
const toolApprovalOverlayVisible =
pendingToolApproval !== null &&
!activePanel &&
modelDialogMode === null &&
!showApprovalModeDialog &&
mainView === 'chat';
- const prevToolApprovalOverlayVisibleRef = useRef(toolApprovalOverlayVisible);
- useEffect(() => {
- const wasVisible = prevToolApprovalOverlayVisibleRef.current;
- prevToolApprovalOverlayVisibleRef.current = toolApprovalOverlayVisible;
- if (toolApprovalOverlayVisible && !wasVisible) {
- approvalOverlayRef.current?.focus();
- }
- }, [toolApprovalOverlayVisible]);
+ const askUserOverlayVisible =
+ pendingAskUserApproval !== null &&
+ !activePanel &&
+ modelDialogMode === null &&
+ !showApprovalModeDialog &&
+ mainView === 'chat';
const [showMemoryDialog, setShowMemoryDialog] = useState(false);
const [showAuthDialog, setShowAuthDialog] = useState(false);
const showAuthDialogRef = useRef(showAuthDialog);
@@ -7061,13 +7055,11 @@ export function App({
)}
{/* Only render the outer session's approval on the chat
view. Under a full-page view (split / scheduled tasks)
- it would sit hidden yet still own global keyboard
- shortcuts — a keypress could confirm an unseen
- approval. Each split pane surfaces its own approval. */}
+ it would sit hidden and unreachable. Each split pane
+ surfaces its own approval. `keyboardActive` tells the
+ overlay to grab focus only when it's the topmost one. */}
{pendingToolApproval && mainView === 'chat' && (
@@ -7075,13 +7067,12 @@ export function App({
request={pendingToolApproval}
onConfirm={handleConfirm}
variant="floating"
+ keyboardActive={toolApprovalOverlayVisible}
/>
)}
{pendingAskUserApproval && mainView === 'chat' && (
@@ -7089,6 +7080,7 @@ export function App({
request={pendingAskUserApproval}
onConfirm={handleConfirm}
variant="floating"
+ keyboardActive={askUserOverlayVisible}
/>
)}
diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx
index bafcf96c2e2..75903dcb6f3 100644
--- a/packages/web-shell/client/components/ChatPane.test.tsx
+++ b/packages/web-shell/client/components/ChatPane.test.tsx
@@ -205,6 +205,7 @@ vi.mock('./messages/AskUserQuestion', () => ({
AskUserQuestion: (props: any) => (
props.onConfirm(props.request.id, 'opt')}
>
ask
@@ -581,6 +582,12 @@ describe('ChatPane', () => {
};
render();
expect(testid('ask-approval')).not.toBeNull();
+ // Like the tool-approval path, a pane's question must not auto-grab focus —
+ // several panes can show at once and stealing focus would yank it from the
+ // pane the user is in.
+ expect(testid('ask-approval')?.getAttribute('data-keyboard-active')).toBe(
+ 'false',
+ );
// AskUserQuestion is not a tool approval, so MessageList gets no inline one.
expect(testid('pane-messages')?.getAttribute('data-approval')).toBe('no');
});
diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx
index 2b05de7807b..40dd4330cce 100644
--- a/packages/web-shell/client/components/ChatPane.tsx
+++ b/packages/web-shell/client/components/ChatPane.tsx
@@ -556,9 +556,10 @@ export function ChatPane({
request={pendingToolApproval}
onConfirm={handleConfirm}
variant="floating"
- // Several panes can show approvals at once; global Enter/Escape
- // shortcuts aren't focus-scoped, so keep pane approvals
- // click-only to avoid confirming the wrong session's request.
+ // Several panes can show approvals at once; don't auto-focus one
+ // pane's approval (it would steal focus from the pane the user is
+ // in). Keyboard handling is focus-scoped, so each pane's approval
+ // is still fully keyboard-operable once clicked/tabbed into.
keyboardActive={false}
/>
@@ -569,6 +570,7 @@ export function ChatPane({
request={pendingAskUserApproval}
onConfirm={handleConfirm}
variant="floating"
+ keyboardActive={false}
/>
)}
diff --git a/packages/web-shell/client/components/messages/AskUserQuestion.module.css b/packages/web-shell/client/components/messages/AskUserQuestion.module.css
index 98573446376..7fb9eb38a60 100644
--- a/packages/web-shell/client/components/messages/AskUserQuestion.module.css
+++ b/packages/web-shell/client/components/messages/AskUserQuestion.module.css
@@ -166,14 +166,29 @@
display: flex;
align-items: flex-start;
gap: 10px;
+ width: 100%;
min-height: 36px;
padding: 7px 12px;
+ border: 0;
border-radius: 12px;
+ background: transparent;
color: var(--foreground);
cursor: pointer;
+ font-family: var(--font-sans, system-ui, sans-serif);
font-size: 14px;
font-weight: 500;
line-height: 22px;
+ text-align: left;
+ appearance: none;
+}
+
+.option:hover {
+ background: var(--accent);
+}
+
+.option:focus-visible {
+ outline: 2px solid var(--agent-blue-500);
+ outline-offset: -2px;
}
.optionActive,
@@ -281,6 +296,30 @@
outline: none;
}
+/* The "Other" row's clickable label when not editing — a real so it's
+ keyboard-reachable, styled to read like the option label it replaces. */
+.customTrigger {
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--foreground);
+ font-family: var(--font-sans, system-ui, sans-serif);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 22px;
+ text-align: left;
+ cursor: pointer;
+ appearance: none;
+}
+
+.customTrigger:focus-visible {
+ outline: 2px solid var(--agent-blue-500);
+ outline-offset: 2px;
+}
+
.customInput:focus {
border-bottom-color: var(--agent-blue-500);
}
diff --git a/packages/web-shell/client/components/messages/AskUserQuestion.test.tsx b/packages/web-shell/client/components/messages/AskUserQuestion.test.tsx
new file mode 100644
index 00000000000..a6a6e493ff4
--- /dev/null
+++ b/packages/web-shell/client/components/messages/AskUserQuestion.test.tsx
@@ -0,0 +1,449 @@
+// @vitest-environment jsdom
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { I18nProvider } from '../../i18n';
+import type { PermissionRequest } from '../../adapters/types';
+import { AskUserQuestion } from './AskUserQuestion';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const request: PermissionRequest = {
+ id: 'req-1',
+ content: [],
+ options: [
+ { id: 'submit', label: 'Submit', kind: 'allow_once' },
+ { id: 'cancel', label: 'Cancel', kind: 'reject_once' },
+ ],
+ rawInput: {
+ questions: [
+ {
+ question: 'Pick a color',
+ header: 'Color',
+ options: [
+ { label: 'Red', description: 'warm' },
+ { label: 'Blue', description: 'cool' },
+ ],
+ },
+ ],
+ },
+};
+
+const multiRequest: PermissionRequest = {
+ id: 'req-multi',
+ content: [],
+ options: [
+ { id: 'submit', label: 'Submit', kind: 'allow_once' },
+ { id: 'cancel', label: 'Cancel', kind: 'reject_once' },
+ ],
+ rawInput: {
+ questions: [
+ {
+ question: 'Pick options',
+ header: 'Options',
+ options: [
+ { label: 'Option A', description: 'a' },
+ { label: 'Option B', description: 'b' },
+ { label: 'Option C', description: 'c' },
+ ],
+ multiSelect: true,
+ },
+ ],
+ },
+};
+
+let root: Root | null = null;
+let container: HTMLDivElement | null = null;
+let onConfirm: ReturnType;
+
+beforeEach(() => {
+ onConfirm = vi.fn();
+});
+
+afterEach(() => {
+ act(() => root?.unmount());
+ container?.remove();
+ root = null;
+ container = null;
+});
+
+function rerender(
+ keyboardActive?: boolean,
+ req: PermissionRequest = request,
+): void {
+ act(() =>
+ root!.render(
+
+
+ ,
+ ),
+ );
+}
+
+function render(
+ keyboardActive?: boolean,
+ req: PermissionRequest = request,
+): void {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ rerender(keyboardActive, req);
+}
+
+function optionButtons(): HTMLButtonElement[] {
+ return Array.from(
+ container!.querySelectorAll(
+ '[data-web-shell-ask-option]',
+ ),
+ );
+}
+
+function submitButton(): HTMLButtonElement | null {
+ return (
+ Array.from(container!.querySelectorAll('button')).find(
+ (b) => b.textContent === 'Submit' || b.textContent === '提交',
+ ) ?? null
+ );
+}
+
+function pressKey(target: Element, key: string): void {
+ act(() => {
+ target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
+ });
+}
+
+describe('AskUserQuestion accessibility', () => {
+ it('exposes an alertdialog of real buttons and focuses the first option', () => {
+ render(undefined);
+ const panel = container!.querySelector('[data-web-shell-ask-panel]');
+ expect(panel?.getAttribute('role')).toBe('alertdialog');
+
+ // Two answer options + the "Other" trigger.
+ const opts = optionButtons();
+ expect(opts).toHaveLength(3);
+ expect(opts.every((o) => o.tagName === 'BUTTON')).toBe(true);
+ expect(document.activeElement).toBe(opts[0]);
+ });
+
+ it('exposes single-select options as radios in a radiogroup', () => {
+ render(undefined);
+ const panel = container!.querySelector('[data-web-shell-ask-panel]')!;
+ expect(panel.querySelector('[role="radiogroup"]')).not.toBeNull();
+
+ const opts = optionButtons();
+ // Radios (not toggle buttons) convey mutual exclusivity; the default first
+ // option is checked.
+ expect(opts[0]!.getAttribute('role')).toBe('radio');
+ expect(opts[0]!.getAttribute('aria-checked')).toBe('true');
+ expect(opts[1]!.getAttribute('aria-checked')).toBe('false');
+ expect(opts[0]!.hasAttribute('aria-pressed')).toBe(false);
+ });
+
+ it('arrow keys change the single-select answer, not just the highlight', () => {
+ // Radiogroup contract: arrow keys move focus AND selection. aria-checked
+ // must follow the option the user moved to, and Submit must send it — not
+ // the originally-checked default.
+ render(undefined);
+ const opts = optionButtons();
+ pressKey(opts[0]!, 'ArrowDown'); // Red -> Blue
+
+ expect(opts[1]!.getAttribute('aria-checked')).toBe('true');
+ expect(opts[0]!.getAttribute('aria-checked')).toBe('false');
+
+ act(() => {
+ submitButton()!.click();
+ });
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'submit', { '0': 'Blue' });
+ });
+
+ it('Home/End change the single-select answer too (radiogroup contract)', () => {
+ render(undefined);
+ const opts = optionButtons();
+ pressKey(opts[0]!, 'ArrowDown'); // -> Blue, answer=Blue
+ expect(opts[1]!.getAttribute('aria-checked')).toBe('true');
+
+ // Home jumps to the first option and commits it as the answer.
+ pressKey(opts[1]!, 'Home');
+ expect(document.activeElement).toBe(opts[0]);
+ expect(opts[0]!.getAttribute('aria-checked')).toBe('true');
+ expect(opts[1]!.getAttribute('aria-checked')).toBe('false');
+
+ act(() => {
+ submitButton()!.click();
+ });
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'submit', { '0': 'Red' });
+ });
+
+ it('moving to "Other" clears the regular single-select answer', () => {
+ render(undefined);
+ const opts = optionButtons(); // [Red, Blue, "Other" trigger]
+ pressKey(opts[0]!, 'ArrowDown'); // -> Blue, checked
+ expect(opts[1]!.getAttribute('aria-checked')).toBe('true');
+
+ // Arrow onto "Other": no regular option may stay checked while focus is on
+ // "Other" (the custom answer isn't committed until the user types it).
+ pressKey(opts[1]!, 'ArrowDown'); // -> Other
+ expect(document.activeElement).toBe(opts[2]);
+ expect(opts[0]!.getAttribute('aria-checked')).toBe('false');
+ expect(opts[1]!.getAttribute('aria-checked')).toBe('false');
+ });
+
+ it('clicking the "Other" row padding (not just the trigger) opens the input', () => {
+ render(undefined);
+ const trigger = optionButtons()[2]; // custom trigger button
+ const row = trigger.parentElement!; // the .option wrapper (cursor:pointer)
+ // A mouse user clicking the row's padding area must also activate "Other".
+ act(() => {
+ row.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+ expect(container!.querySelector('input')).not.toBeNull();
+ });
+
+ it('names the expanded dialog with both the tool name and the question', () => {
+ render(undefined);
+ const panel = container!.querySelector('[data-web-shell-ask-panel]')!;
+ const labelledby = panel.getAttribute('aria-labelledby');
+ expect(labelledby).toBeTruthy();
+ // aria-labelledby must reference two existing elements (tool name + question)
+ // so the tool-name context isn't dropped when the dialog is expanded.
+ const referenced = labelledby!
+ .split(' ')
+ .map((id) => document.getElementById(id));
+ expect(referenced).toHaveLength(2);
+ expect(referenced.every((el) => el !== null)).toBe(true);
+ expect(
+ referenced.some((el) => el!.textContent!.includes('Pick a color')),
+ ).toBe(true);
+ });
+
+ it('does not steal focus when keyboardActive is false (split-view panes)', () => {
+ render(false);
+ expect(optionButtons().some((o) => o === document.activeElement)).toBe(
+ false,
+ );
+ });
+
+ it('moves focus between options with arrow keys', () => {
+ render(undefined);
+ const opts = optionButtons();
+ expect(document.activeElement).toBe(opts[0]);
+
+ pressKey(opts[0]!, 'ArrowDown');
+ expect(document.activeElement).toBe(opts[1]);
+ expect(opts[1]!.tabIndex).toBe(0);
+ expect(opts[0]!.tabIndex).toBe(-1);
+ });
+
+ it('selects an option then submits the answer', () => {
+ render(undefined);
+ act(() => {
+ optionButtons()[1]!.click();
+ });
+ act(() => {
+ submitButton()!.click();
+ });
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'submit', { '0': 'Blue' });
+ });
+
+ it('picks by digit shortcut, scoped to the panel', () => {
+ render(undefined);
+ pressKey(optionButtons()[0]!, '2');
+ act(() => {
+ submitButton()!.click();
+ });
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'submit', { '0': 'Blue' });
+ });
+
+ it('ignores (cancels) on Escape', () => {
+ render(undefined);
+ pressKey(optionButtons()[0]!, 'Escape');
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'cancel', undefined);
+ });
+
+ it('jumps to first/last with Home/End', () => {
+ render(undefined);
+ const opts = optionButtons(); // [Red, Blue, "Other" trigger]
+ expect(document.activeElement).toBe(opts[0]);
+
+ pressKey(opts[0]!, 'End');
+ expect(document.activeElement).toBe(opts[2]);
+ expect(opts[2]!.tabIndex).toBe(0);
+
+ pressKey(opts[2]!, 'Home');
+ expect(document.activeElement).toBe(opts[0]);
+ expect(opts[0]!.tabIndex).toBe(0);
+ });
+
+ it('restores the selected option when re-activated, not the safe default', () => {
+ // Mirrors the ToolApproval guard: a covering panel flips keyboardActive
+ // false then true; focus must return to the option the user had selected,
+ // not snap back to the default (which would silently change what Enter
+ // submits).
+ render(undefined); // keyboardActive=true (topmost)
+ const opts = optionButtons(); // [Red, Blue, "Other" trigger]
+ pressKey(opts[0]!, 'ArrowDown');
+ expect(document.activeElement).toBe(opts[1]);
+
+ rerender(false); // a covering panel opens
+ rerender(true); // it closes
+
+ expect(document.activeElement).toBe(opts[1]);
+ });
+
+ it('restores focus to the "Other" trigger when re-activated', () => {
+ // Covers the focus effect's customRef branch (idx === options.length): when
+ // the "Other" option is current and a covering panel closes, focus must
+ // return to its trigger rather than falling back to body/an option.
+ render(undefined);
+ const opts = optionButtons(); // [Red, Blue, "Other" trigger]
+ pressKey(opts[0]!, 'End'); // End → last item = the "Other" trigger
+ expect(document.activeElement).toBe(opts[2]);
+
+ rerender(false);
+ rerender(true);
+
+ expect(document.activeElement).toBe(opts[2]);
+ });
+
+ it('focuses the first option when a new question arrives while active', () => {
+ // Symmetric to the ToolApproval guard. The focus effect reads
+ // selectedIdxRef.current (written by a separate reset effect), so an
+ // effect-ordering refactor could silently break focus on new-question
+ // arrival — lock the behavior in.
+ render(undefined);
+ const opts = optionButtons();
+ pressKey(opts[0]!, 'ArrowDown');
+ expect(document.activeElement).toBe(opts[1]);
+
+ rerender(true, { ...request, id: 'req-2' });
+ expect(document.activeElement).toBe(optionButtons()[0]);
+ });
+
+ it('advances on rapid repeated ArrowDown without a re-render in between', () => {
+ // Regression: moveSelection must write selectedIdxRef synchronously, else a
+ // held key (repeating faster than React re-renders) reads a stale ref and
+ // the cursor sticks. Two keydowns in one act() run before any re-render.
+ render(undefined);
+ const opts = optionButtons();
+ expect(document.activeElement).toBe(opts[0]);
+
+ act(() => {
+ opts[0]!.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
+ );
+ opts[0]!.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
+ );
+ });
+ expect(document.activeElement).toBe(opts[2]);
+ });
+
+ it('does not treat digits as shortcuts while typing in the custom input', () => {
+ render(undefined);
+ // Reveal the "Other" input.
+ act(() => {
+ optionButtons()[2]!.click();
+ });
+ const input = container!.querySelector('input');
+ expect(input).not.toBeNull();
+
+ const event = new KeyboardEvent('keydown', {
+ key: '1',
+ bubbles: true,
+ cancelable: true,
+ });
+ act(() => {
+ input!.dispatchEvent(event);
+ });
+ // isEditableTarget exempts the input: the digit is typed, not a shortcut
+ // (an unguarded handler would have called preventDefault).
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('ignores option shortcuts when focus is on an action button, not an option', () => {
+ render(undefined);
+ // Click Blue so it is the committed single-select answer (arrow keys only
+ // move the highlight; they don't change the answer).
+ act(() => {
+ optionButtons()[1]!.click();
+ });
+ const submit = submitButton()!;
+ submit.focus();
+
+ // Escape on Submit must not cancel the question...
+ act(() =>
+ submit.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Escape',
+ bubbles: true,
+ cancelable: true,
+ }),
+ ),
+ );
+ expect(onConfirm).not.toHaveBeenCalled();
+
+ // ...and a digit must not silently overwrite the selected answer.
+ act(() =>
+ submit.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: '1',
+ bubbles: true,
+ cancelable: true,
+ }),
+ ),
+ );
+ act(() => submit.click());
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'submit', { '0': 'Blue' });
+ });
+});
+
+describe('AskUserQuestion multi-select', () => {
+ it('uses group + toggle-button semantics, not radiogroup', () => {
+ render(undefined, multiRequest);
+ const panel = container!.querySelector('[data-web-shell-ask-panel]')!;
+ expect(panel.querySelector('[role="group"]')).not.toBeNull();
+ expect(panel.querySelector('[role="radiogroup"]')).toBeNull();
+
+ // Multi-select options are toggle buttons (aria-pressed), not radios.
+ const opts = optionButtons();
+ expect(opts[0]!.getAttribute('aria-pressed')).toBe('true'); // default: first
+ expect(opts[0]!.hasAttribute('aria-checked')).toBe(false);
+ expect(opts[0]!.getAttribute('role')).not.toBe('radio');
+ });
+
+ it('toggles options and submits the joined selection', () => {
+ render(undefined, multiRequest);
+ const opts = optionButtons();
+ // First option is selected by default.
+ expect(opts[0]!.getAttribute('aria-pressed')).toBe('true');
+ expect(opts[1]!.getAttribute('aria-pressed')).toBe('false');
+
+ // Toggle Option B on, then Option A off.
+ act(() => {
+ opts[1]!.click();
+ });
+ expect(opts[1]!.getAttribute('aria-pressed')).toBe('true');
+ act(() => {
+ opts[0]!.click();
+ });
+ expect(opts[0]!.getAttribute('aria-pressed')).toBe('false');
+
+ // Submit → only Option B remains, joined into the answer.
+ act(() => {
+ submitButton()!.click();
+ });
+ expect(onConfirm).toHaveBeenCalledWith('req-multi', 'submit', {
+ '0': 'Option B',
+ });
+ });
+});
diff --git a/packages/web-shell/client/components/messages/AskUserQuestion.tsx b/packages/web-shell/client/components/messages/AskUserQuestion.tsx
index 51a19d156c2..84fc7d0f280 100644
--- a/packages/web-shell/client/components/messages/AskUserQuestion.tsx
+++ b/packages/web-shell/client/components/messages/AskUserQuestion.tsx
@@ -1,6 +1,15 @@
-import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import {
+ useState,
+ useEffect,
+ useCallback,
+ useMemo,
+ useRef,
+ useId,
+ type KeyboardEvent as ReactKeyboardEvent,
+} from 'react';
import type { PermissionRequest } from '../../adapters/types';
import { useI18n } from '../../i18n';
+import { isEditableTarget } from '../../utils/dom';
import { localizeToolDisplayName } from './toolFormatting';
import styles from './AskUserQuestion.module.css';
@@ -19,12 +28,21 @@ interface AskUserQuestionProps {
answers?: Record,
) => void;
variant?: 'inline' | 'floating';
+ /**
+ * Whether this question should pull keyboard focus to its first option when it
+ * becomes the topmost one. Defaults to true. Split-view panes pass false so an
+ * question in one pane doesn't steal focus from the pane the user is in; like
+ * ToolApproval, keyboard handling is focus-scoped, so it stays operable once
+ * the user tabs/clicks into it.
+ */
+ keyboardActive?: boolean;
}
export function AskUserQuestion({
request,
onConfirm,
variant = 'inline',
+ keyboardActive = true,
}: AskUserQuestionProps) {
const { t } = useI18n();
const questions = useMemo(
@@ -44,12 +62,23 @@ export function AskUserQuestion({
const [customFocused, setCustomFocused] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const submittedRef = useRef(false);
+ // Roving-tabindex refs: option buttons (one per question option) plus the
+ // "Other" trigger that reveals the custom input.
+ const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
+ const customRef = useRef(null);
+ const selectedIdxRef = useRef(selectedIdx);
+ selectedIdxRef.current = selectedIdx;
+ const questionTextId = useId();
+ const headingId = useId();
useEffect(() => {
const firstQuestion = questions[0];
submittedRef.current = false;
setCollapsed(false);
setCurrentIdx(0);
+ // Sync the ref too so the focus effect (which runs in this same commit on a
+ // new request) reads the fresh index, not the previous request's selection.
+ selectedIdxRef.current = firstQuestion?.options.length ? 0 : null;
setSelectedIdx(firstQuestion?.options.length ? 0 : null);
setAnswers(
firstQuestion && !firstQuestion.multiSelect && firstQuestion.options[0]
@@ -167,6 +196,129 @@ export function AskUserQuestion({
[current, isMulti, selectedMulti, currentIdx, focusCustomInput],
);
+ // Unified option activation for click, native Enter/Space, and digit
+ // shortcuts: the "Other" row reveals/focuses the custom input; otherwise
+ // toggle (multi) or pick (single).
+ const chooseOption = useCallback(
+ (idx: number) => {
+ if (!current) return;
+ selectedIdxRef.current = idx;
+ setSelectedIdx(idx);
+ if (idx === current.options.length) {
+ focusCustomInput();
+ return;
+ }
+ if (isMulti) handleToggle(idx);
+ else handleSelectOption(idx);
+ },
+ [current, isMulti, focusCustomInput, handleToggle, handleSelectOption],
+ );
+
+ // Move the selection to a specific option index, keeping focus, the roving
+ // tabindex, and — for single-select — the committed answer in sync, so
+ // aria-checked follows focus per the radiogroup contract. Moving to a regular
+ // option commits it as the answer; moving to "Other" clears the regular
+ // answer (the custom answer isn't committed until the user types it). Shared
+ // by arrow navigation and Home/End so every path behaves identically.
+ const selectIndex = useCallback(
+ (idx: number) => {
+ if (!current) return;
+ selectedIdxRef.current = idx;
+ setSelectedIdx(idx);
+ if (idx === current.options.length) {
+ customRef.current?.focus();
+ if (!isMulti) {
+ setAnswers((prev) => {
+ if (!(currentIdx in prev)) return prev;
+ const cleared = { ...prev };
+ delete cleared[currentIdx];
+ return cleared;
+ });
+ }
+ } else {
+ optionRefs.current[idx]?.focus();
+ if (!isMulti) handleSelectOption(idx);
+ }
+ },
+ [current, currentIdx, isMulti, handleSelectOption],
+ );
+
+ const moveSelection = useCallback(
+ (delta: number) => {
+ if (!current) return;
+ const total = current.options.length + 1;
+ // Compute from the ref (kept in sync) so rapid key repeats advance
+ // correctly before re-render.
+ const base = selectedIdxRef.current ?? 0;
+ selectIndex((base + delta + total) % total);
+ },
+ [current, selectIndex],
+ );
+
+ // Focus-scoped keyboard nav (fires only while focus is inside this question):
+ // arrows/j/k move between options and the "Other" row, Home/End jump to the
+ // ends, digits pick by position, Escape ignores. Enter/Space activate the
+ // focused control natively; the custom keeps its own arrow/caret keys
+ // (guarded by isEditableTarget above).
+ const handleKeyDown = useCallback(
+ (e: ReactKeyboardEvent) => {
+ if (isEditableTarget(e.target)) return;
+ // Only react when focus is on an option (a roving-tabindex button or the
+ // "Other" trigger) — not on the action buttons (Submit/Previous/Next) or
+ // the collapse toggle. Otherwise a digit / j-k / Escape pressed while
+ // focused there would silently pick an option or cancel the question; when
+ // collapsed the options aren't even rendered, so the toggle is the only
+ // focusable element and must not trigger any of this.
+ if (!(e.target as HTMLElement).closest('[data-web-shell-ask-option]')) {
+ return;
+ }
+ if (!current) return;
+ const total = current.options.length + 1;
+ if (e.key === 'ArrowDown' || e.key === 'j') {
+ e.preventDefault();
+ moveSelection(1);
+ } else if (e.key === 'ArrowUp' || e.key === 'k') {
+ e.preventDefault();
+ moveSelection(-1);
+ } else if (e.key === 'Home') {
+ e.preventDefault();
+ selectIndex(0);
+ } else if (e.key === 'End') {
+ e.preventDefault();
+ selectIndex(total - 1);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ handleCancel();
+ } else if (e.key >= '1' && e.key <= '9') {
+ const idx = parseInt(e.key, 10) - 1;
+ if (idx < total) {
+ e.preventDefault();
+ chooseOption(idx);
+ }
+ }
+ },
+ [current, moveSelection, selectIndex, handleCancel, chooseOption],
+ );
+
+ // Pull focus to the current option (or the custom input while editing) when
+ // this question becomes the topmost one or a new request arrives. See
+ // ToolApproval's matching effect for the prev-flag reasoning.
+ const prevKeyboardActiveRef = useRef(false);
+ const prevRequestIdRef = useRef(request.id);
+ const optionCountRef = useRef(current?.options.length ?? 0);
+ optionCountRef.current = current?.options.length ?? 0;
+ useEffect(() => {
+ const wasActive = prevKeyboardActiveRef.current;
+ const prevRequestId = prevRequestIdRef.current;
+ prevKeyboardActiveRef.current = keyboardActive;
+ prevRequestIdRef.current = request.id;
+ if (!keyboardActive) return;
+ if (wasActive && request.id === prevRequestId) return;
+ const idx = selectedIdxRef.current ?? 0;
+ if (idx === optionCountRef.current) customRef.current?.focus();
+ else optionRefs.current[idx]?.focus();
+ }, [keyboardActive, request.id]);
+
if (questions.length === 0) return null;
// Check which questions have answers
@@ -218,11 +370,22 @@ export function AskUserQuestion({
className={`${styles.question} ${
variant === 'floating' ? styles.floating : ''
} ${collapsed ? styles.collapsed : ''}`}
+ data-web-shell-ask-panel
+ role="alertdialog"
+ aria-label={localizeToolDisplayName('ask_user_question', t)}
+ // aria-labelledby wins over aria-label, so when expanded name the dialog
+ // with BOTH the tool name and the question (otherwise the tool-name
+ // context is dropped). The tool-name span is display:none but accname
+ // still uses a directly-referenced hidden element's text.
+ aria-labelledby={collapsed ? undefined : `${headingId} ${questionTextId}`}
+ onKeyDown={handleKeyDown}
>
{/* Header line like CLI */}
-
?
-
+
+ ?
+
+
{localizeToolDisplayName('ask_user_question', t)}
@@ -278,7 +441,7 @@ export function AskUserQuestion({
/* Question content */
<>
{/* Question text */}
-
+
{current.question}
{isMulti && (
@@ -289,10 +452,16 @@ export function AskUserQuestion({
{t('askUser.selectAnswer')}
- {/* Options list */}
+ {/* Options list — roving tabindex. Single-select uses radio
+ semantics (radiogroup/radio + aria-checked) so screen readers
+ convey mutual exclusivity; multi-select uses toggle buttons
+ (aria-pressed). The "Other" row is a trigger that reveals a
+ text input (kept out of the button so interactive content isn't
+ nested in a button). */}
setSelectedIdx(null)}
+ role={isMulti ? 'group' : 'radiogroup'}
+ aria-labelledby={questionTextId}
>
{current.options.map((opt, i) => {
const isActive = i === selectedIdx;
@@ -301,25 +470,30 @@ export function AskUserQuestion({
: answers[currentIdx] === opt.label;
return (
-
{
+ optionRefs.current[i] = el;
+ }}
className={`${styles.option} ${
isActive ? styles.optionActive : ''
} ${isSelected ? styles.optionSelected : ''}`}
- onClick={() => {
- setSelectedIdx(i);
- if (isMulti) {
- handleToggle(i);
- } else {
- handleSelectOption(i);
- }
- }}
- onMouseEnter={() => setSelectedIdx(i)}
+ data-web-shell-ask-option
+ tabIndex={isActive ? 0 : -1}
+ role={isMulti ? undefined : 'radio'}
+ aria-checked={isMulti ? undefined : isSelected}
+ aria-pressed={isMulti ? isSelected : undefined}
+ aria-keyshortcuts={i < 9 ? String(i + 1) : undefined}
+ onClick={() => chooseOption(i)}
+ onFocus={() => setSelectedIdx(i)}
>
-
+
{isActive ? '›' : ' '}
- {i + 1}
+
+ {i + 1}
+
{opt.label}
{opt.description && (
@@ -328,7 +502,7 @@ export function AskUserQuestion({
)}
-
+
);
})}
@@ -341,15 +515,14 @@ export function AskUserQuestion({
className={`${styles.option} ${
isCustomActive ? styles.optionActive : ''
} ${hasCustomValue ? styles.optionSelected : ''}`}
- onClick={() => {
- setSelectedIdx(current.options.length);
- focusCustomInput();
- }}
- onMouseEnter={() =>
- setSelectedIdx(current.options.length)
- }
+ // The whole row is clickable (it carries cursor:pointer via
+ // styles.option), so clicks on the padding — not just the
+ // inner trigger/input — activate the "Other" option. The
+ // trigger button has no onClick of its own; its click (and
+ // native Enter/Space activation) bubbles up to here.
+ onClick={() => chooseOption(current.options.length)}
>
-
+
{isCustomActive ? '›' : ' '}
@@ -369,26 +542,45 @@ export function AskUserQuestion({
className={styles.customInput}
placeholder={t('askUser.typePlaceholder')}
value={customInputs[currentIdx] || ''}
+ aria-label={t('askUser.typePlaceholder')}
onChange={(e) =>
setCustomInputs({
...customInputs,
[currentIdx]: e.target.value,
})
}
+ // Clicking inside the input positions the caret; don't
+ // let it bubble to the row's onClick and re-trigger the
+ // option choice.
+ onClick={(e) => e.stopPropagation()}
+ onFocus={() => setSelectedIdx(current.options.length)}
onBlur={() => setCustomFocused(false)}
autoFocus
/>
) : (
- setSelectedIdx(current.options.length)}
>
{customInputs[currentIdx] ||
t('askUser.typePlaceholder')}
-
+
)}
);
diff --git a/packages/web-shell/client/components/messages/ToolApproval.module.css b/packages/web-shell/client/components/messages/ToolApproval.module.css
index 05fa9539524..2357a573062 100644
--- a/packages/web-shell/client/components/messages/ToolApproval.module.css
+++ b/packages/web-shell/client/components/messages/ToolApproval.module.css
@@ -130,10 +130,19 @@
-webkit-line-clamp: unset;
}
+/* Kept in the accessibility tree (used as the alertdialog's aria-describedby
+ and the option group's label) but visually hidden — the tool name header and
+ the option labels already convey the choice to sighted users. */
.question {
- display: none;
- color: var(--muted-foreground);
- margin: 10px 0 0;
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
}
.options {
@@ -158,10 +167,17 @@
background: transparent;
cursor: pointer;
color: var(--foreground);
+ font-family: var(--font-sans, system-ui, sans-serif);
font-size: 12px;
font-weight: 500;
line-height: 22px;
white-space: nowrap;
+ appearance: none;
+}
+
+.option:focus-visible {
+ outline: 2px solid var(--agent-blue-500);
+ outline-offset: 2px;
}
.optionPlain {
diff --git a/packages/web-shell/client/components/messages/ToolApproval.test.tsx b/packages/web-shell/client/components/messages/ToolApproval.test.tsx
index ccbd4730dba..9394267ca6e 100644
--- a/packages/web-shell/client/components/messages/ToolApproval.test.tsx
+++ b/packages/web-shell/client/components/messages/ToolApproval.test.tsx
@@ -18,17 +18,31 @@ const request: PermissionRequest = {
id: 'req-1',
content: [],
options: [
- { id: 'proceed', label: 'Proceed', kind: 'proceed_once' },
+ { id: 'proceed', label: 'Proceed', kind: 'allow_once' },
{ id: 'reject', label: 'Reject', kind: 'reject_once' },
],
};
+const execRequest: PermissionRequest = {
+ id: 'req-exec',
+ content: [],
+ toolName: 'run_shell_command',
+ title: 'run_shell_command',
+ options: [
+ { id: 'proceed', label: 'Proceed', kind: 'allow_once' },
+ { id: 'reject', label: 'Reject', kind: 'reject_once' },
+ ],
+ rawInput: {
+ command: 'rm -rf /tmp/data',
+ description: 'Delete temporary data',
+ },
+};
+
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let onConfirm: ReturnType;
beforeEach(() => {
- vi.useFakeTimers();
onConfirm = vi.fn();
});
@@ -37,48 +51,219 @@ afterEach(() => {
container?.remove();
root = null;
container = null;
- vi.useRealTimers();
});
-function render(keyboardActive?: boolean): void {
- container = document.createElement('div');
- document.body.appendChild(container);
- root = createRoot(container);
+function rerender(
+ keyboardActive?: boolean,
+ req: PermissionRequest = request,
+): void {
act(() =>
root!.render(
,
),
);
- // The keydown listener is armed after a 250ms delay.
- act(() => {
- vi.advanceTimersByTime(300);
- });
}
-function pressDigitOne(): void {
+function render(
+ keyboardActive?: boolean,
+ req: PermissionRequest = request,
+): void {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ rerender(keyboardActive, req);
+}
+
+function optionButtons(): HTMLButtonElement[] {
+ return Array.from(
+ container!.querySelectorAll(
+ '[data-web-shell-permission-option]',
+ ),
+ );
+}
+
+function pressKey(target: Element, key: string): void {
act(() => {
- window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' }));
+ target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
});
}
-describe('ToolApproval keyboard gate', () => {
- it('confirms via a global shortcut when keyboardActive (the default)', () => {
+describe('ToolApproval accessibility', () => {
+ it('exposes an alertdialog of real, focusable buttons', () => {
+ render(undefined);
+ const panel = container!.querySelector('[data-web-shell-permission-panel]');
+ expect(panel?.getAttribute('role')).toBe('alertdialog');
+
+ const opts = optionButtons();
+ expect(opts).toHaveLength(2);
+ expect(opts.every((o) => o.tagName === 'BUTTON')).toBe(true);
+ // Exactly one option is in the tab order (roving tabindex).
+ expect(opts.filter((o) => o.tabIndex === 0)).toHaveLength(1);
+ });
+
+ it('exposes the options as radios in a radiogroup (single-select)', () => {
render(undefined);
- pressDigitOne();
- expect(onConfirm).toHaveBeenCalledTimes(1);
+ const panel = container!.querySelector(
+ '[data-web-shell-permission-panel]',
+ )!;
+ expect(panel.querySelector('[role="radiogroup"]')).not.toBeNull();
+
+ const opts = optionButtons();
+ // The safe default (reject, index 0) is the checked radio.
+ expect(opts[0]!.getAttribute('role')).toBe('radio');
+ expect(opts[0]!.getAttribute('aria-checked')).toBe('true');
+ expect(opts[1]!.getAttribute('aria-checked')).toBe('false');
+ });
+
+ it('exposes the command and description to assistive tech', () => {
+ render(undefined, execRequest);
+ const panel = container!.querySelector(
+ '[data-web-shell-permission-panel]',
+ )!;
+ const describedby = panel.getAttribute('aria-describedby');
+ expect(describedby).toBeTruthy();
+
+ // SR users must hear WHAT will run, not just the question — the referenced
+ // elements include the command and the description.
+ const texts = describedby!
+ .split(' ')
+ .map((id) => document.getElementById(id)?.textContent ?? '');
+ expect(texts.some((t) => t.includes('rm -rf /tmp/data'))).toBe(true);
+ expect(texts.some((t) => t.includes('Delete temporary data'))).toBe(true);
});
- it('ignores global shortcuts when keyboardActive is false', () => {
- // Split-view panes pass keyboardActive={false} so a keypress can't confirm
- // the wrong (or an off-screen) session's approval.
+ it('emits no dangling aria-describedby references', () => {
+ // Basic approval: no command, no description. describedby must reference
+ // only elements that actually render — a dangling IDREF is an axe-core
+ // aria-valid-attr-value violation.
+ render(undefined);
+ const panel = container!.querySelector(
+ '[data-web-shell-permission-panel]',
+ )!;
+ const ids = panel.getAttribute('aria-describedby')!.split(' ');
+ expect(ids.length).toBeGreaterThan(0);
+ ids.forEach((id) => expect(document.getElementById(id)).not.toBeNull());
+ });
+
+ it('focuses the safe-default option when keyboardActive (the default)', () => {
+ render(undefined);
+ // Reject sorts first and is the safe default.
+ const opts = optionButtons();
+ expect(opts[0]?.getAttribute('data-option-id')).toBe('reject');
+ expect(document.activeElement).toBe(opts[0]);
+ });
+
+ it('does not steal focus when keyboardActive is false (split-view panes)', () => {
render(false);
- pressDigitOne();
- expect(onConfirm).not.toHaveBeenCalled();
+ expect(optionButtons().some((o) => o === document.activeElement)).toBe(
+ false,
+ );
+ });
+
+ it('confirms the clicked option', () => {
+ render(undefined);
+ act(() => {
+ optionButtons()[1]!.click();
+ });
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'proceed');
+ });
+
+ it('confirms by digit shortcut, scoped to the panel', () => {
+ render(undefined);
+ // '2' picks the second ordered option (proceed). Dispatched on a button so
+ // it bubbles to the panel's onKeyDown — a window-level keypress would not.
+ pressKey(optionButtons()[0]!, '2');
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'proceed');
+ });
+
+ it('rejects on Escape', () => {
+ render(undefined);
+ pressKey(optionButtons()[0]!, 'Escape');
+ expect(onConfirm).toHaveBeenCalledWith('req-1', 'reject');
+ });
+
+ it('moves focus between options with arrow keys (roving tabindex)', () => {
+ render(undefined);
+ const opts = optionButtons();
+ expect(document.activeElement).toBe(opts[0]);
+
+ pressKey(opts[0]!, 'ArrowDown');
+ expect(document.activeElement).toBe(opts[1]);
+ expect(opts[1]!.tabIndex).toBe(0);
+ expect(opts[0]!.tabIndex).toBe(-1);
+
+ pressKey(opts[1]!, 'ArrowUp');
+ expect(document.activeElement).toBe(opts[0]);
+ expect(opts[0]!.tabIndex).toBe(0);
+ });
+
+ it('jumps to first/last option with Home/End', () => {
+ render(undefined);
+ const opts = optionButtons();
+ expect(document.activeElement).toBe(opts[0]);
+
+ pressKey(opts[0]!, 'End');
+ expect(document.activeElement).toBe(opts[1]);
+ expect(opts[1]!.tabIndex).toBe(0);
+
+ pressKey(opts[1]!, 'Home');
+ expect(document.activeElement).toBe(opts[0]);
+ expect(opts[0]!.tabIndex).toBe(0);
+ });
+
+ it('restores the selected option when re-activated, not the safe default', () => {
+ render(undefined); // keyboardActive=true (topmost)
+ const opts = optionButtons();
+ // User moves off the default (Reject) to Proceed.
+ pressKey(opts[0]!, 'ArrowDown');
+ expect(document.activeElement).toBe(opts[1]);
+
+ // A covering panel opens (keyboardActive=false) then closes (true).
+ rerender(false);
+ rerender(true);
+
+ // Focus returns to the user's selection — it must not snap back to Reject
+ // (which would silently change what Enter confirms).
+ expect(document.activeElement).toBe(opts[1]);
+ });
+
+ it('focuses the safe default when a new request arrives while active', () => {
+ render(undefined); // keyboardActive=true (topmost)
+ const opts = optionButtons();
+ // User moves off the safe default (Reject) to Proceed.
+ pressKey(opts[0]!, 'ArrowDown');
+ expect(document.activeElement).toBe(opts[1]);
+
+ // A NEW request (different id) arrives while still active: focus must go to
+ // the new request's safe default, not the stale option index the user was on
+ // (which could map to a more permissive option in the new request).
+ rerender(true, { ...request, id: 'req-2' });
+ expect(document.activeElement).toBe(optionButtons()[0]);
+ });
+
+ it('leaves Enter to native button activation (no double-press guard)', () => {
+ render(undefined);
+ const opts = optionButtons();
+ opts[1]!.focus();
+ const event = new KeyboardEvent('keydown', {
+ key: 'Enter',
+ bubbles: true,
+ cancelable: true,
+ });
+ act(() => {
+ opts[1]!.dispatchEvent(event);
+ });
+ // handleKeyDown must not intercept Enter: the focused button activates
+ // natively on Enter, so a single press confirms. The old interactedRef
+ // double-press guard preventDefault'd the first Enter — assert that no such
+ // interception exists. (jsdom doesn't synthesize the native Enter->click, so
+ // we assert the handler leaves the event un-cancelled instead.)
+ expect(event.defaultPrevented).toBe(false);
});
});
diff --git a/packages/web-shell/client/components/messages/ToolApproval.tsx b/packages/web-shell/client/components/messages/ToolApproval.tsx
index 0371da3dfff..1cc4132ea46 100644
--- a/packages/web-shell/client/components/messages/ToolApproval.tsx
+++ b/packages/web-shell/client/components/messages/ToolApproval.tsx
@@ -1,8 +1,15 @@
-import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
+import {
+ useState,
+ useEffect,
+ useCallback,
+ useRef,
+ useMemo,
+ useId,
+ type KeyboardEvent as ReactKeyboardEvent,
+} from 'react';
import { isAgentTool } from '@qwen-code/webui/daemon-react-sdk';
import type { PermissionRequest } from '../../adapters/types';
import { useI18n } from '../../i18n';
-import { isEditableTarget } from '../../utils/dom';
import { localizeToolDisplayName } from './toolFormatting';
import styles from './ToolApproval.module.css';
@@ -11,10 +18,14 @@ interface ToolApprovalProps {
onConfirm: (id: string, selectedOption: string) => void;
variant?: 'inline' | 'floating';
/**
- * Whether this instance owns the global keyboard shortcuts (Enter/Escape/j/k/
- * digits). Defaults to true. Set false when several approvals can be mounted
- * at once (e.g. split-view panes) so a keypress can't confirm the wrong
- * session's request from behind or beside the focused one.
+ * Whether this approval should pull keyboard focus to its safe-default option
+ * when it becomes the topmost (visible) one — on appearance, or when a panel/
+ * dialog that was covering it closes. Defaults to true. Split-view panes pass
+ * false: each pane's approval stays visible side-by-side, so auto-focusing one
+ * would steal focus from the pane the user is working in. Keyboard handling
+ * itself is focus-scoped (an onKeyDown on the panel), so a keyboardActive=false
+ * approval is still fully operable by keyboard once the user tabs/clicks into
+ * it — it just never grabs focus on its own.
*/
keyboardActive?: boolean;
}
@@ -175,15 +186,19 @@ export function ToolApproval({
const requestRef = useRef(request);
requestRef.current = request;
const selectedRef = useRef(selected);
+ selectedRef.current = selected;
const submittedRef = useRef(false);
- const interactedRef = useRef(false);
+ const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
+ const headingId = useId();
+ const questionId = useId();
+ const descId = useId();
+ const commandId = useId();
useEffect(() => {
const safeDefaultIndex = getSafeDefaultIndex(
orderPermissionOptions(requestRef.current.options),
);
submittedRef.current = false;
- interactedRef.current = false;
selectedRef.current = safeDefaultIndex;
setSelected(safeDefaultIndex);
}, [request.id]);
@@ -204,68 +219,109 @@ export function ToolApproval({
[onConfirm],
);
+ const focusOption = useCallback((index: number) => {
+ const target = optionRefs.current[index];
+ if (!target) return;
+ // A bare .focus() is a no-op when the option already has focus, so a new
+ // request that lands on the same index wouldn't re-announce for screen
+ // readers. Blur first to force a re-focus indication in that edge case.
+ if (document.activeElement === target) target.blur();
+ target.focus();
+ }, []);
+
+ // Pull focus to the safe-default option when this approval becomes the
+ // topmost one — on appearance (false→true) or when a new request arrives
+ // while already active. Initializing the prev flag to false makes the first
+ // mount with keyboardActive=true count as a transition, so an approval that is
+ // already topmost on mount still focuses its default.
+ const prevKeyboardActiveRef = useRef(false);
+ const prevRequestIdRef = useRef(request.id);
+ useEffect(() => {
+ const wasActive = prevKeyboardActiveRef.current;
+ const prevRequestId = prevRequestIdRef.current;
+ prevKeyboardActiveRef.current = keyboardActive;
+ prevRequestIdRef.current = request.id;
+ if (!keyboardActive) return;
+ const requestChanged = request.id !== prevRequestId;
+ if (wasActive && !requestChanged) return;
+ // Fresh request → safe default; same request re-activated (e.g. a covering
+ // panel closed) → restore the option the user had selected rather than
+ // snapping focus back to the default and silently changing their choice.
+ focusOption(
+ requestChanged
+ ? getSafeDefaultIndex(
+ orderPermissionOptions(requestRef.current.options),
+ )
+ : selectedRef.current,
+ );
+ }, [keyboardActive, request.id, focusOption]);
+
+ const moveSelection = useCallback(
+ (delta: number) => {
+ const count = displayOptions.length;
+ // Compute from the ref (kept in sync) so rapid key repeats advance
+ // correctly even before React re-renders, and keep the state updater pure
+ // (no focus() side effect inside it).
+ const next = (selectedRef.current + delta + count) % count;
+ selectedRef.current = next;
+ setSelected(next);
+ focusOption(next);
+ },
+ [displayOptions.length, focusOption],
+ );
+
+ // Keyboard handling is scoped to the panel (onKeyDown), so it only fires while
+ // focus is inside this approval — a keypress can never confirm a different
+ // pane's request. Arrow/j/k move focus (roving tabindex); Enter/Space confirm
+ // the focused option natively; digits confirm by position; Escape rejects.
const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (e.defaultPrevented || isEditableTarget(e.target)) return;
- const currentRequest = requestRef.current;
- const currentOptions = orderPermissionOptions(currentRequest.options);
- const optCount = currentOptions.length;
- if (e.key === 'ArrowUp' || e.key === 'k') {
+ (e: ReactKeyboardEvent) => {
+ const count = displayOptions.length;
+ if (e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault();
- interactedRef.current = true;
- setSelected((s) => {
- const next = (s - 1 + optCount) % optCount;
- selectedRef.current = next;
- return next;
- });
- } else if (e.key === 'ArrowDown' || e.key === 'j') {
+ moveSelection(1);
+ } else if (e.key === 'ArrowUp' || e.key === 'k') {
e.preventDefault();
- interactedRef.current = true;
- setSelected((s) => {
- const next = (s + 1) % optCount;
- selectedRef.current = next;
- return next;
- });
- } else if (e.key === 'Enter') {
+ moveSelection(-1);
+ } else if (e.key === 'Home') {
e.preventDefault();
- if (!interactedRef.current) {
- interactedRef.current = true;
- return;
- }
- const option = currentOptions[selectedRef.current];
- if (option) confirm(option.id);
+ selectedRef.current = 0;
+ setSelected(0);
+ focusOption(0);
+ } else if (e.key === 'End') {
+ e.preventDefault();
+ const last = count - 1;
+ selectedRef.current = last;
+ setSelected(last);
+ focusOption(last);
} else if (e.key === 'Escape') {
e.preventDefault();
- const reject = currentRequest.options.find(
+ const reject = requestRef.current.options.find(
(o) => o.kind === 'reject_once' || o.kind === 'reject_always',
);
if (reject) confirm(reject.id);
} else if (e.key >= '1' && e.key <= '9') {
- const idx = parseInt(e.key) - 1;
- if (idx < optCount) {
+ const idx = parseInt(e.key, 10) - 1;
+ if (idx < count) {
e.preventDefault();
- interactedRef.current = true;
- confirm(currentOptions[idx].id);
+ confirm(displayOptions[idx].id);
}
}
},
- [confirm],
+ [displayOptions, moveSelection, confirm, focusOption],
);
- useEffect(() => {
- if (!keyboardActive) return;
- const timer = setTimeout(() => {
- window.addEventListener('keydown', handleKeyDown);
- }, 250);
- return () => {
- clearTimeout(timer);
- window.removeEventListener('keydown', handleKeyDown);
- };
- }, [handleKeyDown, keyboardActive]);
-
const isExec = isExecKind(request);
const isAgent = isAgentTool(request.toolName);
const command = getCommandFromRawInput(request);
+ const showsCommandBlock = Boolean(
+ (isExec && command) || (contentText && contentText !== request.title),
+ );
+ const questionText = isAgent
+ ? t('approval.launchAgentQuestion')
+ : isExec
+ ? t('approval.execQuestion', { tool: toolName })
+ : t('approval.changeQuestion');
return (
- ?
- {toolName}
+
+ ?
+
+
+ {toolName}
+
{descriptionText && (
-
+
{descriptionText}
)}
{isExec && command ? (
) : contentText && contentText !== request.title ? (
-
+
{contentText}
) : null}
-
- {isAgent
- ? t('approval.launchAgentQuestion')
- : isExec
- ? t('approval.execQuestion', { tool: toolName })
- : t('approval.changeQuestion')}
+
+ {questionText}
-
+ {/* radiogroup semantics — the approval choice is single-select. No label
+ on the group: the alertdialog already exposes the question via
+ aria-describedby, so labelling the container with the same text would
+ make screen readers speak the question twice. */}
+
{displayOptions.map((option, i) => {
const isSelected = i === selected;
const i18nKey = getOptionI18nKey(option);
const label = i18nKey ? t(i18nKey) : option.label;
return (
-
{
+ optionRefs.current[i] = el;
+ }}
className={`${styles.option} ${getOptionClassName(option)} ${
isSelected ? styles.optionActive : ''
}`}
data-web-shell-permission-option
data-option-id={option.id}
+ tabIndex={isSelected ? 0 : -1}
+ role="radio"
+ aria-checked={isSelected}
+ aria-keyshortcuts={i < 9 ? String(i + 1) : undefined}
onClick={() => confirm(option.id)}
+ onFocus={() => {
+ selectedRef.current = i;
+ setSelected(i);
+ }}
>
- {isSelected ? '›' : ' '}
- {i + 1}.
+
+ {isSelected ? '›' : ' '}
+
+
+ {i + 1}.
+
{label}
-
+
);
})}