feat(web-shell): overhaul list-dialog interaction, keyboard nav & a11y (#6128)

* feat(web-shell): add keyboard-nav and IME-safe filter hooks

Two reusable hooks for list-style dialogs:

- useListboxKeyboard: Arrow/Home/End/Enter navigation driven by an active
  index, with a "keyboard mode" flag to suppress hover. Yields modified-key
  combos (Cmd/Ctrl/Alt/Shift), Home/End in text inputs, and Enter on focused
  buttons/links to native handling.
- useFilterInput: IME-composition-safe search state so a filtered list does
  not refire on every intermediate pinyin character (commits on compositionend).

* feat(web-shell): DialogShell Escape/backdrop close and focus management

Give every dialog shared, accessible dismissal and focus behaviour:
- Escape closes (guarded during IME composition so it cancels the
  composition, not the dialog)
- click on the backdrop closes
- Tab is trapped within the panel, wrapping at both ends
- focus moves into the dialog on open and is restored to the opener on close

* feat(web-shell): overhaul list-dialog interaction and accessibility

Unify interaction across the model, theme, approval, resume, tools, delete,
release and rewind dialogs:

- keyboard navigation via useListboxKeyboard, with a roving highlight that
  opens on the current value and does not fight the mouse
- consistent selection visuals: a single roving highlight plus a persistent
  "current" accent bar + checkmark; options are role=option divs (no stray
  focus ring)
- IME-safe search via useFilterInput; fix Chinese-input jitter in the
  resume/delete/release search boxes
- accessibility: role=listbox/option, aria-activedescendant, and aria-selected
  bound to the current value rather than the roving highlight
- destructive dialogs keep Enter non-destructive where a confirm button is the
  commit (delete/release); rewind confirms on Enter like a single-select picker

Refactors:
- extract shared SessionRow used by resume/delete/release
- rename resume-picker-* CSS primitives to picker-* (they are shared by all
  list dialogs, not resume-specific)

Adds regression tests for the model duplicate-current fix, release hover
selection, rewind Enter, the listbox/aria wiring, and the shared hooks.

* fix(web-shell): address dialog interaction review feedback

Follow-up fixes from upstream review:
- DialogShell closes on completed backdrop clicks instead of mousedown, and its
  Tab trap now also catches the panel-focused fallback case
- ToolsDialog now has full listbox semantics (ids, aria-activedescendant,
  aria-expanded)
- Rewind keeps the roving cursor separate from the confirmed target; Enter only
  confirms, and the danger button executes the rewind
- Model/Approval aria-selected now reflects the actual current value; model
  highlights stay in bounds when the model list shrinks
- Home/End and modified arrow-key combos yield to native text navigation in
  search inputs; Escape yields to IME composition in DialogShell
- Dead picker CSS and duplicate declarations removed; extra regression tests
  added for reviewer-raised edge cases

* fix(web-shell): harden shared dialog keyboard and IME handling

* fix(web-shell): tighten dialog shell focus, stacking, and backdrop behavior

* fix(web-shell): align list dialog selection semantics and add coverage
This commit is contained in:
carffuca 2026-07-02 20:19:17 +08:00 committed by GitHub
parent 848386a624
commit 5c9e73f371
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 3823 additions and 509 deletions

View file

@ -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);
}

View file

@ -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(<I18nProvider language="en">{node}</I18nProvider>);
});
}
function rerender(node: React.ReactNode) {
act(() => {
root!.render(<I18nProvider language="en">{node}</I18nProvider>);
});
}
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(<ApprovalModeDialog currentMode="default" onSelect={onSelect} />);
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(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
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(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
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(<ApprovalModeDialog currentMode="yolo" onSelect={vi.fn()} />);
expect(activeDescendant()).toBe('mode-opt-2');
});
it('stops following once the user has navigated', () => {
mount(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
press('ArrowDown');
expect(activeDescendant()).toBe('mode-opt-1');
// The user owns the highlight now — a mode change must not steal it.
rerender(<ApprovalModeDialog currentMode="yolo" onSelect={vi.fn()} />);
expect(activeDescendant()).toBe('mode-opt-1');
});
});

View file

@ -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 (
<div
className={styles.list}
className={`${styles.list} ${keyboardMode ? styles.keyboardOnly : ''}`}
ref={listRef}
role="listbox"
tabIndex={0}
aria-activedescendant={
approvalModes.length > 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 (
<button
<div
key={mode.id}
type="button"
id={`mode-opt-${index}`}
role="option"
aria-selected={selected}
className={`${styles.row} ${selected ? styles.selected : ''}`}
onClick={() => onSelect(mode.id)}
// aria-selected marks the actual current mode; the roving keyboard
// highlight is conveyed by aria-activedescendant + `.selected`.
aria-selected={isCurrent}
className={`${styles.row} ${selected ? styles.selected : ''} ${
isCurrent ? dp('dialog-current') : ''
}`}
onClick={() => confirm(index)}
onMouseMove={() => moveHighlight(index)}
>
<span className={styles.modeIcon}>
<ModeIcon mode={mode.id} />
@ -61,7 +102,7 @@ export function ApprovalModeDialog({
<span className={styles.modeName}>{mode.name}</span>
<span className={styles.modeDesc}>{mode.description}</span>
</span>
</button>
</div>
);
})}
</div>

View file

@ -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<typeof vi.fn>;
let onError: ReturnType<typeof vi.fn>;
let onClose: ReturnType<typeof vi.fn>;
function renderDialog() {
root!.render(
<I18nProvider language="en">
<DeleteSessionDialog
onDeleted={onDeleted}
onError={onError}
onClose={onClose}
/>
</I18nProvider>,
);
}
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();
});
});

View file

@ -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<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const { filterValue: filterQuery, inputProps } = useFilterInput(() => {
setSelectedIdx(-1);
setSelectedIds(new Set());
});
const [message, setMessage] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
<div className={dp('resume-picker-search')}>
<span className={dp('resume-picker-search-label')}>
<div className={dp('picker', 'picker-in-shell')}>
<div className={dp('picker-search')}>
<span className={dp('picker-search-label')}>
{t('resume.search')}:{' '}
</span>
<input
ref={inputRef}
className={dp('resume-picker-search-input')}
value={searchQuery}
onChange={(e) => {
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=""
/>
<span className={dp('resume-picker-search-hint')}>
<span className={dp('picker-search-hint')}>
{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 })
: '')}
</span>
</div>
<div className={dp('resume-picker-sep')} />
<div className={dp('picker-sep')} />
<div className={dp('resume-picker-list')} ref={listRef}>
<div
id={LIST_ID}
role="listbox"
aria-multiselectable="true"
className={dp(
'picker-list',
keyboardMode ? 'picker-keyboard-only' : undefined,
)}
ref={listRef}
>
{loading && (
<div className={dp('resume-picker-empty')}>{t('common.loading')}</div>
<div className={dp('picker-empty')}>{t('common.loading')}</div>
)}
{!loading && sessionsError && (
<div className={dp('resume-picker-empty')}>
{sessionsError.message}
</div>
<div className={dp('picker-empty')}>{sessionsError.message}</div>
)}
{!loading && !sessionsError && filtered.length === 0 && (
<div className={dp('resume-picker-empty')}>
{searchQuery
? t('delete.noMatch', { query: searchQuery })
<div className={dp('picker-empty')}>
{filterQuery
? t('delete.noMatch', { query: filterQuery })
: t('delete.none')}
</div>
)}
@ -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 (
<div
<SessionRow
key={s.sessionId}
className={dp(
'resume-picker-item',
'resume-picker-session-item',
isChecked ? 'selected' : undefined,
isCurrent ? 'resume-picker-item-current' : undefined,
isCurrent ? 'disabled' : undefined,
)}
session={s}
optionId={optionId(i)}
active={i === selectedIdx}
ariaSelected={isChecked}
current={false}
disabled={isCurrent}
leading={
<span
className={dp(
'picker-item-checkbox',
isChecked ? 'picker-item-checkbox-checked' : undefined,
)}
>
{isChecked ? '[x] ' : '[ ] '}
</span>
}
trailing={
isCurrent ? (
<span className={dp('picker-item-badge')}>
{t('resume.current')}
</span>
) : undefined
}
onClick={() => {
setSelectedIdx(i);
if (!isCurrent) toggleSelection(s.sessionId);
}}
>
<div className={dp('resume-picker-item-row')}>
<span className={dp('resume-picker-item-checkbox')}>
{checkbox}
</span>
<span className={dp('resume-picker-item-title')}>
{s.displayName || s.sessionId.slice(0, 8)}
</span>
{isCurrent && (
<span className={dp('resume-picker-item-badge')}>
{t('resume.current')}
</span>
)}
</div>
<div className={dp('resume-picker-item-meta')}>
<span>
{(s.updatedAt || s.createdAt) &&
formatRelativeTime(s.updatedAt || s.createdAt || '', t)}
</span>
<span className={dp('resume-picker-item-detail')}>
{t('common.clients', { count: s.clientCount ?? 0 })}
</span>
{s.hasActivePrompt && (
<span className={dp('resume-picker-item-detail')}>
{t('resume.activePrompt')}
</span>
)}
</div>
</div>
onActivate={() => setSelectedIdx(i)}
/>
);
})}
</div>
<div className={dp('resume-picker-sep')} />
<div className={dp('picker-sep')} />
<div className={dp('dialog-footer-actions')}>
<button
type="button"

View file

