qwen-code/packages/web-shell/client/components/dialogs/HelpDialog.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

273 lines
6.9 KiB
TypeScript

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';
interface HelpDialogProps {
commands: readonly CommandInfo[];
}
const TABS: Array<{ id: HelpTab; labelKey: string }> = [
{ id: 'general', labelKey: 'help.tab.general' },
{ id: 'commands', labelKey: 'help.tab.commands' },
{ id: 'custom-commands', labelKey: 'help.tab.custom' },
];
const BUILT_IN_COMMANDS = new Set([
'about',
'agents',
'approval-mode',
'arena',
'auth',
'branch',
'btw',
'bug',
'clear',
'compress',
'context',
'copy',
'release',
'diff',
'directory',
'docs',
'doctor',
'dream',
'editor',
'export',
'extensions',
'forget',
'goal',
'help',
'hooks',
'ide',
'init',
'insight',
'language',
'lsp',
'mcp',
'memory',
'model',
'new',
'permissions',
'plan',
'quit',
'recap',
'remember',
'rename',
'reset',
'restore',
'resume',
'rewind',
'settings',
'setup-github',
'skills',
'stats',
'status',
'statusline',
'summary',
'tasks',
'terminal-setup',
'theme',
'tools',
'trust',
'vim',
]);
const GENERAL_SHORTCUTS: Array<[string, string]> = [
['@', 'help.shortcut.addContext'],
['!', 'help.shortcut.shell'],
['/', 'help.shortcut.commandMenu'],
['Tab', 'help.shortcut.completion'],
['Esc', 'help.shortcut.cancel'],
['Ctrl+J', 'help.shortcut.newline'],
['Ctrl+L', 'help.shortcut.clear'],
['Ctrl+Y', 'help.shortcut.retry'],
['Shift+Tab', 'help.shortcut.approvals'],
['Alt+Left/Right', 'help.shortcut.altWords'],
['Up/Down', 'help.shortcut.history'],
];
function commandSignature(command: CommandInfo): string {
return [`/${command.name}`, command.argumentHint].filter(Boolean).join(' ');
}
function isCustomCommand(command: CommandInfo): boolean {
return !BUILT_IN_COMMANDS.has(command.name);
}
function commandMeta(
command: CommandInfo,
t: ReturnType<typeof useI18n>['t'],
): string {
return isCustomCommand(command)
? t('help.commandMeta.custom')
: t('help.commandMeta.builtIn');
}
function filterCommands(
commands: readonly CommandInfo[],
tab: HelpTab,
query: string,
): CommandInfo[] {
const normalized = query.trim().toLowerCase();
return commands
.filter((command) => command.name && command.description !== undefined)
.filter((command) => {
if (tab === 'commands') return !isCustomCommand(command);
if (tab === 'custom-commands') return isCustomCommand(command);
return true;
})
.filter((command) => {
if (!normalized) return true;
return (
command.name.toLowerCase().includes(normalized) ||
(command.description ?? '').toLowerCase().includes(normalized) ||
(command.argumentHint ?? '').toLowerCase().includes(normalized)
);
})
.sort((a, b) => a.name.localeCompare(b.name));
}
function GeneralHelp() {
const { t } = useI18n();
return (
<div className={styles.general}>
<div className={styles.shortcuts}>
{GENERAL_SHORTCUTS.map(([key, description]) => (
<div className={styles.shortcut} key={key}>
<span className={styles.shortcutDesc}>{t(description)}</span>
<span className={styles.shortcutKey}>{key}</span>
</div>
))}
</div>
</div>
);
}
function CommandsHelp({
commands,
tab,
query,
}: {
commands: readonly CommandInfo[];
tab: HelpTab;
query: string;
}) {
const { t } = useI18n();
const [expandedCommand, setExpandedCommand] = useState<string | null>(null);
const visibleCommands = useMemo(
() => filterCommands(commands, tab, query),
[commands, query, tab],
);
if (visibleCommands.length === 0) {
return (
<div className={styles.empty}>
{tab === 'custom-commands' ? t('help.emptyCustom') : t('help.empty')}
</div>
);
}
return (
<div className={styles.commandList}>
{visibleCommands.map((command) => {
const expanded = expandedCommand === command.name;
return (
<article
className={`${styles.commandCard} ${
expanded ? styles.commandCardExpanded : ''
}`}
key={command.name}
>
<button
type="button"
className={styles.commandRow}
onClick={() => setExpandedCommand(expanded ? null : command.name)}
aria-expanded={expanded}
>
<span className={styles.commandName}>
{commandSignature(command)}
</span>
<span className={styles.commandTag}>
{commandMeta(command, t)}
</span>
<svg
className={`${styles.chevron} ${
expanded ? styles.chevronExpanded : ''
}`}
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>
</button>
{expanded && (
<div className={styles.commandDetail}>
{command.description && (
<div className={styles.commandDescription}>
{command.description}
</div>
)}
{!!command.subcommands?.length && (
<div className={styles.commandSubcommands}>
{t('help.subcommands')}: {command.subcommands.join(', ')}
</div>
)}
</div>
)}
</article>
);
})}
</div>
);
}
export function HelpDialog({ commands }: HelpDialogProps) {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<HelpTab>('general');
const { filterValue: query, inputProps } = useFilterInput();
const showSearch = activeTab !== 'general';
return (
<div className={styles.dialog}>
<div className={styles.toolbar}>
<div className={styles.tabs}>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`${styles.tab} ${
tab.id === activeTab ? styles.tabActive : ''
}`}
onClick={() => setActiveTab(tab.id)}
>
{t(tab.labelKey)}
</button>
))}
</div>
{showSearch && (
<input
className={styles.search}
{...inputProps}
placeholder={t('help.search')}
/>
)}
</div>
{activeTab === 'general' ? (
<GeneralHelp />
) : (
<CommandsHelp commands={commands} tab={activeTab} query={query} />
)}
</div>
);
}