diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.module.css b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.module.css
index efb78a1511..f3a68d353f 100644
--- a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.module.css
+++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.module.css
@@ -7,6 +7,12 @@
padding: 6px 8px 0;
}
+/* Focused programmatically for keyboard nav / aria-activedescendant; selection
+ is shown by the roving row highlight, so suppress the container outline. */
+.list:focus {
+ outline: none;
+}
+
.row {
display: flex;
align-items: center;
@@ -27,6 +33,17 @@
background: var(--secondary);
}
+/* Keyboard mode: the pointer yields to the keyboard, so a cursor resting on a
+ row must not paint a second highlight. Only the keyboard-selected row keeps
+ its background. */
+.keyboardOnly .row:hover {
+ background: transparent;
+}
+
+.keyboardOnly .row.selected:hover {
+ background: var(--secondary);
+}
+
.selected .modeName {
color: var(--agent-blue-500);
}
diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx
new file mode 100644
index 0000000000..cab4e3cf52
--- /dev/null
+++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.test.tsx
@@ -0,0 +1,103 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { I18nProvider } from '../../i18n';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = () => {};
+}
+
+vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
+ DAEMON_APPROVAL_MODES: ['plan', 'default', 'yolo'],
+}));
+
+const { ApprovalModeDialog } = await import('./ApprovalModeDialog');
+
+let container: HTMLDivElement | null = null;
+let root: Root | null = null;
+
+function mount(node: React.ReactNode) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => {
+ root!.render({node});
+ });
+}
+
+function rerender(node: React.ReactNode) {
+ act(() => {
+ root!.render({node});
+ });
+}
+
+function press(key: string) {
+ act(() => {
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
+ );
+ });
+}
+
+const activeDescendant = () =>
+ container!
+ .querySelector('[role="listbox"]')!
+ .getAttribute('aria-activedescendant');
+
+afterEach(() => {
+ act(() => root?.unmount());
+ container?.remove();
+ root = null;
+ container = null;
+});
+
+describe('ApprovalModeDialog', () => {
+ it('opens with the highlight on the current mode and confirms on Enter', () => {
+ const onSelect = vi.fn();
+ mount();
+
+ expect(activeDescendant()).toBe('mode-opt-1');
+
+ press('ArrowDown');
+ expect(activeDescendant()).toBe('mode-opt-2');
+ press('Enter');
+ expect(onSelect).toHaveBeenCalledWith('yolo');
+ });
+
+ it('binds aria-selected to the current mode, not the roving highlight', () => {
+ mount();
+ const selected = () =>
+ Array.from(container!.querySelectorAll('[aria-selected="true"]'));
+
+ expect(selected()).toHaveLength(1);
+ expect(selected()[0].id).toBe('mode-opt-0');
+
+ press('ArrowDown');
+ expect(selected()).toHaveLength(1);
+ expect(selected()[0].id).toBe('mode-opt-0');
+ });
+
+ it('re-syncs the highlight when the current mode changes while open', () => {
+ mount();
+ expect(activeDescendant()).toBe('mode-opt-0');
+
+ // Another client sharing the session flips approval mode while the dialog
+ // is open: the highlight (and Enter's target) must follow.
+ rerender();
+ expect(activeDescendant()).toBe('mode-opt-2');
+ });
+
+ it('stops following once the user has navigated', () => {
+ mount();
+
+ press('ArrowDown');
+ expect(activeDescendant()).toBe('mode-opt-1');
+
+ // The user owns the highlight now — a mode change must not steal it.
+ rerender();
+ expect(activeDescendant()).toBe('mode-opt-1');
+ });
+});
diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx
index f1b42a9b68..24b13785c3 100644
--- a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx
+++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx
@@ -1,6 +1,8 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
+import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
+import { dp } from './dialogStyles';
import { ModeIcon } from '../ModeIcon';
import styles from './ApprovalModeDialog.module.css';
@@ -27,32 +29,71 @@ export function ApprovalModeDialog({
description: t(`mode.desc.${id}`),
}));
- const selectedIdx = approvalModes.findIndex((m) => m.id === currentMode);
+ const currentIdx = approvalModes.findIndex((m) => m.id === currentMode);
+ const [activeIndex, setActiveIndex] = useState(
+ currentIdx >= 0 ? currentIdx : 0,
+ );
+
+ // Follow the current mode until the user first navigates: it can change
+ // while the dialog is open (e.g. another client sharing the session flips
+ // approval mode). Once the user has moved the highlight, don't steal it.
+ const userNavigatedRef = useRef(false);
+ useEffect(() => {
+ if (userNavigatedRef.current || currentIdx < 0) return;
+ setActiveIndex(currentIdx);
+ }, [currentIdx]);
+
+ const moveHighlight = (index: number) => {
+ userNavigatedRef.current = true;
+ setActiveIndex(index);
+ };
+
+ const confirm = (index: number) => {
+ const mode = approvalModes[index];
+ if (mode) onSelect(mode.id);
+ };
+
+ const { keyboardMode } = useListboxKeyboard({
+ itemCount: approvalModes.length,
+ activeIndex,
+ onActiveIndexChange: moveHighlight,
+ onConfirm: confirm,
+ });
useEffect(() => {
- const el = listRef.current?.children[selectedIdx] as
+ const el = listRef.current?.children[activeIndex] as
| HTMLElement
| undefined;
el?.scrollIntoView({ block: 'nearest' });
- }, [selectedIdx]);
+ }, [activeIndex]);
return (
0 ? `mode-opt-${activeIndex}` : undefined
+ }
aria-label={t('mode.select')}
>
- {approvalModes.map((mode) => {
- const selected = mode.id === currentMode;
+ {approvalModes.map((mode, index) => {
+ const selected = index === activeIndex;
+ const isCurrent = mode.id === currentMode;
return (
-
+
);
})}
diff --git a/packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx
new file mode 100644
index 0000000000..baa3c86b24
--- /dev/null
+++ b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.test.tsx
@@ -0,0 +1,255 @@
+// @vitest-environment jsdom
+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 { dp } from './dialogStyles';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = () => {};
+}
+
+let sessions = [
+ {
+ sessionId: 's0',
+ displayName: 'S0',
+ clientCount: 1,
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ {
+ sessionId: 's1',
+ displayName: 'S1',
+ clientCount: 1,
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ {
+ sessionId: 'me',
+ displayName: 'Current Session',
+ clientCount: 1,
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+];
+
+const deleteSessionMock = vi.fn();
+const deleteSessionsMock = vi.fn();
+const initialSessions = sessions.slice();
+
+vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
+ useConnection: () => ({ sessionId: 'me' }),
+ useSessions: () => ({
+ sessions,
+ loading: false,
+ error: undefined,
+ deleteSession: deleteSessionMock,
+ deleteSessions: deleteSessionsMock,
+ }),
+}));
+
+const { DeleteSessionDialog } = await import('./DeleteSessionDialog');
+
+let container: HTMLDivElement | null = null;
+let root: Root | null = null;
+let onDeleted: ReturnType;
+let onError: ReturnType;
+let onClose: ReturnType;
+
+function renderDialog() {
+ root!.render(
+
+
+ ,
+ );
+}
+
+function mount() {
+ onDeleted = vi.fn();
+ onError = vi.fn();
+ onClose = vi.fn();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => {
+ renderDialog();
+ });
+}
+
+function rerender() {
+ act(() => {
+ renderDialog();
+ });
+}
+
+function rows(): HTMLElement[] {
+ return Array.from(container!.querySelectorAll('[role="option"]'));
+}
+
+function dangerButton(): HTMLButtonElement {
+ return Array.from(container!.querySelectorAll('button')).find((b) =>
+ b.className.includes(dp('dialog-danger-button')),
+ ) as HTMLButtonElement;
+}
+
+function press(key: string) {
+ act(() => {
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
+ );
+ });
+}
+
+function clickRow(index: number) {
+ act(() => {
+ rows()[index].dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+}
+
+const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value',
+)!.set!;
+
+function typeFilter(value: string) {
+ act(() => {
+ const el = container!.querySelector('input')!;
+ nativeInputValueSetter.call(el, value);
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+}
+
+const isCursor = (el: HTMLElement) => el.className.includes(dp('selected'));
+const isChecked = (el: HTMLElement) => el.textContent?.includes('[x]') === true;
+
+beforeEach(() => {
+ deleteSessionMock.mockReset();
+ deleteSessionsMock.mockReset();
+});
+
+afterEach(() => {
+ act(() => root?.unmount());
+ container?.remove();
+ root = null;
+ container = null;
+ sessions = initialSessions.slice();
+});
+
+describe('DeleteSessionDialog selection', () => {
+ it('keeps the keyboard cursor separate from the checked set; Enter only toggles', () => {
+ mount();
+
+ // Opens with no highlight and nothing checked; delete stays disabled.
+ expect(rows().some(isCursor)).toBe(false);
+ expect(rows().some(isChecked)).toBe(false);
+ expect(dangerButton().disabled).toBe(true);
+
+ // The first ArrowDown lands the cursor on row 0 without checking it.
+ press('ArrowDown');
+ expect(isCursor(rows()[0])).toBe(true);
+ expect(isChecked(rows()[0])).toBe(false);
+ expect(dangerButton().disabled).toBe(true);
+
+ // Enter toggles the cursor row's checkbox — it must not delete anything.
+ press('Enter');
+ expect(isChecked(rows()[0])).toBe(true);
+ expect(dangerButton().disabled).toBe(false);
+ expect(deleteSessionsMock).not.toHaveBeenCalled();
+ expect(deleteSessionMock).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+
+ // Moving the cursor keeps prior checks intact (multi-select).
+ press('ArrowDown');
+ expect(isCursor(rows()[1])).toBe(true);
+ expect(isChecked(rows()[0])).toBe(true);
+ expect(isChecked(rows()[1])).toBe(false);
+
+ press('Enter');
+ expect(isChecked(rows()[1])).toBe(true);
+
+ // Enter on an already-checked row unchecks it.
+ press('Enter');
+ expect(isChecked(rows()[1])).toBe(false);
+ expect(isChecked(rows()[0])).toBe(true);
+ });
+
+ it('does not check the current session row', () => {
+ mount();
+
+ clickRow(2);
+ expect(isChecked(rows()[2])).toBe(false);
+ expect(dangerButton().disabled).toBe(true);
+
+ // Keyboard Enter on the current session row must not check it either.
+ press('ArrowDown');
+ press('ArrowDown');
+ press('ArrowDown');
+ expect(isCursor(rows()[2])).toBe(true);
+ press('Enter');
+ expect(isChecked(rows()[2])).toBe(false);
+ expect(dangerButton().disabled).toBe(true);
+ });
+
+ it('clears checked rows and disarms delete when the filter changes', () => {
+ mount();
+
+ clickRow(0);
+ clickRow(1);
+ expect(isChecked(rows()[0])).toBe(true);
+ expect(isChecked(rows()[1])).toBe(true);
+ expect(dangerButton().disabled).toBe(false);
+
+ typeFilter('s1');
+ expect(rows()).toHaveLength(1);
+ expect(isChecked(rows()[0])).toBe(false);
+ expect(rows().some(isCursor)).toBe(false);
+ expect(dangerButton().disabled).toBe(true);
+ });
+
+ it('prunes stale checked ids after an unfiltered session refresh', async () => {
+ mount();
+
+ clickRow(0);
+ clickRow(1);
+ expect(dangerButton().disabled).toBe(false);
+
+ sessions = [
+ {
+ sessionId: 'me',
+ displayName: 'Current Session',
+ clientCount: 1,
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ ];
+ rerender();
+
+ expect(rows()).toHaveLength(1);
+ expect(isChecked(rows()[0])).toBe(false);
+ expect(dangerButton().disabled).toBe(true);
+ });
+
+ it('deletes the checked sessions via the batch API and closes', async () => {
+ deleteSessionsMock.mockResolvedValue({
+ removed: ['s0', 's1'],
+ notFound: [],
+ errors: [],
+ });
+ mount();
+
+ clickRow(0);
+ clickRow(1);
+ expect(dangerButton().disabled).toBe(false);
+
+ await act(async () => {
+ dangerButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+
+ expect(deleteSessionsMock).toHaveBeenCalledWith(['s0', 's1']);
+ expect(onDeleted).toHaveBeenCalledWith(['s0', 's1']);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onError).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx
index 02e9a7ab20..a1be13be8a 100644
--- a/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx
+++ b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { dp } from './dialogStyles';
import { useConnection, useSessions } from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
-import { formatRelativeTime } from '../../utils/formatRelativeTime';
+import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
+import { useFilterInput } from '../../hooks/useFilterInput';
+import { SessionRow } from './SessionRow';
interface DeleteSessionDialogProps {
onDeleted: (sessionIds: string[]) => void;
@@ -10,6 +12,9 @@ interface DeleteSessionDialogProps {
onClose: () => void;
}
+const LIST_ID = 'delete-session-list';
+const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
+
export function DeleteSessionDialog({
onDeleted,
onError,
@@ -26,9 +31,15 @@ export function DeleteSessionDialog({
} = useSessions({ autoLoad: true });
const currentSessionId = connection.sessionId;
const [deleting, setDeleting] = useState(false);
- const [selectedIdx, setSelectedIdx] = useState(0);
+ // `selectedIdx` is the keyboard/hover cursor (roving highlight, -1 = none —
+ // see ResumeDialog for the rationale); `selectedIds` is the multi-select set
+ // marked for deletion (shown by the [x] checkbox).
+ const [selectedIdx, setSelectedIdx] = useState(-1);
const [selectedIds, setSelectedIds] = useState>(new Set());
- const [searchQuery, setSearchQuery] = useState('');
+ const { filterValue: filterQuery, inputProps } = useFilterInput(() => {
+ setSelectedIdx(-1);
+ setSelectedIds(new Set());
+ });
const [message, setMessage] = useState(null);
const listRef = useRef(null);
const inputRef = useRef(null);
@@ -39,27 +50,42 @@ export function DeleteSessionDialog({
const filtered = useMemo(
() =>
- searchQuery
+ filterQuery
? sessions.filter((s) => {
- const q = searchQuery.toLowerCase();
+ const q = filterQuery.toLowerCase();
return (
(s.displayName || '').toLowerCase().includes(q) ||
s.sessionId.toLowerCase().includes(q)
);
})
: sessions,
- [sessions, searchQuery],
+ [sessions, filterQuery],
+ );
+
+ const toggleSelection = useCallback(
+ (sessionId: string) => {
+ if (sessionId === currentSessionId) return;
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(sessionId)) {
+ next.delete(sessionId);
+ } else {
+ next.add(sessionId);
+ }
+ return next;
+ });
+ },
+ [currentSessionId],
);
useEffect(() => {
- if (searchQuery && selectedIds.size > 0) {
- const filteredSet = new Set(filtered.map((s) => s.sessionId));
- setSelectedIds((prev) => {
- const pruned = new Set([...prev].filter((id) => filteredSet.has(id)));
- return pruned.size === prev.size ? prev : pruned;
- });
- }
- }, [searchQuery, filtered, selectedIds.size]);
+ if (selectedIds.size === 0) return;
+ const filteredSet = new Set(filtered.map((s) => s.sessionId));
+ setSelectedIds((prev) => {
+ const pruned = new Set([...prev].filter((id) => filteredSet.has(id)));
+ return pruned.size === prev.size ? prev : pruned;
+ });
+ }, [filtered, selectedIds.size]);
useEffect(() => {
if (selectedIdx >= filtered.length && filtered.length > 0) {
@@ -78,21 +104,17 @@ export function DeleteSessionDialog({
inputRef.current?.focus();
}, []);
- const toggleSelection = useCallback(
- (sessionId: string) => {
- if (sessionId === currentSessionId) return;
- setSelectedIds((prev) => {
- const next = new Set(prev);
- if (next.has(sessionId)) {
- next.delete(sessionId);
- } else {
- next.add(sessionId);
- }
- return next;
- });
+ // Enter toggles the cursor row's checkbox; the actual (destructive) delete
+ // still requires pressing the danger button — mirroring the click behaviour.
+ const { keyboardMode } = useListboxKeyboard({
+ itemCount: filtered.length,
+ activeIndex: selectedIdx,
+ onActiveIndexChange: setSelectedIdx,
+ onConfirm: (index) => {
+ const session = filtered[index];
+ if (session) toggleSelection(session.sessionId);
},
- [currentSessionId],
- );
+ });
const handleDelete = useCallback(() => {
if (deleting) return;
@@ -191,23 +213,28 @@ export function DeleteSessionDialog({
const canDelete = !deleting && !loading && hasSelection;
return (
-
-
-
+
+
+
{t('resume.search')}:{' '}
{
- setSearchQuery(e.target.value);
- setSelectedIdx(0);
- setSelectedIds(new Set());
- }}
+ className={dp('picker-search-input')}
+ aria-label={t('resume.search')}
+ role="combobox"
+ aria-autocomplete="list"
+ aria-expanded="true"
+ aria-controls={LIST_ID}
+ aria-activedescendant={
+ selectedIdx >= 0 && selectedIdx < filtered.length
+ ? optionId(selectedIdx)
+ : undefined
+ }
+ {...inputProps}
placeholder=""
/>
-
+
{message ||
(deleting
? t('delete.deleting')
@@ -215,27 +242,34 @@ export function DeleteSessionDialog({
? t('common.loading')
: hasSelection
? t('delete.selected', { count: selectedIds.size })
- : searchQuery
+ : filterQuery
? t('delete.matches', { count: filtered.length })
: '')}
-
+
-
+
{loading && (
-
{t('common.loading')}
+
{t('common.loading')}
)}
{!loading && sessionsError && (
-
- {sessionsError.message}
-
+
{sessionsError.message}
)}
{!loading && !sessionsError && filtered.length === 0 && (
-
- {searchQuery
- ? t('delete.noMatch', { query: searchQuery })
+
+ {filterQuery
+ ? t('delete.noMatch', { query: filterQuery })
: t('delete.none')}
)}
@@ -243,55 +277,43 @@ export function DeleteSessionDialog({
filtered.map((s, i) => {
const isCurrent = s.sessionId === currentSessionId;
const isChecked = selectedIds.has(s.sessionId);
- const checkbox = isChecked ? '[x] ' : '[ ] ';
return (
-
+ {isChecked ? '[x] ' : '[ ] '}
+
+ }
+ trailing={
+ isCurrent ? (
+
+ {t('resume.current')}
+
+ ) : undefined
+ }
onClick={() => {
setSelectedIdx(i);
if (!isCurrent) toggleSelection(s.sessionId);
}}
- >
-
-
- {checkbox}
-
-
- {s.displayName || s.sessionId.slice(0, 8)}
-
- {isCurrent && (
-
- {t('resume.current')}
-
- )}
-
-
-
- {(s.updatedAt || s.createdAt) &&
- formatRelativeTime(s.updatedAt || s.createdAt || '', t)}
-
-
- {t('common.clients', { count: s.clientCount ?? 0 })}
-
- {s.hasActivePrompt && (
-
- {t('resume.activePrompt')}
-
- )}
-
-
+ onActivate={() => setSelectedIdx(i)}
+ />
);
})}
-
+