@ -1,7 +1,7 @@
/* Shared dialog and picker primitives reused by dialog components. */
/* ===== Dialog Overlay & Panel ===== */
/* ===== CLI-style Picker (Model, Mode, Resume) ===== */
.resume-picker {
.picker {
display: flex;
flex-direction: column;
height: 100%;
@ -12,72 +12,14 @@
margin: 24px;
}
.resume-picker-in-shell {
.picker-in-shell {
height: 100%;
margin: 0;
border: 0;
border-radius: 0;
}
.resume-picker-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.resume-picker-close {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
width: 24px;
height: 24px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.resume-picker-close:hover {
color: var(--foreground);
background: var(--muted);
}
.resume-picker-close::before,
.resume-picker-close::after {
content: '';
position: absolute;
width: 12px;
height: 1.5px;
border-radius: 999px;
background: currentColor;
}
.resume-picker-close::before {
transform: rotate(45deg);
}
.resume-picker-close::after {
transform: rotate(-45deg);
}
.resume-picker-title {
font-weight: 700;
color: var(--foreground);
font-size: 14px;
}
.resume-picker-count {
font-size: 12px;
color: var(--muted-foreground);
}
.resume-picker-search {
.picker-search {
display: flex;
align-items: center;
gap: 8px;
@ -86,11 +28,11 @@
min-height: 24px;
}
.resume-picker-search-label {
.picker-search-label {
color: var(--muted-foreground);
}
.resume-picker-search-input {
.picker-search-input {
flex: 1;
background: transparent;
border: none;
@ -100,37 +42,39 @@
font-size: 13px;
}
.resume-picker-search-value {
color: var(--foreground);
}
.resume-picker-search-hint {
.picker-search-hint {
color: var(--muted-foreground);
}
.resume-picker-sep {
.picker-sep {
height: 1px;
background: var(--border);
}
.resume-picker-list {
.picker-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 0;
}
.resume-picker-list-compact {
/* When the list itself is the focus owner (aria-activedescendant pattern),
selection is shown by the roving row highlight suppress the outline. */
.picker-list:focus {
outline: none;
}
.picker-list-compact {
flex: 0 1 auto;
}
.resume-picker-empty {
.picker-empty {
padding: 16px 12px;
color: var(--muted-foreground);
font-size: 13px;
}
.resume-picker-item {
.picker-item {
display: flex;
width: calc(100% - 16px);
align-items: baseline;
@ -146,38 +90,33 @@
cursor: pointer;
}
.resume-picker-session-item {
.picker-session-item {
align-items: stretch;
flex-direction: column;
}
.resume-picker-item.selected {
.picker-item.selected {
background: var(--secondary);
}
.resume-picker-item:hover {
.picker-item:hover {
background: var(--secondary);
}
.resume-picker-item-current {
border-radius: 6px;
background: var(--secondary);
}
.resume-picker-keyboard-only .resume-picker-item:hover {
.picker-keyboard-only .picker-item:hover {
background: transparent;
}
.resume-picker-keyboard-only .resume-picker-item.selected:hover {
.picker-keyboard-only .picker-item.selected:hover {
background: var(--secondary);
}
.resume-picker-item.disabled {
.picker-item.disabled {
cursor: default;
opacity: 0.55;
}
.resume-picker-item-row {
.picker-item-row {
display: flex;
align-items: baseline;
flex: 1;
@ -186,63 +125,42 @@
width: 100%;
}
.resume-picker-item-prefix {
min-width: 14px;
flex-shrink: 0;
color: var(--agent-blue-500);
font-weight: 700;
font-size: 20px;
margin-right: 8px;
white-space: pre;
}
.resume-picker-item-title {
.picker-item-title {
flex: 1 1 auto;
min-width: 0;
color: var(--foreground);
flex: 1 1 auto;
font-size: 13px;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resume-picker-item-checkbox {
.picker-item-checkbox {
color: var(--muted-foreground);
flex-shrink: 0;
font-size: 13px;
white-space: pre;
}
.resume-picker-item.selected .resume-picker-item-checkbox {
.picker-item.selected .picker-item-checkbox {
color: var(--agent-blue-500);
font-weight: 700;
}
.resume-picker-item-number {
flex-shrink: 0;
min-width: 28px;
color: var(--muted-foreground);
font-size: 13px;
text-align: right;
}
.resume-picker-item-provider {
flex-shrink: 0;
color: var(--agent-blue-500);
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.resume-picker-item.selected .resume-picker-item-title {
/* Multi-select "checked" emphasis independent of the roving `.selected`
cursor, so ticked rows stay legible even when the cursor is elsewhere. */
.picker-item-checkbox-checked {
color: var(--agent-blue-500);
font-weight: 700;
}
.resume-picker-item-meta {
.picker-item.selected .picker-item-title {
color: var(--agent-blue-500);
font-weight: 700;
}
.picker-item-meta {
display: flex;
align-items: center;
gap: 8px;
@ -250,23 +168,53 @@
color: var(--muted-foreground);
}
.resume-picker-item-detail {
.picker-item-detail {
color: var(--muted-foreground);
}
.resume-picker-item-detail::before {
.picker-item-detail::before {
content: '·';
margin-right: 8px;
color: var(--muted-foreground);
}
.resume-picker-item-check {
color: var(--agent-blue-500, #4a9eff);
font-weight: bold;
margin-left: 4px;
/* "Current value" marker: a left accent bar + a right that read on their own
channel, independent of the roving `.selected` background highlight. Both are
absolutely positioned and vertically centered, so they stay centered whatever
the row height (single- or multi-line). Shared by every list dialog. */
.dialog-current,
.picker-item-confirmed {
position: relative;
padding-right: 28px;
}
.resume-picker-item-badge {
.dialog-current::before,
.picker-item-confirmed::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 60%;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--agent-blue-500, #4a9eff);
}
.dialog-current::after,
.picker-item-confirmed::after {
content: '✓';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--agent-blue-500, #4a9eff);
font-weight: bold;
font-size: 13px;
line-height: 1;
}
.picker-item-badge {
flex-shrink: 0;
margin-left: 8px;
font-size: 11px;
@ -304,7 +252,7 @@
padding: 5px 8px 6px;
}
.tools-picker-item .resume-picker-item-row {
.tools-picker-item .picker-item-row {
align-items: center;
gap: 8px;
}
@ -386,32 +334,6 @@
.mcp-status-unknown {
color: var(--muted-foreground);
}
.resume-picker-detail-panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
padding-left: 32px;
font-size: 13px;
flex-shrink: 0;
}
.resume-picker-detail-row {
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.resume-picker-detail-label {
color: var(--muted-foreground);
white-space: nowrap;
}
.resume-picker-detail-value {
color: var(--foreground);
overflow-wrap: anywhere;
}
.dialog-inline-button,
.dialog-primary-button,

View file

@ -68,6 +68,12 @@
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
}
/* The panel receives focus programmatically for keyboard management; suppress
the container outline since selection is shown via a roving row highlight. */
.panel:focus {
outline: none;
}
.sizeSm {
width: min(100%, 420px);
}

View file

@ -0,0 +1,455 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, useEffect, useRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../i18n';
import { ThemeProvider } from '../../themeContext';
import { DialogShell } from './DialogShell';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
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(
<I18nProvider language="en">
<ThemeProvider value="dark">{node}</ThemeProvider>
</I18nProvider>,
);
});
}
function press(key: string, options: KeyboardEventInit = {}) {
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key, cancelable: true, ...options }),
);
});
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function AutofocusChild() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="text" />;
}
describe('DialogShell', () => {
it('closes on Escape', () => {
const onClose = vi.fn();
mount(
<DialogShell title="Test" onClose={onClose}>
<button type="button">inner</button>
</DialogShell>,
);
press('Escape');
expect(onClose).toHaveBeenCalledTimes(1);
});
it('ignores Escape that belongs to an IME composition', () => {
const onClose = vi.fn();
mount(
<DialogShell title="Test" onClose={onClose}>
<input type="text" />
</DialogShell>,
);
// Chrome/Firefox: Escape cancelling a composition reports isComposing.
press('Escape', { isComposing: true });
expect(onClose).not.toHaveBeenCalled();
// WebKit: compositionend fires first, so only keyCode 229 marks the key.
act(() => {
const imeEscape = new KeyboardEvent('keydown', {
key: 'Escape',
cancelable: true,
});
Object.defineProperty(imeEscape, 'keyCode', { value: 229 });
document.dispatchEvent(imeEscape);
});
expect(onClose).not.toHaveBeenCalled();
// A genuine Escape still closes.
press('Escape');
expect(onClose).toHaveBeenCalledTimes(1);
});
it('keeps focus on the panel when Tab is pressed with nothing focusable inside', () => {
mount(
<DialogShell title="Test" onClose={vi.fn()}>
<button type="button" data-testid="inner">
inner
</button>
</DialogShell>,
);
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
// Simulate content whose focusables all went away (e.g. everything became
// disabled/hidden while an action runs).
document.querySelector('[data-dialog-close]')!.remove();
document.querySelector('[data-testid="inner"]')!.remove();
panel.focus();
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
bubbles: true,
cancelable: true,
}),
);
});
// Tab must not escape to the page behind; focus stays parked on the panel.
expect(document.activeElement).toBe(panel);
});
it('lets a dialog control consume Escape instead of closing', () => {
const onClose = vi.fn();
mount(
<DialogShell title="Test" onClose={onClose}>
<input
type="text"
onKeyDown={(event) => {
// e.g. an inline editor cancelling its edit on Escape.
if (event.key === 'Escape') event.preventDefault();
}}
/>
</DialogShell>,
);
const input = document.querySelector<HTMLInputElement>('input')!;
input.focus();
act(() => {
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
);
});
expect(onClose).not.toHaveBeenCalled();
});
it('only the topmost of stacked shells handles Escape', () => {
const onCloseBottom = vi.fn();
const onCloseTop = vi.fn();
mount(
<>
<DialogShell title="Bottom" onClose={onCloseBottom}>
<button type="button">bottom</button>
</DialogShell>
<DialogShell title="Top" onClose={onCloseTop}>
<button type="button">top</button>
</DialogShell>
</>,
);
// One Escape peels off one layer — the top one — not both at once.
press('Escape');
expect(onCloseTop).toHaveBeenCalledTimes(1);
expect(onCloseBottom).not.toHaveBeenCalled();
});
it('keeps focus inside the top shell if a lower shell unmounts first', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
function Harness({ showBottom }: { showBottom: boolean }) {
return (
<>
{showBottom ? (
<DialogShell title="Bottom" onClose={vi.fn()}>
<button type="button">bottom</button>
</DialogShell>
) : null}
<DialogShell title="Top" onClose={vi.fn()}>
<button type="button" data-testid="top-focus">
top
</button>
</DialogShell>
</>
);
}
mount(<Harness showBottom={true} />);
const topButton = document.querySelector<HTMLElement>(
'[data-testid="top-focus"]',
)!;
expect(document.activeElement).toBe(topButton);
act(() => {
root!.render(
<I18nProvider language="en">
<ThemeProvider value="dark">
<Harness showBottom={false} />
</ThemeProvider>
</I18nProvider>,
);
});
// Focus must stay inside the remaining top shell, not jump back behind it.
expect(document.activeElement).toBe(topButton);
opener.remove();
});
it('restores focus to the remaining top shell when the lower shell unmounts after focus moved', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
function Harness({ showBottom }: { showBottom: boolean }) {
return (
<>
{showBottom ? (
<DialogShell title="Bottom" onClose={vi.fn()}>
<button type="button">bottom</button>
</DialogShell>
) : null}
<DialogShell title="Top" onClose={vi.fn()}>
<button type="button" data-testid="top-focus">
top
</button>
</DialogShell>
</>
);
}
mount(<Harness showBottom={true} />);
const topButton = document.querySelector<HTMLElement>(
'[data-testid="top-focus"]',
)!;
document.querySelector<HTMLElement>('button:not([data-testid])')!.focus();
act(() => {
root!.render(
<I18nProvider language="en">
<ThemeProvider value="dark">
<Harness showBottom={false} />
</ThemeProvider>
</I18nProvider>,
);
});
expect(document.activeElement).toBe(topButton);
opener.remove();
});
it('closes when the backdrop is clicked but not when the panel is clicked', () => {
const onClose = vi.fn();
mount(
<DialogShell title="Test" onClose={onClose}>
<button type="button">inner</button>
</DialogShell>,
);
const backdrop = document.querySelector<HTMLElement>(
'[data-keyboard-scope]',
);
const panel = document.querySelector<HTMLElement>('[role="dialog"]');
expect(backdrop).toBeTruthy();
act(() => {
panel!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
panel!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onClose).not.toHaveBeenCalled();
act(() => {
backdrop!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
backdrop!.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
backdrop!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not close when a drag starts in the panel and ends on the backdrop', () => {
const onClose = vi.fn();
mount(
<DialogShell title="Test" onClose={onClose}>
<button type="button">inner</button>
</DialogShell>,
);
const backdrop = document.querySelector<HTMLElement>(
'[data-keyboard-scope]',
)!;
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
act(() => {
panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onClose).not.toHaveBeenCalled();
});
it('does not close when a press starts on the backdrop and ends on the panel', () => {
const onClose = vi.fn();
mount(
<DialogShell title="Test" onClose={onClose}>
<button type="button">inner</button>
</DialogShell>,
);
const backdrop = document.querySelector<HTMLElement>(
'[data-keyboard-scope]',
)!;
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
act(() => {
backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
panel.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
// Browsers synthesize click on the nearest common ancestor for mismatched
// press/release targets; here that's effectively the backdrop.
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onClose).not.toHaveBeenCalled();
});
it('moves focus into the dialog on open', () => {
mount(
<DialogShell title="Test" onClose={vi.fn()}>
<button type="button" data-testid="first">
first
</button>
<button type="button">second</button>
</DialogShell>,
);
const first = document.querySelector<HTMLElement>('[data-testid="first"]');
expect(document.activeElement).toBe(first);
});
it('restores focus to the opener on close', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
expect(document.activeElement).toBe(opener);
mount(
<DialogShell title="Test" onClose={vi.fn()}>
<button type="button">inner</button>
</DialogShell>,
);
// Focus moved into the dialog.
expect(document.activeElement).not.toBe(opener);
act(() => root?.unmount());
root = null;
expect(document.activeElement).toBe(opener);
opener.remove();
});
it('restores focus to the opener even if a child autofocuses first', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
expect(document.activeElement).toBe(opener);
mount(
<DialogShell title="Test" onClose={vi.fn()}>
<AutofocusChild />
</DialogShell>,
);
const input = document.querySelector<HTMLInputElement>('input');
expect(document.activeElement).toBe(input);
act(() => root?.unmount());
root = null;
expect(document.activeElement).toBe(opener);
opener.remove();
});
it('traps Tab within the dialog, wrapping at both ends', () => {
mount(
<DialogShell title="Test" onClose={vi.fn()}>
<button type="button" data-testid="last">
inner
</button>
</DialogShell>,
);
// Focusables in DOM order: [close button, inner button].
const close = document.querySelector<HTMLElement>('[data-dialog-close]')!;
const last = document.querySelector<HTMLElement>('[data-testid="last"]')!;
// Tab from the last focusable wraps to the first (the close button).
last.focus();
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
);
});
expect(document.activeElement).toBe(close);
// Shift+Tab from the first focusable wraps to the last.
close.focus();
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
}),
);
});
expect(document.activeElement).toBe(last);
});
it('pulls focus into the dialog when Tab is pressed while the panel holds focus', () => {
mount(
<DialogShell title="Test" onClose={vi.fn()}>
<button type="button" data-testid="last">
inner
</button>
</DialogShell>,
);
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
const close = document.querySelector<HTMLElement>('[data-dialog-close]')!;
const last = document.querySelector<HTMLElement>('[data-testid="last"]')!;
// Focus sits on the panel itself (roving-list fallback). Tab pulls it in.
panel.focus();
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
);
});
expect(document.activeElement).toBe(close);
// Shift+Tab from the panel pulls in from the end instead.
panel.focus();
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
}),
);
});
expect(document.activeElement).toBe(last);
});
});

View file

