mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-25 17:04:42 +00:00
* 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
177 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|