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

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>
);
}