@ -1,4 +1,10 @@
import { type ReactNode } from 'react';
import {
createContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { useI18n } from '../../i18n';
import { useTheme, WebShellThemeId } from '../../themeContext';
@ -21,6 +27,39 @@ const sizeClass: Record<DialogSize, string> = {
xl: styles.sizeXl,
};
const FOCUSABLE_SELECTOR = [
'a[href]:not([hidden])',
'button:not([disabled]):not([hidden])',
'input:not([disabled]):not([hidden])',
'select:not([disabled]):not([hidden])',
'textarea:not([disabled]):not([hidden])',
'[tabindex]:not([tabindex="-1"]):not([hidden])',
].join(',');
function getFocusable(container: HTMLElement | null): HTMLElement[] {
if (!container) return [];
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
);
}
// Mounted shells, bottom → top. Every shell listens on `document`, and
// `stopPropagation` cannot silence sibling listeners on the same node — so
// without this, one Escape would close every stacked dialog at once (and the
// bottom one would win any race, since it registered first). Only the topmost
// shell handles keys; stacked dialogs peel off one layer per Escape.
const shellStack: object[] = [];
export const DialogShellIdContext = createContext<object | null>(null);
export function isTopDialogShellId(shellId: object | null): boolean {
// Most production callers live inside DialogShell and get a shell id. Tests or
// any future standalone consumer may not; in that case, preserve the original
// single-dialog behavior and allow the hook to handle keys normally.
if (shellId === null) return true;
return shellStack[shellStack.length - 1] === shellId;
}
export function DialogShell({
title,
subtitle,
@ -32,30 +71,182 @@ export function DialogShell({
const theme = useTheme();
const themeClass =
theme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark;
const panelRef = useRef<HTMLElement>(null);
// `onClose` may change identity across renders; keep the latest for the
// once-bound key listener.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Capture the opener during the dialog's first render, before any child
// effects can move focus into an autofocused search field.
const [previouslyFocused] = useState<HTMLElement | null>(() =>
typeof document !== 'undefined'
? (document.activeElement as HTMLElement | null)
: null,
);
// A completed backdrop click should close, but any drag that crosses the
// panel boundary in either direction must not. Record whether the press both
// started and ended on the backdrop itself, then let the synthesized click
// close only when both are true.
const backdropPressStartedRef = useRef(false);
const backdropPressEndedRef = useRef(false);
// Identity token for this shell instance in the module-level stack.
const shellIdRef = useRef<object | null>(null);
if (shellIdRef.current === null) shellIdRef.current = {};
// Move focus into the dialog on open, restore it to the opener on close, and
// trap Tab within the panel. Escape closes.
useEffect(() => {
const panel = panelRef.current;
const shellId = shellIdRef.current!;
shellStack.push(shellId);
// Autofocus: respect a child that already claimed focus (e.g. a search
// input's own effect); otherwise focus the first content focusable (skipping
// the header close button), else the panel itself. Falling back to the panel
// rather than the close button avoids a stray focus ring when a list dialog's
// options are managed via a roving highlight (tabIndex=-1) instead of focus.
if (panel && !panel.contains(document.activeElement)) {
const focusables = getFocusable(panel);
const preferred = focusables.find(
(el) => !el.hasAttribute('data-dialog-close'),
);
(preferred ?? panel).focus();
}
const handleKeyDown = (event: KeyboardEvent) => {
// With stacked dialogs, only the topmost shell may handle Escape/Tab —
// a lower shell closing or trapping focus would act "through" the one
// covering it.
if (shellStack[shellStack.length - 1] !== shellId) return;
// A control inside the dialog may consume the key first (e.g. Escape to
// cancel an inline edit) — honor that instead of dismissing the dialog.
if (event.defaultPrevented) return;
// Escape mid-IME-composition cancels the composition, not the dialog.
// keyCode 229 covers WebKit, which fires compositionend before the
// committing key's keydown (see useListboxKeyboard for the same guard).
if (event.isComposing || event.keyCode === 229) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onCloseRef.current();
return;
}
if (event.key === 'Tab') {
const focusables = getFocusable(panelRef.current);
if (focusables.length === 0) {
// Nothing focusable inside — keep focus on the panel itself.
event.preventDefault();
panelRef.current?.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const activeEl = document.activeElement;
const insideList = focusables.includes(activeEl as HTMLElement);
if (!insideList) {
// Focus is on the panel itself (e.g. a roving-highlight list where the
// options are tabIndex=-1) — pull it into the dialog so Tab can't
// escape to the page behind.
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && activeEl === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && activeEl === last) {
event.preventDefault();
first.focus();
}
}
};
// Bubble phase on `document`, deliberately positioned in the middle of the
// propagation chain: controls inside the dialog run first (and can consume
// Escape via preventDefault, honored above), while `window`-level listeners
// — the app's global shortcuts and useListboxKeyboard — run after, so the
// stopPropagation on Escape still shields them. Moving this listener to the
// capture phase would steal Escape from the dialog's own controls; moving
// it to `window` would lose the race with the app-level handlers.
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
const idx = shellStack.indexOf(shellId);
if (idx >= 0) shellStack.splice(idx, 1);
if (shellStack.length === 0) {
previouslyFocused?.focus?.();
return;
}
// Another modal is still stacked above the page — keep focus inside the
// remaining top shell instead of restoring it behind the modal layer.
const scopes = Array.from(
document.querySelectorAll<HTMLElement>('[data-keyboard-scope]'),
);
const topPanel =
scopes[scopes.length - 1]?.querySelector<HTMLElement>(
'[role="dialog"]',
);
const topFocusables = getFocusable(topPanel);
const preferred = topFocusables.find(
(el) => !el.hasAttribute('data-dialog-close'),
);
(preferred ?? topPanel)?.focus();
};
}, [previouslyFocused]);
const handleBackdropMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
backdropPressStartedRef.current = event.target === event.currentTarget;
backdropPressEndedRef.current = false;
};
const handleBackdropMouseUp = (event: React.MouseEvent<HTMLDivElement>) => {
backdropPressEndedRef.current = event.target === event.currentTarget;
};
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
const shouldClose =
backdropPressStartedRef.current &&
backdropPressEndedRef.current &&
event.target === event.currentTarget;
backdropPressStartedRef.current = false;
backdropPressEndedRef.current = false;
if (shouldClose) {
onClose();
}
};
const content = (
<div className={`${styles.backdrop} ${themeClass}`} data-keyboard-scope>
<section
className={`${styles.panel} ${sizeClass[size]}`}
role="dialog"
aria-modal="true"
aria-label={title}
>
<header className={styles.header}>
<div className={styles.titleWrap}>
<div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
</div>
<button
type="button"
className={styles.close}
onClick={onClose}
aria-label={t('common.close')}
title={t('common.close')}
/>
</header>
<div className={styles.body}>{children}</div>
</section>
<div
className={`${styles.backdrop} ${themeClass}`}
data-keyboard-scope
onMouseDown={handleBackdropMouseDown}
onMouseUp={handleBackdropMouseUp}
onClick={handleBackdropClick}
>
<DialogShellIdContext.Provider value={shellIdRef.current}>
<section
ref={panelRef}
className={`${styles.panel} ${sizeClass[size]}`}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
>
<header className={styles.header}>
<div className={styles.titleWrap}>
<div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
</div>
<button
type="button"
className={styles.close}
onClick={onClose}
aria-label={t('common.close')}
title={t('common.close')}
data-dialog-close
/>
</header>
<div className={styles.body}>{children}</div>
</section>
</DialogShellIdContext.Provider>
</div>
);

View file

@ -176,11 +176,9 @@ export function ExtensionsDialog() {
}, [checking, extensions.length, loading, t]);
return (
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
<div className={dp('resume-picker-search', 'extensions-toolbar')}>
<span className={dp('resume-picker-search-hint')}>
{message || summary}
</span>
<div className={dp('picker', 'picker-in-shell')}>
<div className={dp('picker-search', 'extensions-toolbar')}>
<span className={dp('picker-search-hint')}>{message || summary}</span>
<button
type="button"
className={dp('dialog-inline-button')}
@ -191,11 +189,11 @@ export function ExtensionsDialog() {
</button>
</div>
<div className={dp('resume-picker-sep')} />
<div className={dp('picker-sep')} />
<div className={dp('resume-picker-list')}>
<div className={dp('picker-list')}>
{!loading && extensions.length === 0 && (
<div className={dp('resume-picker-empty')}>
<div className={dp('picker-empty')}>
{t('extensions.manage.empty')}
</div>
)}
@ -207,8 +205,8 @@ export function ExtensionsDialog() {
<div
key={extension.id || extension.name}
className={dp(
'resume-picker-item',
'resume-picker-session-item',
'picker-item',
'picker-session-item',
'tools-picker-item',
expanded ? 'selected' : undefined,
expanded ? 'tools-picker-item-expanded' : undefined,
@ -223,10 +221,10 @@ export function ExtensionsDialog() {
}}
>
<span className={dp('tools-item-icon')} aria-hidden="true" />
<span className={dp('resume-picker-item-title')}>
<span className={dp('picker-item-title')}>
{extensionTitle(extension)}
</span>
<span className={dp('resume-picker-item-badge')}>
<span className={dp('picker-item-badge')}>
v{extension.version}
</span>
<span

View file

@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import type { CommandInfo } from '../../adapters/types';
import { useI18n } from '../../i18n';
import { useFilterInput } from '../../hooks/useFilterInput';
import styles from './HelpDialog.module.css';
type HelpTab = 'general' | 'commands' | 'custom-commands';
@ -233,7 +234,7 @@ function CommandsHelp({
export function HelpDialog({ commands }: HelpDialogProps) {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<HelpTab>('general');
const [query, setQuery] = useState('');
const { filterValue: query, inputProps } = useFilterInput();
const showSearch = activeTab !== 'general';
return (
@ -256,8 +257,7 @@ export function HelpDialog({ commands }: HelpDialogProps) {
{showSearch && (
<input
className={styles.search}
value={query}
onChange={(event) => setQuery(event.target.value)}
{...inputProps}
placeholder={t('help.search')}
/>
)}

View file

@ -14,6 +14,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;
@ -34,6 +40,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 .label {
color: var(--agent-blue-500);
}

View file

@ -0,0 +1,158 @@
// @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';
import { dp } from './dialogStyles';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
// ModelDialog only reads `useConnection()`; models/current come in via props here.
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useConnection: () => ({}),
}));
const { ModelDialog } = await import('./ModelDialog');
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(<I18nProvider language="en">{node}</I18nProvider>);
});
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('ModelDialog current marker', () => {
it('marks exactly one row current when two models share an id', () => {
// Two providers expose the same model id "qwen"; `currentModel` is only an
// id, so both used to be flagged. Only the first match should be current.
const models = [
{ id: 'qwen', authType: 'a', baseUrl: 'https://a' },
{ id: 'qwen', authType: 'b', baseUrl: 'https://b' },
{ id: 'other', authType: 'c' },
];
mount(
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="qwen" />,
);
const options = Array.from(container!.querySelectorAll('[role="option"]'));
expect(options).toHaveLength(3);
const currentClass = dp('dialog-current');
const marked = options.filter((el) => el.className.includes(currentClass));
expect(marked).toHaveLength(1);
expect(marked[0].textContent).toContain('1.');
});
it('binds aria-selected to the current model, not the roving highlight', () => {
const models = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
mount(
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="b" />,
);
const selected = () =>
Array.from(container!.querySelectorAll('[aria-selected="true"]'));
// Only the current model (b) is aria-selected on open.
expect(selected()).toHaveLength(1);
expect(selected()[0].textContent).toContain('b');
// Moving the keyboard highlight must not change which row is aria-selected.
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
});
expect(selected()).toHaveLength(1);
expect(selected()[0].textContent).toContain('b');
});
});
describe('ModelDialog keyboard confirmation', () => {
it('confirms the highlighted model on Enter', () => {
const onSelect = vi.fn();
const models = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
mount(
<ModelDialog onSelect={onSelect} models={models} currentModelId="b" />,
);
// Enter with no navigation confirms the current model's row.
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }),
);
});
expect(onSelect).toHaveBeenCalledWith('b');
// After arrowing, Enter confirms the newly highlighted row.
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }),
);
});
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }),
);
});
expect(onSelect).toHaveBeenLastCalledWith('c');
});
});
describe('ModelDialog highlight follows the current model', () => {
const models = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const activeDescendant = () =>
container!
.querySelector('[role="listbox"]')!
.getAttribute('aria-activedescendant');
it('re-syncs the highlight when the current model changes while open', () => {
mount(
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="a" />,
);
expect(activeDescendant()).toBe('model-opt-0');
// Another client sharing the session switches the model while the dialog
// is open: the highlight (and thus detail panel / Enter) must follow.
act(() => {
root!.render(
<I18nProvider language="en">
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="c" />
</I18nProvider>,
);
});
expect(activeDescendant()).toBe('model-opt-2');
});
it('stops following once the user has navigated', () => {
mount(
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="a" />,
);
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
});
expect(activeDescendant()).toBe('model-opt-1');
// The user owns the highlight now — a current-model change must not steal it.
act(() => {
root!.render(
<I18nProvider language="en">
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="c" />
</I18nProvider>,
);
});
expect(activeDescendant()).toBe('model-opt-1');
});
});

View file

