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

269 lines
8.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { dp } from './dialogStyles';
import {
useConnection,
useSessions,
type DaemonSessionSummary,
} from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
import { useFilterInput } from '../../hooks/useFilterInput';
import { SessionRow } from './SessionRow';
interface ReleaseSessionDialogProps {
onReleased: (sessionId: string) => void;
onError: (error: unknown) => void;
onClose: () => void;
}
const LIST_ID = 'release-session-list';
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
export function ReleaseSessionDialog({
onReleased,
onError,
onClose,
}: ReleaseSessionDialogProps) {
const { t } = useI18n();
const connection = useConnection();
const {
sessions,
loading,
error: sessionsError,
releaseSession,
} = useSessions({ autoLoad: true });
const currentSessionId = connection.sessionId;
const [deleting, setDeleting] = useState(false);
// -1 = no highlight; see ResumeDialog for the rationale.
const [cursorIdx, setCursorIdx] = useState(-1);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null,
);
const { filterValue: filterQuery, inputProps } = useFilterInput(() => {
setCursorIdx(-1);
setSelectedSessionId(null);
});
const [message, setMessage] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (sessionsError) setMessage(sessionsError.message);
}, [sessionsError]);
const filtered = filterQuery
? sessions.filter((s) => {
const q = filterQuery.toLowerCase();
return (
(s.displayName || '').toLowerCase().includes(q) ||
s.sessionId.toLowerCase().includes(q)
);
})
: sessions;
const confirmRow = (index: number) => {
const session = filtered[index];
if (!session) return;
const isCurrent = session.sessionId === currentSessionId;
const isReleasable =
(session.clientCount ?? 0) > 0 || session.hasActivePrompt === true;
if (isCurrent || !isReleasable) return;
setCursorIdx(index);
setSelectedSessionId(session.sessionId);
};
useEffect(() => {
if (cursorIdx >= filtered.length && filtered.length > 0) {
setCursorIdx(filtered.length - 1);
}
}, [filtered.length, cursorIdx]);
useEffect(() => {
const el = listRef.current?.children[cursorIdx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [cursorIdx]);
useEffect(() => {
inputRef.current?.focus();
}, []);
// Arrows move only the roving cursor. Enter/click confirms the target row,
// but the destructive release still stays behind the danger button.
const { keyboardMode } = useListboxKeyboard({
itemCount: filtered.length,
activeIndex: cursorIdx,
onActiveIndexChange: setCursorIdx,
onConfirm: confirmRow,
});
const handleRelease = useCallback(
(targetSession?: DaemonSessionSummary) => {
const session =
targetSession ??
filtered.find((s) => s.sessionId === selectedSessionId) ??
undefined;
if (!session || deleting) return;
const releasable =
(session.clientCount ?? 0) > 0 || session.hasActivePrompt === true;
if (!releasable) {
setMessage(t('release.inactive'));
return;
}
if (session.sessionId === currentSessionId) {
setMessage(t('release.cannotCurrent'));
return;
}
if (!releaseSession) return;
setDeleting(true);
releaseSession(session.sessionId)
.then(() => {
onReleased(session.sessionId);
onClose();
})
.catch((error: unknown) => {
onError(error);
setDeleting(false);
});
},
[
currentSessionId,
deleting,
filtered,
onClose,
onError,
onReleased,
releaseSession,
selectedSessionId,
t,
],
);
const selectedSession =
filtered.find((s) => s.sessionId === selectedSessionId) ?? undefined;
const selectedReleasable =
selectedSession &&
((selectedSession.clientCount ?? 0) > 0 ||
selectedSession.hasActivePrompt === true);
const canRelease =
!deleting &&
!loading &&
!!selectedSession &&
selectedSession.sessionId !== currentSessionId &&
!!selectedReleasable;
return (
<div className={dp('picker', 'picker-in-shell')}>
<div className={dp('picker-search')}>
<span className={dp('picker-search-label')}>
{t('resume.search')}:{' '}
</span>
<input
ref={inputRef}
className={dp('picker-search-input')}
aria-label={t('resume.search')}
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls={LIST_ID}
aria-activedescendant={
cursorIdx >= 0 && cursorIdx < filtered.length
? optionId(cursorIdx)
: undefined
}
{...inputProps}
placeholder=""
/>
<span className={dp('picker-search-hint')}>
{message ||
(deleting
? t('release.releasing')
: loading
? t('common.loading')
: filterQuery
? t('release.matches', { count: filtered.length })
: '')}
</span>
</div>
<div className={dp('picker-sep')} />
<div
id={LIST_ID}
role="listbox"
className={dp(
'picker-list',
keyboardMode ? 'picker-keyboard-only' : undefined,
)}
ref={listRef}
>
{loading && (
<div className={dp('picker-empty')}>{t('common.loading')}</div>
)}
{!loading && filtered.length === 0 && (
<div className={dp('picker-empty')}>
{filterQuery
? t('release.noMatch', { query: filterQuery })
: t('release.none')}
</div>
)}
{!loading &&
filtered.map((s, i) => {
const isCurrent = s.sessionId === currentSessionId;
const isReleasable =
(s.clientCount ?? 0) > 0 || s.hasActivePrompt === true;
const isDisabled = isCurrent || !isReleasable;
return (
<SessionRow
key={s.sessionId}
session={s}
optionId={optionId(i)}
active={i === cursorIdx}
confirmed={s.sessionId === selectedSessionId}
ariaSelected={s.sessionId === selectedSessionId}
// In release/delete dialogs, "current session" is just a
// disabled reason, not the confirmed target. Keep the stronger
// accent bar + ✓ for the actual confirmed release target only.
current={false}
disabled={isDisabled}
trailing={
isCurrent ? (
<span className={dp('picker-item-badge')}>
{t('resume.current')}
</span>
) : !isReleasable ? (
<span className={dp('picker-item-badge')}>
{t('release.inactiveBadge')}
</span>
) : undefined
}
onClick={() => confirmRow(i)}
onActivate={() => setCursorIdx(i)}
/>
);
})}
</div>
<div className={dp('picker-sep')} />
<div className={dp('dialog-footer-actions')}>
<button
type="button"
className={dp('dialog-inline-button')}
onClick={onClose}
disabled={deleting}
>
{t('common.cancel')}
</button>
<button
type="button"
className={dp('dialog-danger-button')}
onClick={() => handleRelease()}
disabled={!canRelease}
>
{deleting ? t('release.releasing') : t('release.action')}
</button>
</div>
</div>
);
}