mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 22:55:16 +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
110 lines
3.4 KiB
TypeScript
110 lines
3.4 KiB
TypeScript
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';
|
|
|
|
interface ApprovalModeDialogProps {
|
|
currentMode: string;
|
|
onSelect: (modeId: string) => void;
|
|
}
|
|
|
|
interface ModeItem {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
export function ApprovalModeDialog({
|
|
currentMode,
|
|
onSelect,
|
|
}: ApprovalModeDialogProps) {
|
|
const { t } = useI18n();
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const approvalModes: ModeItem[] = DAEMON_APPROVAL_MODES.map((id) => ({
|
|
id,
|
|
name: t(`mode.listLabel.${id}`),
|
|
description: t(`mode.desc.${id}`),
|
|
}));
|
|
|
|
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[activeIndex] as
|
|
| HTMLElement
|
|
| undefined;
|
|
el?.scrollIntoView({ block: 'nearest' });
|
|
}, [activeIndex]);
|
|
|
|
return (
|
|
<div
|
|
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, index) => {
|
|
const selected = index === activeIndex;
|
|
const isCurrent = mode.id === currentMode;
|
|
return (
|
|
<div
|
|
key={mode.id}
|
|
id={`mode-opt-${index}`}
|
|
role="option"
|
|
// 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} />
|
|
</span>
|
|
<span className={styles.modeText}>
|
|
<span className={styles.modeName}>{mode.name}</span>
|
|
<span className={styles.modeDesc}>{mode.description}</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|