@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useConnection } from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
import { dp } from './dialogStyles';
import styles from './ModelDialog.module.css';
export type ModelDialogMode = 'main' | 'fast' | 'voice';
@ -105,23 +107,65 @@ export function ModelDialog({
const listRef = useRef<HTMLDivElement>(null);
const isFastMode = mode === 'fast';
const isVoiceMode = mode === 'voice';
const selectedIdx = availableModels.findIndex((m) => m.id === currentModel);
const selectedModel =
selectedIdx >= 0 ? availableModels[selectedIdx] : availableModels[0];
const currentIdx = availableModels.findIndex((m) => m.id === currentModel);
const [activeIndex, setActiveIndex] = useState(
currentIdx >= 0 ? currentIdx : 0,
);
// Follow the current model until the user first navigates: models arrive
// asynchronously, and the current model itself can change while the dialog
// is open (e.g. another client sharing the session switches models) — the
// highlight, detail panel and Enter must track it. Once the user has moved
// the highlight themselves, it is theirs and must not be stolen.
const userNavigatedRef = useRef(false);
useEffect(() => {
if (userNavigatedRef.current || availableModels.length === 0) return;
setActiveIndex(currentIdx >= 0 ? currentIdx : 0);
}, [availableModels.length, currentIdx]);
const moveHighlight = (index: number) => {
userNavigatedRef.current = true;
setActiveIndex(index);
};
// Keep the highlight in bounds if the model list refreshes/shrinks while open,
// so aria-activedescendant, the detail panel and Enter all stay in sync.
useEffect(() => {
if (activeIndex >= availableModels.length && availableModels.length > 0) {
setActiveIndex(availableModels.length - 1);
}
}, [availableModels.length, activeIndex]);
const selectedModel = availableModels[activeIndex] ?? availableModels[0];
const confirm = (index: number) => {
const model = availableModels[index];
if (model) onSelect(getModelSelectId(model, isFastMode));
};
const { keyboardMode } = useListboxKeyboard({
itemCount: availableModels.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 (
<div className={styles.layout}>
<div
className={styles.list}
className={`${styles.list} ${keyboardMode ? styles.keyboardOnly : ''}`}
ref={listRef}
role="listbox"
tabIndex={0}
aria-activedescendant={
availableModels.length > 0 ? `model-opt-${activeIndex}` : undefined
}
aria-label={
isFastMode
? t('model.setFast')
@ -134,16 +178,27 @@ export function ModelDialog({
<div className={styles.empty}>{t('model.none')}</div>
) : null}
{availableModels.map((model, index) => {
const selected = model.id === currentModel;
const selected = index === activeIndex;
// Only the first id match is the "current" one. `currentModel` is just
// an id string, so when two providers expose the same model id we
// cannot tell them apart here — mark one, consistent with the initial
// highlight (which also lands on `currentIdx`, the first match).
const isCurrent = index === currentIdx;
const authType = getAuthType(model);
return (
<button
<div
key={getModelKey(model)}
type="button"
id={`model-opt-${index}`}
role="option"
aria-selected={selected}
className={`${styles.row} ${selected ? styles.selected : ''}`}
onClick={() => onSelect(getModelSelectId(model, isFastMode))}
// aria-selected marks the actual current model; the roving
// keyboard highlight is conveyed by aria-activedescendant + the
// visual `.selected` class, not by aria-selected.
aria-selected={isCurrent}
className={`${styles.row} ${selected ? styles.selected : ''} ${
isCurrent ? dp('dialog-current') : ''
}`}
onClick={() => confirm(index)}
onMouseMove={() => moveHighlight(index)}
>
<span className={styles.number}>{index + 1}.</span>
{authType ? (
@ -153,7 +208,7 @@ export function ModelDialog({
{model.isRuntime ? (
<span className={styles.badge}>Runtime</span>
) : null}
</button>
</div>
);
})}
</div>

View file

@ -0,0 +1,267 @@
// @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';
import { dp } from './dialogStyles';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
const 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',
},
{
sessionId: 'inactive',
displayName: 'Inactive Session',
clientCount: 0,
hasActivePrompt: false,
updatedAt: '2026-01-01T00:00:00Z',
},
];
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useConnection: () => ({ sessionId: 'me' }),
useSessions: () => ({
sessions,
loading: false,
error: undefined,
releaseSession: vi.fn().mockResolvedValue(undefined),
}),
}));
const { ReleaseSessionDialog } = await import('./ReleaseSessionDialog');
let container: HTMLDivElement | null = null;
let root: Root | null = null;
function mount() {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root!.render(
<I18nProvider language="en">
<ReleaseSessionDialog
onReleased={vi.fn()}
onError={vi.fn()}
onClose={vi.fn()}
/>
</I18nProvider>,
);
});
}
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;
}
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 isConfirmed = (el: HTMLElement) =>
el.className.includes(dp('picker-item-confirmed'));
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('ReleaseSessionDialog selection', () => {
it('keeps the cursor and the confirmed target separate', () => {
mount();
// The dialog opens with no highlight at all; Enter has nothing to act on.
expect(rows().some(isCursor)).toBe(false);
expect(rows().some(isConfirmed)).toBe(false);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(rows().some(isConfirmed)).toBe(false);
expect(dangerButton().disabled).toBe(true);
// The first ArrowDown lands on row 0 without confirming it.
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
});
expect(isCursor(rows()[0])).toBe(true);
expect(isConfirmed(rows()[0])).toBe(false);
expect(dangerButton().disabled).toBe(true);
// Arrowing to another row must not confirm/enable the destructive action.
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
});
expect(isCursor(rows()[1])).toBe(true);
expect(isConfirmed(rows()[1])).toBe(false);
expect(dangerButton().disabled).toBe(true);
// Enter confirms the current cursor row and enables the action.
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(isConfirmed(rows()[1])).toBe(true);
expect(dangerButton().disabled).toBe(false);
// Moving the cursor again must not steal the confirmed target.
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
);
});
expect(isCursor(rows()[0])).toBe(true);
expect(isConfirmed(rows()[1])).toBe(true);
expect(isConfirmed(rows()[0])).toBe(false);
expect(dangerButton().disabled).toBe(false);
});
it('moves the cursor on hover, but only Enter/click confirms the target', () => {
mount();
// Hover updates the cursor so the visible gray row matches Enter's target,
// but the destructive action still stays disabled until confirmation.
act(() => {
rows()[1].dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
});
expect(isCursor(rows()[1])).toBe(true);
expect(isConfirmed(rows()[1])).toBe(false);
expect(dangerButton().disabled).toBe(true);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(isConfirmed(rows()[1])).toBe(true);
expect(dangerButton().disabled).toBe(false);
});
it('does not confirm the current or an inactive session on click', () => {
mount();
// Current session row cannot become the confirmed target.
act(() => {
rows()[2].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(isConfirmed(rows()[2])).toBe(false);
expect(dangerButton().disabled).toBe(true);
// Inactive session row cannot become the confirmed target either.
act(() => {
rows()[3].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(isConfirmed(rows()[3])).toBe(false);
expect(dangerButton().disabled).toBe(true);
});
it('clears the confirmed target and disarms release when the filter changes', () => {
mount();
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
});
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
});
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(isConfirmed(rows()[1])).toBe(true);
expect(dangerButton().disabled).toBe(false);
typeFilter('s1');
expect(rows()).toHaveLength(1);
expect(rows().some(isConfirmed)).toBe(false);
expect(rows().some(isCursor)).toBe(false);
expect(dangerButton().disabled).toBe(true);
});
it('does not confirm any row when all visible rows are non-releasable', () => {
const original = sessions.slice();
try {
sessions.splice(
0,
sessions.length,
{
sessionId: 'me',
displayName: 'Current Session',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'inactive',
displayName: 'Inactive Session',
clientCount: 0,
hasActivePrompt: false,
updatedAt: '2026-01-01T00:00:00Z',
},
);
mount();
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(rows().some((row) => isConfirmed(row))).toBe(false);
expect(dangerButton().disabled).toBe(true);
} finally {
sessions.splice(0, sessions.length, ...original);
}
});
});

View file

