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
104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|