mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 13:51:13 +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
337 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|