@ -6,7 +6,9 @@ import {
type DaemonSessionSummary,
} 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 ReleaseSessionDialogProps {
onReleased: (sessionId: string) => void;
@ -14,6 +16,9 @@ interface ReleaseSessionDialogProps {
onClose: () => void;
}
const LIST_ID = 'release-session-list';
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
export function ReleaseSessionDialog({
onReleased,
onError,
@ -29,9 +34,15 @@ export function ReleaseSessionDialog({
} = useSessions({ autoLoad: true });
const currentSessionId = connection.sessionId;
const [deleting, setDeleting] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(0);
const [hasSelectedSession, setHasSelectedSession] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// -1 = no highlight; see ResumeDialog for the rationale.
const [cursorIdx, setCursorIdx] = useState(-1);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null,
);
const { filterValue: filterQuery, inputProps } = useFilterInput(() => {
setCursorIdx(-1);
setSelectedSessionId(null);
});
const [message, setMessage] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@ -40,9 +51,9 @@ export function ReleaseSessionDialog({
if (sessionsError) setMessage(sessionsError.message);
}, [sessionsError]);
const filtered = searchQuery
const filtered = filterQuery
? sessions.filter((s) => {
const q = searchQuery.toLowerCase();
const q = filterQuery.toLowerCase();
return (
(s.displayName || '').toLowerCase().includes(q) ||
s.sessionId.toLowerCase().includes(q)
@ -50,26 +61,49 @@ export function ReleaseSessionDialog({
})
: sessions;
useEffect(() => {
if (selectedIdx >= filtered.length && filtered.length > 0) {
setSelectedIdx(filtered.length - 1);
}
}, [filtered.length, selectedIdx]);
const confirmRow = (index: number) => {
const session = filtered[index];
if (!session) return;
const isCurrent = session.sessionId === currentSessionId;
const isReleasable =
(session.clientCount ?? 0) > 0 || session.hasActivePrompt === true;
if (isCurrent || !isReleasable) return;
setCursorIdx(index);
setSelectedSessionId(session.sessionId);
};
useEffect(() => {
const el = listRef.current?.children[selectedIdx] as
| HTMLElement
| undefined;
if (cursorIdx >= filtered.length && filtered.length > 0) {
setCursorIdx(filtered.length - 1);
}
}, [filtered.length, cursorIdx]);
useEffect(() => {
const el = listRef.current?.children[cursorIdx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIdx]);
}, [cursorIdx]);
useEffect(() => {
inputRef.current?.focus();
}, []);
// Arrows move only the roving cursor. Enter/click confirms the target row,
// but the destructive release still stays behind the danger button.
const { keyboardMode } = useListboxKeyboard({
itemCount: filtered.length,
activeIndex: cursorIdx,
onActiveIndexChange: setCursorIdx,
onConfirm: confirmRow,
});
const handleRelease = useCallback(
(targetSession?: DaemonSessionSummary) => {
const session = targetSession ?? filtered[selectedIdx];
const session =
targetSession ??
filtered.find((s) => s.sessionId === selectedSessionId) ??
undefined;
if (!session || deleting) return;
const releasable =
(session.clientCount ?? 0) > 0 || session.hasActivePrompt === true;
@ -101,12 +135,13 @@ export function ReleaseSessionDialog({
onError,
onReleased,
releaseSession,
selectedIdx,
selectedSessionId,
t,
],
);
const selectedSession = filtered[selectedIdx];
const selectedSession =
filtered.find((s) => s.sessionId === selectedSessionId) ?? undefined;
const selectedReleasable =
selectedSession &&
((selectedSession.clientCount ?? 0) > 0 ||
@ -114,50 +149,62 @@ export function ReleaseSessionDialog({
const canRelease =
!deleting &&
!loading &&
hasSelectedSession &&
!!selectedSession &&
selectedSession.sessionId !== currentSessionId &&
!!selectedReleasable;
return (
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
<div className={dp('resume-picker-search')}>
<span className={dp('resume-picker-search-label')}>
<div className={dp('picker', 'picker-in-shell')}>
<div className={dp('picker-search')}>
<span className={dp('picker-search-label')}>
{t('resume.search')}:{' '}
</span>
<input
ref={inputRef}
className={dp('resume-picker-search-input')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedIdx(0);
setHasSelectedSession(false);
}}
className={dp('picker-search-input')}
aria-label={t('resume.search')}
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls={LIST_ID}
aria-activedescendant={
cursorIdx >= 0 && cursorIdx < filtered.length
? optionId(cursorIdx)
: undefined
}
{...inputProps}
placeholder=""
/>
<span className={dp('resume-picker-search-hint')}>
<span className={dp('picker-search-hint')}>
{message ||
(deleting
? t('release.releasing')
: loading
? t('common.loading')
: searchQuery
: filterQuery
? t('release.matches', { count: filtered.length })
: '')}
</span>
</div>
<div className={dp('resume-picker-sep')} />
<div className={dp('picker-sep')} />
<div className={dp('resume-picker-list')} ref={listRef}>
<div
id={LIST_ID}
role="listbox"
className={dp(
'picker-list',
keyboardMode ? 'picker-keyboard-only' : undefined,
)}
ref={listRef}
>
{loading && (
<div className={dp('resume-picker-empty')}>{t('common.loading')}</div>
<div className={dp('picker-empty')}>{t('common.loading')}</div>
)}
{!loading && filtered.length === 0 && (
<div className={dp('resume-picker-empty')}>
{searchQuery
? t('release.noMatch', { query: searchQuery })
<div className={dp('picker-empty')}>
{filterQuery
? t('release.noMatch', { query: filterQuery })
: t('release.none')}
</div>
)}
@ -168,57 +215,37 @@ export function ReleaseSessionDialog({
(s.clientCount ?? 0) > 0 || s.hasActivePrompt === true;
const isDisabled = isCurrent || !isReleasable;
return (
<div
<SessionRow
key={s.sessionId}
className={dp(
'resume-picker-item',
'resume-picker-session-item',
hasSelectedSession && i === selectedIdx
? 'selected'
: undefined,
isCurrent ? 'resume-picker-item-current' : undefined,
isDisabled ? 'disabled' : undefined,
)}
onClick={() => {
setSelectedIdx(i);
setHasSelectedSession(true);
}}
>
<div className={dp('resume-picker-item-row')}>
<span className={dp('resume-picker-item-title')}>
{s.displayName || s.sessionId.slice(0, 8)}
</span>
{isCurrent && (
<span className={dp('resume-picker-item-badge')}>
session={s}
optionId={optionId(i)}
active={i === cursorIdx}
confirmed={s.sessionId === selectedSessionId}
ariaSelected={s.sessionId === selectedSessionId}
// In release/delete dialogs, "current session" is just a
// disabled reason, not the confirmed target. Keep the stronger
// accent bar + ✓ for the actual confirmed release target only.
current={false}
disabled={isDisabled}
trailing={
isCurrent ? (
<span className={dp('picker-item-badge')}>
{t('resume.current')}
</span>
)}
{!isCurrent && !isReleasable && (
<span className={dp('resume-picker-item-badge')}>
) : !isReleasable ? (
<span className={dp('picker-item-badge')}>
{t('release.inactiveBadge')}
</span>
)}
</div>
<div className={dp('resume-picker-item-meta')}>
<span>
{(s.updatedAt || s.createdAt) &&
formatRelativeTime(s.updatedAt || s.createdAt || '', t)}
</span>
<span className={dp('resume-picker-item-detail')}>
{t('common.clients', { count: s.clientCount ?? 0 })}
</span>
{s.hasActivePrompt && (
<span className={dp('resume-picker-item-detail')}>
{t('resume.activePrompt')}
</span>
)}
</div>
</div>
) : undefined
}
onClick={() => confirmRow(i)}
onActivate={() => setCursorIdx(i)}
/>
);
})}
</div>
<div className={dp('resume-picker-sep')} />
<div className={dp('picker-sep')} />
<div className={dp('dialog-footer-actions')}>
<button
type="button"

View file

@ -0,0 +1,151 @@
// @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';
import { dp } from './dialogStyles';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
const sessions = [
{
sessionId: 'alpha-id',
displayName: 'Alpha',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'beta-id',
displayName: 'Beta',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'me',
displayName: 'Current Session',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
];
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useConnection: () => ({ sessionId: 'me' }),
useSessions: () => ({
sessions,
loading: false,
error: undefined,
}),
}));
const { ResumeDialog } = await import('./ResumeDialog');
let container: HTMLDivElement | null = null;
let root: Root | null = null;
let onSelect: ReturnType<typeof vi.fn>;
let onClose: ReturnType<typeof vi.fn>;
function mount() {
onSelect = vi.fn();
onClose = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root!.render(
<I18nProvider language="en">
<ResumeDialog onSelect={onSelect} onClose={onClose} />
</I18nProvider>,
);
});
}
function rows(): HTMLElement[] {
return Array.from(container!.querySelectorAll('[role="option"]'));
}
function press(key: string) {
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
);
});
}
// React installs a value tracker on the input, so set the value through the
// prototype's native setter for React's onChange to observe the change.
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'));
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('ResumeDialog', () => {
it('opens with no highlight; Enter does not switch sessions', () => {
mount();
expect(rows()).toHaveLength(3);
expect(rows().some(isCursor)).toBe(false);
// A reflexive Enter in the search box must not resume anything.
press('Enter');
expect(onSelect).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it('resumes the highlighted session on Enter after arrow navigation', () => {
mount();
// First ArrowDown lands on row 0; a second moves to row 1.
press('ArrowDown');
expect(isCursor(rows()[0])).toBe(true);
press('ArrowDown');
expect(isCursor(rows()[1])).toBe(true);
press('Enter');
expect(onSelect).toHaveBeenCalledWith('beta-id');
expect(onClose).toHaveBeenCalledTimes(1);
});
it('resets the highlight on filter edits and confirms within the filtered list', () => {
mount();
// Navigate first, then type: editing the filter must clear the highlight
// so Enter cannot confirm a row the user did not visibly re-pick.
press('ArrowDown');
expect(isCursor(rows()[0])).toBe(true);
typeFilter('beta');
expect(rows()).toHaveLength(1);
expect(rows().some(isCursor)).toBe(false);
press('Enter');
expect(onSelect).not.toHaveBeenCalled();
// Re-navigating confirms the correct session from the filtered list.
press('ArrowDown');
expect(isCursor(rows()[0])).toBe(true);
press('Enter');
expect(onSelect).toHaveBeenCalledWith('beta-id');
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -2,26 +2,36 @@ import { useState, useEffect, useRef } 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 ResumeDialogProps {
onSelect: (sessionId: string) => void;
onClose: () => void;
}
const LIST_ID = 'resume-session-list';
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) {
const { t } = useI18n();
const connection = useConnection();
const { sessions, loading, error } = useSessions({ autoLoad: true });
const currentSessionId = connection.sessionId;
const [selectedIdx, setSelectedIdx] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
// -1 = no highlight. The dialog opens with nothing highlighted and resets to
// none on filter edits, so Enter in the search box cannot confirm a row the
// user didn't pick — the highlight only appears once they press ↓/↑ or hover.
const [selectedIdx, setSelectedIdx] = useState(-1);
const { filterValue: filterQuery, inputProps } = useFilterInput(() =>
setSelectedIdx(-1),
);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = searchQuery
const filtered = filterQuery
? sessions.filter((s) => {
const q = searchQuery.toLowerCase();
const q = filterQuery.toLowerCase();
return (
(s.displayName || '').toLowerCase().includes(q) ||
s.sessionId.toLowerCase().includes(q)
@ -44,89 +54,87 @@ export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) {
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIdx]);
const confirm = (index: number) => {
const session = filtered[index];
if (!session) return;
onSelect(session.sessionId);
onClose();
};
const { keyboardMode } = useListboxKeyboard({
itemCount: filtered.length,
activeIndex: selectedIdx,
onActiveIndexChange: setSelectedIdx,
onConfirm: confirm,
});
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
<div className={dp('resume-picker-search')}>
<span className={dp('resume-picker-search-label')}>
<div className={dp('picker', 'picker-in-shell')}>
<div className={dp('picker-search')}>
<span className={dp('picker-search-label')}>
{t('resume.search')}:{' '}
</span>
<input
ref={inputRef}
className={dp('resume-picker-search-input')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedIdx(0);
}}
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=""
/>
</div>
<div className={dp('resume-picker-sep')} />
<div className={dp('picker-sep')} />
<div className={dp('resume-picker-list')} ref={listRef}>
<div
id={LIST_ID}
role="listbox"
className={dp(
'picker-list',
keyboardMode ? 'picker-keyboard-only' : undefined,
)}
ref={listRef}
>
{loading && (
<div className={dp('resume-picker-empty')}>{t('common.loading')}</div>
<div className={dp('picker-empty')}>{t('common.loading')}</div>
)}
{!loading && error && (
<div className={dp('resume-picker-empty')}>
<div className={dp('picker-empty')}>
{error.message || 'Failed to load sessions'}
</div>
)}
{!loading && !error && filtered.length === 0 && (
<div className={dp('resume-picker-empty')}>
{searchQuery
? t('resume.noMatch', { query: searchQuery })
<div className={dp('picker-empty')}>
{filterQuery
? t('resume.noMatch', { query: filterQuery })
: t('resume.none')}
</div>
)}
{!loading &&
filtered.map((s) => {
const isCurrent = s.sessionId === currentSessionId;
return (
<div
key={s.sessionId}
className={dp(
'resume-picker-item',
'resume-picker-session-item',
isCurrent ? 'resume-picker-item-current' : undefined,
)}
onClick={() => {
onSelect(s.sessionId);
onClose();
}}
>
<div className={dp('resume-picker-item-row')}>
<span className={dp('resume-picker-item-title')}>
{s.displayName || s.sessionId.slice(0, 8)}
</span>
{isCurrent && (
<span className={dp('resume-picker-item-badge')}>
{t('resume.current')}
</span>
)}
</div>
<div className={dp('resume-picker-item-meta')}>
<span>
{(s.updatedAt || s.createdAt) &&
formatRelativeTime(s.updatedAt || s.createdAt || '', t)}
</span>
<span className={dp('resume-picker-item-detail')}>
{t('common.clients', { count: s.clientCount ?? 0 })}
</span>
{s.hasActivePrompt && (
<span className={dp('resume-picker-item-detail')}>
{t('resume.activePrompt')}
</span>
)}
</div>
</div>
);
})}
filtered.map((s, index) => (
<SessionRow
key={s.sessionId}
session={s}
optionId={optionId(index)}
active={index === selectedIdx}
current={s.sessionId === currentSessionId}
currentLabel={t('resume.current')}
onClick={() => confirm(index)}
onActivate={() => setSelectedIdx(index)}
/>
))}
</div>
</div>
);

View file

@ -12,7 +12,14 @@
padding: 4px 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;
}
.item {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@ -29,12 +36,45 @@
cursor: pointer;
}
/* Roving cursor (keyboard/hover position) a plain gray highlight, matching
the other list dialogs. */
.item:hover,
.itemSelected {
.itemCursor {
background: var(--secondary);
}
.item:disabled {
/* Confirmed selection: stronger than the cursor a left accent bar plus a blue
title, so the chosen row stays distinguishable from the row merely under the
cursor (which only gets the gray background). */
.itemSelected::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 60%;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--agent-blue-500, #4a9eff);
}
.itemSelected .prompt {
color: var(--agent-blue-500, #4a9eff);
font-weight: 600;
}
/* Keyboard mode: the pointer yields to the keyboard, so a cursor resting on a
row must not paint the hover highlight only the keyboard cursor row does. */
.keyboardOnly .item:hover {
background: transparent;
}
.keyboardOnly .itemCursor:hover,
.keyboardOnly .itemCursor {
background: var(--secondary);
}
.itemDisabled {
cursor: default;
opacity: 0.6;
}
@ -62,10 +102,19 @@
.footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 10px 8px 0;
}
/* Inline failure text: toasts can be deduplicated by the app shell, so the
dialog must show the error itself (mirrors delete/release's message hint). */
.footerMessage {
margin-right: auto;
color: var(--error-color);
font-size: 12px;
}
.dangerButton {
border-color: var(--error-color);
color: var(--error-color);

View file

@ -0,0 +1,158 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type {
DaemonRewindSnapshotInfo,
DaemonTranscriptBlock,
} from '@qwen-code/sdk/daemon';
import { I18nProvider } from '../../i18n';
import { RewindDialog } from './RewindDialog';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
const blocks = [
{ kind: 'user', text: 'first turn' },
{ kind: 'user', text: 'second turn' },
] as unknown as DaemonTranscriptBlock[];
const snapshots: DaemonRewindSnapshotInfo[] = [
{ promptId: 'p0', turnIndex: 0, timestamp: '2026-01-01T00:00:00.000Z' },
{ promptId: 'p1', turnIndex: 1, timestamp: '2026-01-01T00:01:00.000Z' },
] as unknown as DaemonRewindSnapshotInfo[];
let container: HTMLDivElement | null = null;
let root: Root | null = null;
async function mount(rewind: (id: string) => Promise<void>) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<I18nProvider language="en">
<RewindDialog
blocks={blocks}
loadSnapshots={() => Promise.resolve({ snapshots })}
rewind={rewind}
onError={vi.fn()}
onClose={vi.fn()}
/>
</I18nProvider>,
);
});
// Flush the async loadSnapshots() effect.
await act(async () => {});
}
function press(key: string) {
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key, cancelable: true }),
);
});
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('RewindDialog keyboard', () => {
function rewindButton(): HTMLButtonElement {
return Array.from(container!.querySelectorAll('button')).find((el) =>
/rewind/i.test(el.textContent || ''),
) as HTMLButtonElement;
}
it('does not confirm a target until Enter — the button stays disabled', async () => {
const rewind = vi.fn().mockResolvedValue(undefined);
await mount(rewind);
// Moving the cursor with arrows must not confirm anything yet.
press('ArrowDown');
press('ArrowUp');
expect(rewind).not.toHaveBeenCalled();
expect(rewindButton().disabled).toBe(true);
// Enter commits the cursor row; it still does not run the rewind itself.
press('Enter');
expect(rewind).not.toHaveBeenCalled();
expect(rewindButton().disabled).toBe(false);
});
it('the button rewinds the snapshot confirmed via keyboard', async () => {
const rewind = vi.fn().mockResolvedValue(undefined);
await mount(rewind);
// Move cursor to the 2nd snapshot and confirm it with Enter.
press('ArrowDown');
press('Enter');
act(() => {
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(rewind).toHaveBeenCalledWith('p1');
});
it('disables keyboard navigation while a rewind is in flight', async () => {
// A rewind that never settles keeps isRewinding true.
const rewind = vi.fn().mockReturnValue(new Promise<void>(() => {}));
await mount(rewind);
const activeDescendant = () =>
container!
.querySelector('[role="listbox"]')!
.getAttribute('aria-activedescendant');
press('ArrowDown');
press('Enter');
expect(activeDescendant()).toBe('rewind-snapshot-list-opt-1');
act(() => {
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(rewind).toHaveBeenCalledWith('p1');
// While rewinding, arrows must not move the highlight (enabled: false).
press('ArrowUp');
expect(activeDescendant()).toBe('rewind-snapshot-list-opt-1');
});
it('shows an inline error and re-enables the button when rewind fails', async () => {
const rewind = vi.fn().mockRejectedValue(new Error('boom'));
await mount(rewind);
press('ArrowDown');
press('Enter');
await act(async () => {
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// The failure is visible in-dialog (toasts may be deduplicated upstream)
// and the user can retry.
expect(container!.textContent).toContain('boom');
expect(rewindButton().disabled).toBe(false);
});
it('pulls focus into the listbox once snapshots arrive, if parked on the panel', async () => {
// Simulate DialogShell's fallback: nothing focusable during loading, so
// focus sits on the dialog panel.
const panel = document.createElement('div');
panel.setAttribute('role', 'dialog');
panel.tabIndex = -1;
document.body.appendChild(panel);
panel.focus();
expect(document.activeElement).toBe(panel);
await mount(vi.fn().mockResolvedValue(undefined));
const listbox = container!.querySelector<HTMLElement>('[role="listbox"]');
expect(document.activeElement).toBe(listbox);
panel.remove();
});
});

View file

@ -1,12 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
DaemonRewindSnapshotInfo,
DaemonTranscriptBlock,
} from '@qwen-code/sdk/daemon';
import { useI18n } from '../../i18n';
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
import { dp } from './dialogStyles';
import styles from './RewindDialog.module.css';
const LIST_ID = 'rewind-snapshot-list';
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
interface RewindDialogProps {
blocks: readonly DaemonTranscriptBlock[];
loadSnapshots: () => Promise<{ snapshots: DaemonRewindSnapshotInfo[] }>;
@ -47,7 +51,15 @@ export function RewindDialog({
const [rewindingPromptId, setRewindingPromptId] = useState<string | null>(
null,
);
// `cursorIdx` is the roving keyboard/hover highlight; `selectedPromptId` is
// the confirmed target the danger button acts on. They are separate so moving
// the highlight with the arrow keys does not change what will be rewound until
// the user commits with Enter or a click.
const [cursorIdx, setCursorIdx] = useState(0);
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
// Inline failure text. The app-level onError toast deduplicates repeats, so
// a second identical failure would otherwise be invisible in this dialog.
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
let alive = true;
@ -78,57 +90,113 @@ export function RewindDialog({
[blocks, snapshots],
);
// Keep the cursor in range as snapshots load / change.
useEffect(() => {
if (items.length > 0 && !selectedPromptId) {
setSelectedPromptId(items[0]!.snapshot.promptId);
if (cursorIdx >= items.length && items.length > 0) {
setCursorIdx(items.length - 1);
}
}, [items, selectedPromptId]);
}, [items.length, cursorIdx]);
const handleRewind = () => {
const promptId = selectedPromptId;
if (!promptId) return;
if (rewindingPromptId) return;
const listRef = useRef<HTMLDivElement>(null);
const isRewinding = rewindingPromptId !== null;
const handleRewind = (promptId: string | null) => {
if (!promptId || rewindingPromptId) return;
setRewindingPromptId(promptId);
setMessage(null);
rewind(promptId)
.then(() => {
onClose();
})
.catch((error: unknown) => {
onError(error);
setMessage(
t('rewind.failed', {
reason: error instanceof Error ? error.message : String(error),
}),
);
setRewindingPromptId(null);
});
};
// Arrows move the cursor (highlight) only; Enter/click commits the cursor row
// as the confirmed target. The irreversible rewind stays behind the danger
// button, consistent with the other destructive dialogs (delete / release).
const commitRow = (index: number) => {
const item = items[index];
if (item) {
setCursorIdx(index);
setSelectedPromptId(item.snapshot.promptId);
}
};
const { keyboardMode } = useListboxKeyboard({
itemCount: items.length,
activeIndex: cursorIdx,
onActiveIndexChange: setCursorIdx,
onConfirm: commitRow,
enabled: !isRewinding,
});
useEffect(() => {
const el = listRef.current?.children[cursorIdx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [cursorIdx]);
// Snapshots load asynchronously: while loading, nothing in this dialog is
// focusable, so DialogShell parks focus on the dialog panel. Once the listbox
// mounts, pull focus into it — but only if focus is still parked on the panel
// — so screen readers announce the active option via aria-activedescendant
// instead of staying silent until the user tabs into the list.
useEffect(() => {
if (loading || items.length === 0) return;
const active = document.activeElement;
if (active?.getAttribute('role') === 'dialog') {
listRef.current?.focus();
}
}, [loading, items.length]);
if (loading) {
return (
<div className={dp('resume-picker-empty')}>{t('rewind.loading')}</div>
);
return <div className={dp('picker-empty')}>{t('rewind.loading')}</div>;
}
if (items.length === 0) {
return <div className={dp('resume-picker-empty')}>{t('rewind.empty')}</div>;
return <div className={dp('picker-empty')}>{t('rewind.empty')}</div>;
}
return (
<div className={styles.root}>
<div className={styles.list} role="list">
{items.map(({ snapshot, promptText }) => {
const selected = selectedPromptId === snapshot.promptId;
const disabled = rewindingPromptId !== null;
<div
className={`${styles.list} ${keyboardMode ? styles.keyboardOnly : ''}`}
ref={listRef}
role="listbox"
aria-label={t('rewind.title')}
tabIndex={0}
aria-activedescendant={
items.length > 0 ? optionId(cursorIdx) : undefined
}
>
{items.map(({ snapshot, promptText }, index) => {
const isCursor = index === cursorIdx;
const isSelected = selectedPromptId === snapshot.promptId;
const label =
promptText ||
t('rewind.promptFallback', {
id: snapshot.promptId.slice(-8),
});
return (
<button
<div
key={snapshot.promptId}
type="button"
className={`${styles.item} ${
selected ? styles.itemSelected : ''
}`}
disabled={disabled}
onClick={() => setSelectedPromptId(snapshot.promptId)}
id={optionId(index)}
role="option"
aria-selected={isSelected}
aria-disabled={isRewinding || undefined}
className={`${styles.item} ${isCursor ? styles.itemCursor : ''} ${
isSelected ? styles.itemSelected : ''
} ${isRewinding ? styles.itemDisabled : ''}`}
onClick={() => {
if (!isRewinding) commitRow(index);
}}
onMouseMove={() => setCursorIdx(index)}
>
<div className={styles.prompt} title={label}>
<span className={styles.turn}>#{snapshot.turnIndex + 1}</span>{' '}
@ -137,11 +205,16 @@ export function RewindDialog({
<div className={styles.time}>
{formatSnapshotTime(snapshot.timestamp)}
</div>
</button>
</div>
);
})}
</div>
<div className={styles.footer}>
{message && (
<span className={styles.footerMessage} role="alert">
{message}
</span>
)}
<button
type="button"
className={dp('dialog-inline-button')}
@ -153,7 +226,7 @@ export function RewindDialog({
<button
type="button"
className={`${dp('dialog-danger-button')} ${styles.dangerButton}`}
onClick={handleRewind}
onClick={() => handleRewind(selectedPromptId)}
disabled={!selectedPromptId || rewindingPromptId !== null}
>
{rewindingPromptId ? t('rewind.rewinding') : t('rewind.confirm')}

View file

@ -0,0 +1,158 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { DaemonSessionSummary } from '@qwen-code/webui/daemon-react-sdk';
import { I18nProvider } from '../../i18n';
import { dp } from './dialogStyles';
import { SessionRow } from './SessionRow';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const session = {
sessionId: 'sess-1234abcd',
displayName: 'My Session',
updatedAt: '2026-01-01T00:00:00.000Z',
clientCount: 2,
hasActivePrompt: true,
} as unknown as DaemonSessionSummary;
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(<I18nProvider language="en">{node}</I18nProvider>);
});
}
function row(): HTMLElement {
return container!.querySelector('[role="option"]')!;
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('SessionRow', () => {
it('renders an option with the title and metadata', () => {
mount(
<SessionRow
session={session}
active={false}
current={false}
onClick={vi.fn()}
onActivate={vi.fn()}
/>,
);
expect(row().getAttribute('role')).toBe('option');
expect(row().textContent).toContain('My Session');
// clientCount surfaces in the meta line.
expect(row().textContent).toContain('2');
});
it('defaults aria-selected to `current` (not the roving highlight), explicit value wins', () => {
// The roving highlight must NOT be announced as "selected" — per WAI-ARIA
// it is conveyed by aria-activedescendant, while aria-selected marks the
// chosen value (here: the current session, also exposed via aria-current).
mount(
<SessionRow
session={session}
active={true}
current={false}
onClick={vi.fn()}
onActivate={vi.fn()}
/>,
);
expect(row().getAttribute('aria-selected')).toBe('false');
expect(row().getAttribute('aria-current')).toBeNull();
act(() => root?.unmount());
container?.remove();
mount(
<SessionRow
session={session}
active={false}
current={true}
onClick={vi.fn()}
onActivate={vi.fn()}
/>,
);
expect(row().getAttribute('aria-selected')).toBe('true');
expect(row().getAttribute('aria-current')).toBe('true');
act(() => root?.unmount());
container?.remove();
// Multi-select: an explicit ariaSelected (checked state) wins over current.
mount(
<SessionRow
session={session}
active={true}
ariaSelected={false}
current={true}
onClick={vi.fn()}
onActivate={vi.fn()}
/>,
);
expect(row().getAttribute('aria-selected')).toBe('false');
});
it('marks the current session and exposes the label as a tooltip', () => {
mount(
<SessionRow
session={session}
active={false}
current={true}
currentLabel="current"
onClick={vi.fn()}
onActivate={vi.fn()}
/>,
);
expect(row().className).toContain(dp('dialog-current'));
expect(row().getAttribute('title')).toBe('current');
});
it('renders leading and trailing slots', () => {
mount(
<SessionRow
session={session}
active={false}
current={false}
leading={<span data-testid="lead">[x]</span>}
trailing={<span data-testid="trail">inactive</span>}
onClick={vi.fn()}
onActivate={vi.fn()}
/>,
);
expect(container!.querySelector('[data-testid="lead"]')).not.toBeNull();
expect(container!.querySelector('[data-testid="trail"]')).not.toBeNull();
});
it('fires onClick and onActivate', () => {
const onClick = vi.fn();
const onActivate = vi.fn();
mount(
<SessionRow
session={session}
active={false}
current={false}
onClick={onClick}
onActivate={onActivate}
/>,
);
act(() => {
row().dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
act(() => {
row().dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onActivate).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,104 @@
import { type ReactNode } from 'react';
import { type DaemonSessionSummary } from '@qwen-code/webui/daemon-react-sdk';
import { dp } from './dialogStyles';
import { useI18n } from '../../i18n';
import { formatRelativeTime } from '../../utils/formatRelativeTime';
interface SessionRowProps {
session: DaemonSessionSummary;
/** Roving keyboard/hover highlight. */
active: boolean;
/** The user's current session — marks it with the accent bar + ✓. */
current: boolean;
/** Confirmed target for a destructive action (release), distinct from cursor. */
confirmed?: boolean;
/** Non-actionable row (e.g. the current session, or an inactive one). */
disabled?: boolean;
/** Tooltip shown when `current` (the pseudo-element ✓ can't carry text). */
currentLabel?: string;
/** Stable id so the listbox can point `aria-activedescendant` at this row. */
optionId?: string;
/**
* `aria-selected` value. Per WAI-ARIA this marks the chosen value, not the
* roving highlight (which `aria-activedescendant` conveys) so it defaults
* to `current`. Multi-select (delete) passes the checked state and release
* passes its confirmed target instead.
*/
ariaSelected?: boolean;
/** Leading slot, e.g. a multi-select checkbox. */
leading?: ReactNode;
/** Trailing slot in the title row, e.g. a status badge. */
trailing?: ReactNode;
onClick: () => void;
/**
* Pointer moved over the row (real movement see useListboxKeyboard). This
* updates the roving cursor only; callers that separate cursor from confirmed
* target (e.g. release/rewind) still keep the destructive action behind an
* explicit Enter/click + button flow.
*/
onActivate?: () => void;
}
/**
* A session list row shared by the resume / delete / release dialogs. Owns the
* common shell (roving highlight, current marker, disabled state) and the
* identical metadata line (relative time · client count · active prompt);
* per-dialog affordances go through the `leading`/`trailing` slots.
*/
export function SessionRow({
session,
active,
current,
confirmed,
disabled,
currentLabel,
optionId,
ariaSelected,
leading,
trailing,
onClick,
onActivate,
}: SessionRowProps) {
const { t } = useI18n();
const timestamp = session.updatedAt || session.createdAt;
return (
<div
id={optionId}
role="option"
aria-selected={ariaSelected ?? current}
aria-current={current ? 'true' : undefined}
aria-disabled={disabled || undefined}
className={dp(
'picker-item',
'picker-session-item',
active ? 'selected' : undefined,
current ? 'dialog-current' : undefined,
confirmed ? 'picker-item-confirmed' : undefined,
disabled ? 'disabled' : undefined,
)}
title={current ? currentLabel : undefined}
onClick={onClick}
onMouseMove={onActivate}
>
<div className={dp('picker-item-row')}>
{leading}
<span className={dp('picker-item-title')}>
{session.displayName || session.sessionId.slice(0, 8)}
</span>
{trailing}
</div>
<div className={dp('picker-item-meta')}>
<span>{timestamp && formatRelativeTime(timestamp, t)}</span>
<span className={dp('picker-item-detail')}>
{t('common.clients', { count: session.clientCount ?? 0 })}
</span>
{session.hasActivePrompt && (
<span className={dp('picker-item-detail')}>
{t('resume.activePrompt')}
</span>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
// @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';
import { WebShellThemeId } from '../../themeContext';
import { ThemeDialog } from './ThemeDialog';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// jsdom has no layout — stub the scroll-into-view the list uses on selection.
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
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(<I18nProvider language="en">{node}</I18nProvider>);
});
}
function press(key: string) {
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key, cancelable: true }),
);
});
}
function listbox(): HTMLElement {
return container!.querySelector('[role="listbox"]')!;
}
/** The option the listbox currently points `aria-activedescendant` at. */
function activeOption(): HTMLElement | null {
const id = listbox().getAttribute('aria-activedescendant');
return id ? document.getElementById(id) : null;
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('ThemeDialog aria-activedescendant coupling', () => {
it('points aria-activedescendant at an existing option, tracking arrow nav', () => {
mount(
<ThemeDialog
currentTheme={WebShellThemeId.Dark}
onSelect={vi.fn()}
onClose={vi.fn()}
/>,
);
// Opens on the current theme; the referenced id must resolve to a real
// option element (guards the hand-built `theme-opt-<i>` id scheme).
const initial = activeOption();
expect(initial).not.toBeNull();
expect(initial!.getAttribute('role')).toBe('option');
expect(initial!.getAttribute('aria-selected')).toBe('true');
// Arrowing moves the reference, and it still resolves to a real option.
press('ArrowDown');
const next = activeOption();
expect(next).not.toBeNull();
expect(next!.getAttribute('role')).toBe('option');
expect(next).not.toBe(initial);
});
});

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { dp } from './dialogStyles';
import { useI18n } from '../../i18n';
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
import { WEB_SHELL_THEMES, type WebShellTheme } from '../../themeContext';
interface ThemeDialogProps {
@ -26,6 +27,20 @@ export function ThemeDialog({
});
const listRef = useRef<HTMLDivElement>(null);
const confirm = (index: number) => {
const theme = themes[index];
if (!theme) return;
onSelect(theme.id);
onClose();
};
const { keyboardMode } = useListboxKeyboard({
itemCount: themes.length,
activeIndex: selectedIdx,
onActiveIndexChange: setSelectedIdx,
onConfirm: confirm,
});
useEffect(() => {
const el = listRef.current?.children[selectedIdx] as
| HTMLElement
@ -35,41 +50,41 @@ export function ThemeDialog({
return (
<div
className={dp('resume-picker-list', 'resume-picker-list-compact')}
className={dp(
'picker-list',
'picker-list-compact',
keyboardMode ? 'picker-keyboard-only' : undefined,
)}
ref={listRef}
role="listbox"
aria-label={t('theme.title')}
tabIndex={0}
aria-activedescendant={
themes.length > 0 ? `theme-opt-${selectedIdx}` : undefined
}
>
{themes.map((theme, index) => {
const selected = theme.id === currentTheme;
return (
<button
<div
key={theme.id}
type="button"
id={`theme-opt-${index}`}
role="option"
aria-selected={selected}
className={dp(
'resume-picker-item',
'resume-picker-session-item',
index === selectedIdx || selected ? 'selected' : undefined,
'picker-item',
'picker-session-item',
index === selectedIdx ? 'selected' : undefined,
selected ? 'dialog-current' : undefined,
)}
onClick={() => {
onSelect(theme.id);
onClose();
}}
onMouseEnter={() => setSelectedIdx(index)}
onClick={() => confirm(index)}
onMouseMove={() => setSelectedIdx(index)}
>
<div className={dp('resume-picker-item-row')}>
<span className={dp('resume-picker-item-title')}>
{theme.label}
</span>
{selected && (
<span className={dp('resume-picker-item-check')}> </span>
)}
<div className={dp('picker-item-row')}>
<span className={dp('picker-item-title')}>{theme.label}</span>
</div>
<div className={dp('resume-picker-item-meta')}>
{theme.description}
</div>
</button>
<div className={dp('picker-item-meta')}>{theme.description}</div>
</div>
);
})}
</div>

View file

@ -0,0 +1,88 @@
// @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 = () => {};
}
const tools = [
{ name: 'tool-a', displayName: 'Tool A', enabled: true, description: 'A' },
{ name: 'tool-b', displayName: 'Tool B', enabled: false },
];
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useTools: () => ({
status: { errors: [] },
tools,
loading: false,
error: undefined,
}),
}));
const { ToolsDialog } = await import('./ToolsDialog');
let container: HTMLDivElement | null = null;
let root: Root | null = null;
function mount() {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root!.render(
<I18nProvider language="en">
<ToolsDialog />
</I18nProvider>,
);
});
}
function press(key: string) {
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key, cancelable: true }),
);
});
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('ToolsDialog a11y wiring', () => {
it('exposes a listbox with option ids, aria-activedescendant and aria-expanded', () => {
mount();
const list = container!.querySelector<HTMLElement>('[role="listbox"]');
expect(list).not.toBeNull();
expect(list!.getAttribute('aria-activedescendant')).toBe(
'tools-list-opt-0',
);
const options = Array.from(
container!.querySelectorAll<HTMLElement>('[role="option"]'),
);
expect(options).toHaveLength(2);
expect(options[0]!.id).toBe('tools-list-opt-0');
expect(options[0]!.getAttribute('aria-expanded')).toBe('false');
expect(options[1]!.getAttribute('aria-expanded')).toBeNull();
press('ArrowDown');
expect(list!.getAttribute('aria-activedescendant')).toBe(
'tools-list-opt-1',
);
// Move back and expand the first tool via Enter.
press('ArrowUp');
press('Enter');
expect(options[0]!.getAttribute('aria-expanded')).toBe('true');
});
});

View file

@ -5,11 +5,15 @@ import {
type DaemonWorkspaceToolStatus,
} from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
function toolLabel(tool: DaemonWorkspaceToolStatus): string {
return tool.displayName || tool.name;
}
const LIST_ID = 'tools-list';
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
export function ToolsDialog() {
const { t } = useI18n();
const { status, tools, loading, error } = useTools({
@ -47,6 +51,16 @@ export function ToolsDialog() {
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIdx]);
const { keyboardMode } = useListboxKeyboard({
itemCount: tools.length,
activeIndex: selectedIdx,
onActiveIndexChange: setSelectedIdx,
onConfirm: (index) => {
const tool = tools[index];
if (tool?.description) toggleDetails(tool);
},
});
const summary = useMemo(() => {
if (!status) return '';
const enabled = tools.filter((tool) => tool.enabled).length;
@ -54,25 +68,38 @@ export function ToolsDialog() {
}, [status, tools, t]);
return (
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
<div className={dp('picker', 'picker-in-shell')}>
{summary && (
<div className={dp('resume-picker-search')}>
<span className={dp('resume-picker-search-hint')}>{summary}</span>
<div className={dp('picker-search')}>
<span className={dp('picker-search-hint')}>{summary}</span>
</div>
)}
{(message || loading) && (
<div className={dp('resume-picker-search')}>
<span className={dp('resume-picker-search-hint')}>
<div className={dp('picker-search')}>
<span className={dp('picker-search-hint')}>
{message || t('tools.loading')}
</span>
</div>
)}
<div className={dp('resume-picker-sep')} />
<div className={dp('picker-sep')} />
<div className={dp('resume-picker-list')} ref={listRef}>
<div
id={LIST_ID}
role="listbox"
aria-label={t('tools.title')}
tabIndex={0}
aria-activedescendant={
tools.length > 0 ? optionId(selectedIdx) : undefined
}
className={dp(
'picker-list',
keyboardMode ? 'picker-keyboard-only' : undefined,
)}
ref={listRef}
>
{!loading && tools.length === 0 && (
<div className={dp('resume-picker-empty')}>{t('tools.empty')}</div>
<div className={dp('picker-empty')}>{t('tools.empty')}</div>
)}
{tools.map((tool, i) => {
const expanded = expandedTools.has(tool.name);
@ -80,21 +107,29 @@ export function ToolsDialog() {
return (
<div
key={tool.name}
id={optionId(i)}
role="option"
// Informational list — rows are expanded, never "chosen", so no
// row is ever aria-selected; the roving highlight is conveyed by
// aria-activedescendant on the listbox.
aria-selected={false}
aria-expanded={desc ? expanded : undefined}
className={dp(
'resume-picker-item',
'resume-picker-session-item',
'picker-item',
'picker-session-item',
'tools-picker-item',
expanded ? 'selected' : undefined,
i === selectedIdx ? 'selected' : undefined,
expanded ? 'tools-picker-item-expanded' : undefined,
)}
onClick={() => {
setSelectedIdx(i);
if (tool.description) toggleDetails(tool);
}}
onMouseMove={() => setSelectedIdx(i)}
>
<div className={dp('resume-picker-item-row')}>
<div className={dp('picker-item-row')}>
<span className={dp('tools-item-icon')} aria-hidden="true" />
<span className={dp('resume-picker-item-title')}>
<span className={dp('picker-item-title')}>
{toolLabel(tool)}
</span>
<span

View file

@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useFilterInput, type UseFilterInputResult } from './useFilterInput';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let latest: UseFilterInputResult | null = null;
function Harness({
onFilterChange,
}: {
onFilterChange?: (value: string) => void;
}) {
const result = useFilterInput(onFilterChange);
latest = result;
return <input {...result.inputProps} />;
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
function mount(onFilterChange?: (value: string) => void) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root!.render(<Harness onFilterChange={onFilterChange} />);
});
}
function input(): HTMLInputElement {
return container!.querySelector('input')!;
}
// React installs a value tracker on the input element, so assigning `.value`
// directly is ignored by its onChange. Use the prototype's native setter so the
// tracker observes the change and React fires onChange.
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value',
)!.set!;
function type(value: string) {
act(() => {
const el = input();
nativeInputValueSetter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
});
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
latest = null;
});
describe('useFilterInput', () => {
it('commits the filter value on plain (non-composition) input', () => {
const onFilterChange = vi.fn();
mount(onFilterChange);
type('abc');
expect(input().value).toBe('abc');
expect(latest!.filterValue).toBe('abc');
expect(onFilterChange).toHaveBeenLastCalledWith('abc');
});
it('holds the filter value steady during IME composition, committing on end', () => {
const onFilterChange = vi.fn();
mount(onFilterChange);
act(() => {
input().dispatchEvent(
new CompositionEvent('compositionstart', { bubbles: true }),
);
});
// Intermediate pinyin keystrokes update the visible value but not the filter.
type('ni');
type('nih');
expect(input().value).toBe('nih');
expect(latest!.filterValue).toBe('');
expect(onFilterChange).not.toHaveBeenCalled();
// Committing the composition applies the final value once.
act(() => {
const el = input();
nativeInputValueSetter.call(el, '你好');
el.dispatchEvent(
new CompositionEvent('compositionend', { bubbles: true, data: '你好' }),
);
});
expect(latest!.filterValue).toBe('你好');
expect(onFilterChange).toHaveBeenLastCalledWith('你好');
// No `input` event followed the compositionend here, so this proves the
// controlled value is synced by compositionend itself — the field must
// never display a stale preedit while the list filters by the new value.
expect(input().value).toBe('你好');
});
it('ignores a cancelled composition that leaves the value unchanged', () => {
const onFilterChange = vi.fn();
mount(onFilterChange);
type('abc');
expect(onFilterChange).toHaveBeenCalledTimes(1);
// Start a composition, type an intermediate pinyin char, then cancel it
// (e.g. Esc): the visible value returns to 'abc' and compositionend fires.
act(() => {
input().dispatchEvent(
new CompositionEvent('compositionstart', { bubbles: true }),
);
});
type('abcn');
act(() => {
const el = input();
nativeInputValueSetter.call(el, 'abc');
el.dispatchEvent(
new CompositionEvent('compositionend', { bubbles: true, data: '' }),
);
});
// The committed filter never changed, so consumers must not be notified —
// a notification would reset dialog selection state (e.g. delete checks).
expect(latest!.filterValue).toBe('abc');
expect(onFilterChange).toHaveBeenCalledTimes(1);
// A real edit afterwards still commits normally.
type('abcd');
expect(latest!.filterValue).toBe('abcd');
expect(onFilterChange).toHaveBeenLastCalledWith('abcd');
});
});

View file

@ -0,0 +1,95 @@
import {
useCallback,
useRef,
useState,
type ChangeEvent,
type CompositionEvent,
} from 'react';
export interface FilterInputProps {
value: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onCompositionStart: () => void;
onCompositionEnd: (event: CompositionEvent<HTMLInputElement>) => void;
}
export interface UseFilterInputResult {
/**
* Committed value to filter by. Only changes once a keystroke is committed,
* never while an IME composition is in flight, so filtering a list by this
* value does not refilter/relayout on every intermediate pinyin character.
*/
filterValue: string;
/** Spread onto the search `<input>` (carries the raw value + IME handlers). */
inputProps: FilterInputProps;
}
/**
* Search-field state for filterable list dialogs, hardened against IME
* composition jitter: the field reflects every keystroke while the committed
* `filterValue` (used to filter) updates only on non-composition input and on
* `compositionend`.
*
* `onFilterChange` fires with the committed value whenever it changes dialogs
* use it to reset their own selection/cursor state.
*/
export function useFilterInput(
onFilterChange?: (value: string) => void,
): UseFilterInputResult {
const [inputValue, setInputValue] = useState('');
const [filterValue, setFilterValue] = useState('');
const composingRef = useRef(false);
const committedRef = useRef('');
const onFilterChangeRef = useRef(onFilterChange);
onFilterChangeRef.current = onFilterChange;
const commit = useCallback((value: string) => {
// `compositionend` fires even when the composition was cancelled and the
// text is back to what it was. A no-op commit must not signal a filter
// change — dialogs reset their selection/cursor state on that signal, so
// a cancelled composition would wipe e.g. the delete dialog's checkmarks.
if (value === committedRef.current) return;
committedRef.current = value;
setFilterValue(value);
onFilterChangeRef.current?.(value);
}, []);
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setInputValue(value);
// Skip filtering while an IME composition is in flight; compositionend
// commits the final value.
if (!composingRef.current) commit(value);
},
[commit],
);
const onCompositionStart = useCallback(() => {
composingRef.current = true;
}, []);
const onCompositionEnd = useCallback(
(event: CompositionEvent<HTMLInputElement>) => {
composingRef.current = false;
const value = event.currentTarget.value;
// Most browsers follow compositionend with an `input` event that syncs
// the controlled value, but that's not guaranteed — sync it here too so
// the field can never display a stale preedit while the list already
// filters by the committed text.
setInputValue(value);
commit(value);
},
[commit],
);
return {
filterValue,
inputProps: {
value: inputValue,
onChange,
onCompositionStart,
onCompositionEnd,
},
};
}

View file

@ -0,0 +1,406 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { DialogShell } from '../components/dialogs/DialogShell';
import { I18nProvider } from '../i18n';
import { ThemeProvider } from '../themeContext';
import { createRoot, type Root } from 'react-dom/client';
import { useListboxKeyboard } from './useListboxKeyboard';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
interface HarnessProps {
itemCount: number;
activeIndex: number;
onActiveIndexChange: (index: number) => void;
onConfirm: (index: number) => void;
enabled?: boolean;
onKeyboardMode?: (value: boolean) => void;
}
function Harness({ onKeyboardMode, ...props }: HarnessProps) {
const { keyboardMode } = useListboxKeyboard(props);
onKeyboardMode?.(keyboardMode);
return null;
}
function ScopedHarness({
name,
onKeyboardMode,
...props
}: HarnessProps & { name: string }) {
const { keyboardMode } = useListboxKeyboard(props);
onKeyboardMode?.(keyboardMode);
return (
<div data-scope={name} tabIndex={-1}>
{name}
</div>
);
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
function mount(props: HarnessProps) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root!.render(<Harness {...props} />);
});
}
function mountNode(node: React.ReactNode) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root!.render(node);
});
}
function press(key: string) {
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key, cancelable: true }),
);
});
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('useListboxKeyboard', () => {
it('moves the active index down and up within bounds', () => {
const onActiveIndexChange = vi.fn();
mount({
itemCount: 3,
activeIndex: 1,
onActiveIndexChange,
onConfirm: vi.fn(),
});
press('ArrowDown');
expect(onActiveIndexChange).toHaveBeenLastCalledWith(2);
press('ArrowUp');
expect(onActiveIndexChange).toHaveBeenLastCalledWith(0);
});
it('clamps at the edges', () => {
const onActiveIndexChange = vi.fn();
mount({
itemCount: 3,
activeIndex: 2,
onActiveIndexChange,
onConfirm: vi.fn(),
});
press('ArrowDown');
expect(onActiveIndexChange).toHaveBeenLastCalledWith(2);
});
it('jumps to first/last with Home/End', () => {
const onActiveIndexChange = vi.fn();
mount({
itemCount: 5,
activeIndex: 2,
onActiveIndexChange,
onConfirm: vi.fn(),
});
press('End');
expect(onActiveIndexChange).toHaveBeenLastCalledWith(4);
press('Home');
expect(onActiveIndexChange).toHaveBeenLastCalledWith(0);
});
it('yields Home/End to a focused text input for caret movement', () => {
const onActiveIndexChange = vi.fn();
const input = document.createElement('input');
input.type = 'text';
document.body.appendChild(input);
input.focus();
mount({
itemCount: 5,
activeIndex: 2,
onActiveIndexChange,
onConfirm: vi.fn(),
});
press('Home');
press('End');
// The input keeps Home/End for its caret; the list is not moved.
expect(onActiveIndexChange).not.toHaveBeenCalled();
// Arrows still navigate the list even from within the input (combobox nav).
press('ArrowDown');
expect(onActiveIndexChange).toHaveBeenLastCalledWith(3);
input.remove();
});
it('ignores modified arrow keys (e.g. macOS Cmd+↑/↓ text navigation)', () => {
const onActiveIndexChange = vi.fn();
mount({
itemCount: 5,
activeIndex: 2,
onActiveIndexChange,
onConfirm: vi.fn(),
});
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', metaKey: true }),
);
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', shiftKey: true }),
);
});
expect(onActiveIndexChange).not.toHaveBeenCalled();
});
it('confirms the active index on Enter', () => {
const onConfirm = vi.fn();
mount({
itemCount: 3,
activeIndex: 1,
onActiveIndexChange: vi.fn(),
onConfirm,
});
press('Enter');
expect(onConfirm).toHaveBeenCalledWith(1);
});
it('yields Enter to a focused button so it activates instead of confirming', () => {
const onConfirm = vi.fn();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
mount({
itemCount: 3,
activeIndex: 1,
onActiveIndexChange: vi.fn(),
onConfirm,
});
press('Enter');
expect(onConfirm).not.toHaveBeenCalled();
button.remove();
});
it('does not confirm on Enter when no row is highlighted (activeIndex -1)', () => {
const onActiveIndexChange = vi.fn();
const onConfirm = vi.fn();
mount({
itemCount: 3,
activeIndex: -1,
onActiveIndexChange,
onConfirm,
});
// Searchable dialogs open with no highlight: Enter/Space have nothing to
// act on.
press('Enter');
press(' ');
expect(onConfirm).not.toHaveBeenCalled();
// The first ArrowDown lands on the first row, not the second.
press('ArrowDown');
expect(onActiveIndexChange).toHaveBeenLastCalledWith(0);
});
it('ignores keys during IME composition, including the WebKit Enter commit', () => {
const onActiveIndexChange = vi.fn();
const onConfirm = vi.fn();
mount({
itemCount: 3,
activeIndex: 1,
onActiveIndexChange,
onConfirm,
});
// Mid-composition keys (Chrome/Firefox report isComposing) do nothing.
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowDown',
isComposing: true,
cancelable: true,
}),
);
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
isComposing: true,
cancelable: true,
}),
);
});
expect(onActiveIndexChange).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
// WebKit fires compositionend BEFORE the committing Enter's keydown, so it
// arrives with isComposing false — only keyCode 229 marks it as an IME key.
act(() => {
const imeEnter = new KeyboardEvent('keydown', {
key: 'Enter',
cancelable: true,
});
Object.defineProperty(imeEnter, 'keyCode', { value: 229 });
window.dispatchEvent(imeEnter);
});
expect(onConfirm).not.toHaveBeenCalled();
// A genuine Enter afterwards still confirms.
press('Enter');
expect(onConfirm).toHaveBeenCalledWith(1);
});
it('confirms the active row on Space, mirroring Enter', () => {
const onConfirm = vi.fn();
mount({
itemCount: 3,
activeIndex: 1,
onActiveIndexChange: vi.fn(),
onConfirm,
});
press(' ');
expect(onConfirm).toHaveBeenCalledWith(1);
});
it('yields Space to a focused text input, where it types a space', () => {
const onConfirm = vi.fn();
const input = document.createElement('input');
input.type = 'text';
document.body.appendChild(input);
input.focus();
mount({
itemCount: 3,
activeIndex: 1,
onActiveIndexChange: vi.fn(),
onConfirm,
});
press(' ');
expect(onConfirm).not.toHaveBeenCalled();
input.remove();
});
it('confirms from a focused search combobox once a row is highlighted', () => {
const onConfirm = vi.fn();
const input = document.createElement('input');
input.type = 'text';
input.setAttribute('role', 'combobox');
document.body.appendChild(input);
input.focus();
mount({
itemCount: 3,
activeIndex: 1,
onActiveIndexChange: vi.fn(),
onConfirm,
});
// Focus never leaves the filter input in searchable dialogs, so Enter
// from it must confirm the visibly highlighted row.
press('Enter');
expect(onConfirm).toHaveBeenCalledWith(1);
input.remove();
});
it('does nothing when disabled or empty', () => {
const onActiveIndexChange = vi.fn();
const onConfirm = vi.fn();
mount({
itemCount: 0,
activeIndex: 0,
onActiveIndexChange,
onConfirm,
enabled: true,
});
press('ArrowDown');
press('Enter');
expect(onActiveIndexChange).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
it('does nothing when disabled', () => {
const onActiveIndexChange = vi.fn();
const onConfirm = vi.fn();
mount({
itemCount: 3,
activeIndex: 1,
onActiveIndexChange,
onConfirm,
enabled: false,
});
press('ArrowDown');
press('Enter');
expect(onActiveIndexChange).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
it('enters keyboard mode on arrow nav and exits on real mouse movement', () => {
let mode = false;
mount({
itemCount: 3,
activeIndex: 0,
onActiveIndexChange: vi.fn(),
onConfirm: vi.fn(),
onKeyboardMode: (value) => {
mode = value;
},
});
expect(mode).toBe(false);
press('ArrowDown');
expect(mode).toBe(true);
act(() => {
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
});
expect(mode).toBe(false);
});
it('only the active scope handles keys when two scoped hooks are mounted', () => {
const onBottomMove = vi.fn();
const onTopMove = vi.fn();
mountNode(
<I18nProvider language="en">
<ThemeProvider value="dark">
<DialogShell title="Bottom" onClose={vi.fn()}>
<ScopedHarness
name="bottom"
itemCount={3}
activeIndex={0}
onActiveIndexChange={onBottomMove}
onConfirm={vi.fn()}
/>
</DialogShell>
<DialogShell title="Top" onClose={vi.fn()}>
<ScopedHarness
name="top"
itemCount={3}
activeIndex={0}
onActiveIndexChange={onTopMove}
onConfirm={vi.fn()}
/>
</DialogShell>
</ThemeProvider>
</I18nProvider>,
);
// The top shell auto-focuses its content; only it should react.
press('ArrowDown');
expect(onTopMove).toHaveBeenCalledWith(1);
expect(onBottomMove).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,225 @@
import { useContext, useEffect, useRef, useState } from 'react';
import {
DialogShellIdContext,
isTopDialogShellId,
} from '../components/dialogs/DialogShell';
export interface ListboxKeyboardOptions {
/** Number of selectable items in the list. */
itemCount: number;
/** Currently highlighted index, or -1 when no row is highlighted. */
activeIndex: number;
/** Called with the next index when the user moves the highlight. */
onActiveIndexChange: (index: number) => void;
/** Called with an index when the user confirms it (Enter). */
onConfirm: (index: number) => void;
/** Disable the listener without unmounting the host component. */
enabled?: boolean;
}
export interface ListboxKeyboardResult {
/**
* True while the user is navigating by keyboard. Dialogs use this to suppress
* the CSS `:hover` highlight so a cursor that happens to rest over a row
* e.g. when the dialog opens under the pointer does not fight the keyboard
* highlight. It flips back to false on a real `mousemove` (which never fires
* from a dialog merely appearing under a stationary cursor).
*/
keyboardMode: boolean;
}
function clamp(index: number, itemCount: number): number {
if (itemCount <= 0) return 0;
if (index < 0) return 0;
if (index > itemCount - 1) return itemCount - 1;
return index;
}
const BUTTON_INPUT_TYPES = new Set(['button', 'submit', 'reset']);
const NON_TEXT_INPUT_TYPES = new Set([
'checkbox',
'radio',
'button',
'submit',
'reset',
'range',
'color',
'file',
]);
/**
* True when the focused element natively acts on Enter (a dialog button, link,
* textarea, etc.). In that case list confirmation must yield so, e.g., Enter on
* a focused "Delete" button triggers the button rather than toggling a row.
* Text inputs are intentionally NOT listed here: in the searchable dialogs
* focus never leaves the filter input, so Enter from it must still confirm
* but only a row the user visibly highlighted first (see the `active < 0`
* guard in the Enter case).
*/
function focusOwnsEnter(): boolean {
const el = typeof document !== 'undefined' ? document.activeElement : null;
if (!el) return false;
const tag = el.tagName;
if (tag === 'BUTTON' || tag === 'A' || tag === 'TEXTAREA') return true;
if (tag === 'INPUT') {
return BUTTON_INPUT_TYPES.has((el as HTMLInputElement).type);
}
const role = el.getAttribute('role');
return role === 'button' || role === 'link' || role === 'menuitem';
}
/**
* True when focus is in an editable text field, where Home/End must keep their
* native caret behaviour (jump to start/end of the text) instead of being
* hijacked to move the list highlight.
*/
function focusOwnsHomeEnd(): boolean {
const el = typeof document !== 'undefined' ? document.activeElement : null;
if (!el) return false;
const tag = el.tagName;
if (tag === 'TEXTAREA') return true;
if (tag === 'INPUT') {
return !NON_TEXT_INPUT_TYPES.has((el as HTMLInputElement).type);
}
return (el as HTMLElement).isContentEditable === true;
}
/**
* Keyboard navigation for listbox-style dialogs (model/theme/approval/resume/).
*
* Selection is driven by `activeIndex` state rather than DOM focus, so it works
* whether focus sits on the dialog panel/listbox or on a search input. The
* visual highlight + `scrollIntoView` already implemented by each dialog
* reflects the active index; this hook only moves that index and confirms it.
*
* Enter confirms the active row, unless focus is on a control that owns Enter
* (see {@link focusOwnsEnter}) so, e.g., Enter on a focused "Delete" button
* activates the button or no row is highlighted (`activeIndex < 0`).
* Searchable dialogs rely on the latter: they open with no highlight and reset
* to none whenever the filter text changes, so a reflexive Enter in the search
* box never confirms a row the user didn't visibly pick first; the first
* ArrowDown lands on the first row. Space mirrors Enter (per the ARIA listbox
* pattern) but additionally yields to text fields, where it types a space.
* Escape is intentionally NOT handled here {@link DialogShell} owns dialog
* dismissal.
*/
export function useListboxKeyboard({
itemCount,
activeIndex,
onActiveIndexChange,
onConfirm,
enabled = true,
}: ListboxKeyboardOptions): ListboxKeyboardResult {
const dialogShellId = useContext(DialogShellIdContext);
// Keep latest values in a ref so the listener is bound once, not per keystroke.
const stateRef = useRef({
itemCount,
activeIndex,
onActiveIndexChange,
onConfirm,
});
stateRef.current = { itemCount, activeIndex, onActiveIndexChange, onConfirm };
const [keyboardMode, setKeyboardMode] = useState(false);
useEffect(() => {
if (!enabled) return;
const enterKeyboardMode = () => setKeyboardMode(true);
const exitKeyboardMode = () => setKeyboardMode(false);
const handleKeyDown = (event: KeyboardEvent) => {
// Only the active/topmost dialog should react. In stacked dialogs, a
// background listbox must ignore Arrow/Enter/Space even though it also
// has a global listener. The shell id comes from DialogShell context, so
// focus may sit on either the search input or the list itself.
if (!isTopDialogShellId(dialogShellId)) return;
// keyCode 229 is the cross-browser "this key belongs to the IME" marker.
// WebKit fires `compositionend` BEFORE the committing Enter's keydown,
// so that keydown arrives with isComposing === false — only the legacy
// keyCode identifies it as an IME commit rather than a real Enter.
if (event.defaultPrevented || event.isComposing || event.keyCode === 229)
return;
// Only plain keypresses drive list navigation. Modified combos are OS/text
// shortcuts (e.g. Cmd+↑/↓ = text start/end on macOS, Shift+↑/↓ = extend
// selection) and must reach the focused input untouched.
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return;
}
const { itemCount: count, activeIndex: active } = stateRef.current;
if (count <= 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
enterKeyboardMode();
stateRef.current.onActiveIndexChange(clamp(active + 1, count));
break;
case 'ArrowUp':
event.preventDefault();
enterKeyboardMode();
stateRef.current.onActiveIndexChange(clamp(active - 1, count));
break;
case 'Home':
// Let an editable field keep Home for caret-to-start.
if (focusOwnsHomeEnd()) return;
event.preventDefault();
enterKeyboardMode();
stateRef.current.onActiveIndexChange(0);
break;
case 'End':
if (focusOwnsHomeEnd()) return;
event.preventDefault();
enterKeyboardMode();
stateRef.current.onActiveIndexChange(count - 1);
break;
case 'Enter': {
// Let a focused button/link/etc. handle its own Enter activation.
if (focusOwnsEnter()) return;
// No highlighted row → nothing to confirm. Searchable dialogs open
// with activeIndex -1 and reset to -1 on filter edits, so Enter in
// the search box only ever acts on a row the user visibly chose.
if (active < 0) return;
event.preventDefault();
stateRef.current.onConfirm(clamp(active, count));
break;
}
case ' ': {
// The ARIA listbox pattern selects on Space as well as Enter (the
// option rows used to be native <button>s, which gave this for
// free). Space types a space in a text field and activates a
// focused button natively, so only treat it as "select" when the
// list truly owns the key. preventDefault also stops the browser's
// default page-scroll when focus sits on the scrollable list.
if (focusOwnsEnter() || focusOwnsHomeEnd()) return;
if (active < 0) return;
event.preventDefault();
stateRef.current.onConfirm(clamp(active, count));
break;
}
default:
break;
}
};
// `window` bubble phase — the last stop in the propagation chain. This
// ordering is a contract with DialogShell: its `document`-level listener
// runs first and stops propagation on Escape/Tab, so those keys never
// arrive here (and the defaultPrevented check above covers anything a
// dialog control consumed). We additionally scope handling to the active
// dialog via DialogShell context so stacked list dialogs can't drive the
// background one. Don't move this to `document` or capture.
window.addEventListener('keydown', handleKeyDown);
// `mousemove` (not `mouseenter`) marks the switch back to pointer control:
// it only fires on genuine cursor movement, so a dialog opening under a
// stationary pointer never yanks control away from the keyboard.
window.addEventListener('mousemove', exitKeyboardMode);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousemove', exitKeyboardMode);
};
}, [dialogShellId, enabled]);
return { keyboardMode };
}