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

337 lines
9.8 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { dp } from './dialogStyles';
import { useConnection, useSessions } 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 DeleteSessionDialogProps {
onDeleted: (sessionIds: string[]) => void;
onError: (error: unknown) => void;
onClose: () => void;
}
const LIST_ID = 'delete-session-list';
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
export function DeleteSessionDialog({
onDeleted,
onError,
onClose,
}: DeleteSessionDialogProps) {
const { t } = useI18n();
const connection = useConnection();
const {
sessions,
loading,
error: sessionsError,
deleteSession,
deleteSessions,
} = useSessions({ autoLoad: true });
const currentSessionId = connection.sessionId;
const [deleting, setDeleting] = useState(false);
// `selectedIdx` is the keyboard/hover cursor (roving highlight, -1 = none —
// see ResumeDialog for the rationale); `selectedIds` is the multi-select set
// marked for deletion (shown by the [x] checkbox).
const [selectedIdx, setSelectedIdx] = useState(-1);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const { filterValue: filterQuery, inputProps } = useFilterInput(() => {
setSelectedIdx(-1);
setSelectedIds(new Set());
});
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 = useMemo(
() =>
filterQuery
? sessions.filter((s) => {
const q = filterQuery.toLowerCase();
return (
(s.displayName || '').toLowerCase().includes(q) ||
s.sessionId.toLowerCase().includes(q)
);
})
: sessions,
[sessions, filterQuery],
);
const toggleSelection = useCallback(
(sessionId: string) => {
if (sessionId === currentSessionId) return;
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
return next;
});
},
[currentSessionId],
);
useEffect(() => {
if (selectedIds.size === 0) return;
const filteredSet = new Set(filtered.map((s) => s.sessionId));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => filteredSet.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [filtered, selectedIds.size]);
useEffect(() => {
if (selectedIdx >= filtered.length && filtered.length > 0) {
setSelectedIdx(filtered.length - 1);
}
}, [filtered.length, selectedIdx]);
useEffect(() => {
const el = listRef.current?.children[selectedIdx] as
| HTMLElement
| undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIdx]);
useEffect(() => {
inputRef.current?.focus();
}, []);
// Enter toggles the cursor row's checkbox; the actual (destructive) delete
// still requires pressing the danger button — mirroring the click behaviour.
const { keyboardMode } = useListboxKeyboard({
itemCount: filtered.length,
activeIndex: selectedIdx,
onActiveIndexChange: setSelectedIdx,
onConfirm: (index) => {
const session = filtered[index];
if (session) toggleSelection(session.sessionId);
},
});
const handleDelete = useCallback(() => {
if (deleting) return;
if (selectedIds.size > 0) {
const filteredSet = new Set(filtered.map((s) => s.sessionId));
const idsToDelete = Array.from(selectedIds).filter((id) =>
filteredSet.has(id),
);
if (idsToDelete.length === 0) return;
setDeleting(true);
deleteSessions(idsToDelete)
.then((res) => {
const succeeded = res.removed.length + res.notFound.length;
const failed = res.errors.length;
if (failed > 0 && succeeded > 0) {
onError(
new Error(
t('delete.partialFail', {
removed: succeeded,
failed,
detail: res.errors[0].error,
}),
),
);
onClose();
return;
}
if (failed > 0) {
setMessage(
t('delete.allFailed', {
count: failed,
reason: res.errors[0].error,
}),
);
setDeleting(false);
setSelectedIds(new Set());
return;
}
if (succeeded === 0) {
setMessage(t('delete.nonRemoved'));
setDeleting(false);
setSelectedIds(new Set());
return;
}
onDeleted([...res.removed, ...res.notFound]);
onClose();
})
.catch((error: unknown) => {
onError(error);
setDeleting(false);
});
return;
}
const session = filtered[selectedIdx];
if (!session) return;
if (session.sessionId === currentSessionId) {
setMessage(t('delete.cannotCurrent'));
return;
}
setDeleting(true);
deleteSession(session.sessionId)
.then((removed) => {
if (!removed) {
setMessage(t('delete.notFound'));
setDeleting(false);
return;
}
onDeleted([session.sessionId]);
onClose();
})
.catch((error: unknown) => {
onError(error);
setDeleting(false);
});
}, [
currentSessionId,
deleteSession,
deleteSessions,
deleting,
filtered,
onClose,
onDeleted,
onError,
selectedIdx,
selectedIds,
t,
]);
const hasSelection = selectedIds.size > 0;
const canDelete = !deleting && !loading && hasSelection;
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={
selectedIdx >= 0 && selectedIdx < filtered.length
? optionId(selectedIdx)
: undefined
}
{...inputProps}
placeholder=""
/>
<span className={dp('picker-search-hint')}>
{message ||
(deleting
? t('delete.deleting')
: loading
? t('common.loading')
: hasSelection
? t('delete.selected', { count: selectedIds.size })
: filterQuery
? t('delete.matches', { count: filtered.length })
: '')}
</span>
</div>
<div className={dp('picker-sep')} />
<div
id={LIST_ID}
role="listbox"
aria-multiselectable="true"
className={dp(
'picker-list',
keyboardMode ? 'picker-keyboard-only' : undefined,
)}
ref={listRef}
>
{loading && (
<div className={dp('picker-empty')}>{t('common.loading')}</div>
)}
{!loading && sessionsError && (
<div className={dp('picker-empty')}>{sessionsError.message}</div>
)}
{!loading && !sessionsError && filtered.length === 0 && (
<div className={dp('picker-empty')}>
{filterQuery
? t('delete.noMatch', { query: filterQuery })
: t('delete.none')}
</div>
)}
{!loading &&
filtered.map((s, i) => {
const isCurrent = s.sessionId === currentSessionId;
const isChecked = selectedIds.has(s.sessionId);
return (
<SessionRow
key={s.sessionId}
session={s}
optionId={optionId(i)}
active={i === selectedIdx}
ariaSelected={isChecked}
current={false}
disabled={isCurrent}
leading={
<span
className={dp(
'picker-item-checkbox',
isChecked ? 'picker-item-checkbox-checked' : undefined,
)}
>
{isChecked ? '[x] ' : '[ ] '}
</span>
}
trailing={
isCurrent ? (
<span className={dp('picker-item-badge')}>
{t('resume.current')}
</span>
) : undefined
}
onClick={() => {
setSelectedIdx(i);
if (!isCurrent) toggleSelection(s.sessionId);
}}
onActivate={() => setSelectedIdx(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={handleDelete}
disabled={!canDelete}
>
{deleting ? t('delete.deleting') : t('delete.action')}
</button>
</div>
</div>
);
}