qwen-code/packages/web-shell/client/components/dialogs/ToolsDialog.tsx
carffuca 5c9e73f371
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
2026-07-02 12:19:17 +00:00

177 lines
5.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { dp } from './dialogStyles';
import {
useTools,
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({
autoLoad: true,
});
const [selectedIdx, setSelectedIdx] = useState(0);
const [message, setMessage] = useState<string | null>(null);
const [expandedTools, setExpandedTools] = useState<ReadonlySet<string>>(
() => new Set(),
);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (error) setMessage(error.message);
else if (status?.errors?.[0]?.error) setMessage(status.errors[0].error);
else if (status) setMessage(null);
}, [status, error]);
const toggleDetails = useCallback((tool: DaemonWorkspaceToolStatus) => {
setExpandedTools((current) => {
return current.has(tool.name) ? new Set() : new Set([tool.name]);
});
}, []);
useEffect(() => {
if (selectedIdx >= tools.length && tools.length > 0) {
setSelectedIdx(tools.length - 1);
}
}, [selectedIdx, tools.length]);
useEffect(() => {
const el = listRef.current?.children[selectedIdx] as
| HTMLElement
| undefined;
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;
return t('tools.summary', { enabled, total: tools.length });
}, [status, tools, t]);
return (
<div className={dp('picker', 'picker-in-shell')}>
{summary && (
<div className={dp('picker-search')}>
<span className={dp('picker-search-hint')}>{summary}</span>
</div>
)}
{(message || loading) && (
<div className={dp('picker-search')}>
<span className={dp('picker-search-hint')}>
{message || t('tools.loading')}
</span>
</div>
)}
<div className={dp('picker-sep')} />
<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('picker-empty')}>{t('tools.empty')}</div>
)}
{tools.map((tool, i) => {
const expanded = expandedTools.has(tool.name);
const desc = tool.description ?? '';
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(
'picker-item',
'picker-session-item',
'tools-picker-item',
i === selectedIdx ? 'selected' : undefined,
expanded ? 'tools-picker-item-expanded' : undefined,
)}
onClick={() => {
setSelectedIdx(i);
if (tool.description) toggleDetails(tool);
}}
onMouseMove={() => setSelectedIdx(i)}
>
<div className={dp('picker-item-row')}>
<span className={dp('tools-item-icon')} aria-hidden="true" />
<span className={dp('picker-item-title')}>
{toolLabel(tool)}
</span>
<span
className={dp(
'tools-status-badge',
tool.enabled
? 'tools-status-badge-enabled'
: 'tools-status-badge-disabled',
)}
>
{tool.enabled
? t('tools.status.enabled')
: t('tools.status.disabled')}
</span>
{desc ? (
<svg
className={dp(
'tools-item-chevron',
expanded ? 'tools-item-chevron-expanded' : undefined,
)}
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M6 4.5 9.5 8 6 11.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
</div>
{expanded && desc && (
<div className={dp('tools-desc-expanded')}>
<div className={dp('tools-desc-body')}>{desc}</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}