mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-31 02:06:21 +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
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type {
|
|
DaemonRewindSnapshotInfo,
|
|
DaemonTranscriptBlock,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import { useI18n } from '../../i18n';
|
|
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
|
import { dp } from './dialogStyles';
|
|
import styles from './RewindDialog.module.css';
|
|
|
|
const LIST_ID = 'rewind-snapshot-list';
|
|
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
|
|
|
|
interface RewindDialogProps {
|
|
blocks: readonly DaemonTranscriptBlock[];
|
|
loadSnapshots: () => Promise<{ snapshots: DaemonRewindSnapshotInfo[] }>;
|
|
rewind: (promptId: string) => Promise<void>;
|
|
onError: (error: unknown) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function promptTextForTurn(
|
|
blocks: readonly DaemonTranscriptBlock[],
|
|
turnIndex: number,
|
|
): string {
|
|
let userIndex = 0;
|
|
for (const block of blocks) {
|
|
if (block.kind !== 'user') continue;
|
|
if (userIndex === turnIndex) return block.text.trim();
|
|
userIndex += 1;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function formatSnapshotTime(timestamp: string): string {
|
|
const date = new Date(timestamp);
|
|
if (!Number.isFinite(date.getTime())) return timestamp;
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
export function RewindDialog({
|
|
blocks,
|
|
loadSnapshots,
|
|
rewind,
|
|
onError,
|
|
onClose,
|
|
}: RewindDialogProps) {
|
|
const { t } = useI18n();
|
|
const [snapshots, setSnapshots] = useState<DaemonRewindSnapshotInfo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [rewindingPromptId, setRewindingPromptId] = useState<string | null>(
|
|
null,
|
|
);
|
|
// `cursorIdx` is the roving keyboard/hover highlight; `selectedPromptId` is
|
|
// the confirmed target the danger button acts on. They are separate so moving
|
|
// the highlight with the arrow keys does not change what will be rewound until
|
|
// the user commits with Enter or a click.
|
|
const [cursorIdx, setCursorIdx] = useState(0);
|
|
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
|
// Inline failure text. The app-level onError toast deduplicates repeats, so
|
|
// a second identical failure would otherwise be invisible in this dialog.
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setLoading(true);
|
|
loadSnapshots()
|
|
.then((result) => {
|
|
if (alive) setSnapshots(result.snapshots);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (alive) onError(error);
|
|
})
|
|
.finally(() => {
|
|
if (alive) setLoading(false);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [loadSnapshots, onError]);
|
|
|
|
const items = useMemo(
|
|
() =>
|
|
snapshots
|
|
.map((snapshot) => ({
|
|
snapshot,
|
|
promptText: promptTextForTurn(blocks, snapshot.turnIndex),
|
|
}))
|
|
.sort((a, b) => a.snapshot.turnIndex - b.snapshot.turnIndex),
|
|
[blocks, snapshots],
|
|
);
|
|
|
|
// Keep the cursor in range as snapshots load / change.
|
|
useEffect(() => {
|
|
if (cursorIdx >= items.length && items.length > 0) {
|
|
setCursorIdx(items.length - 1);
|
|
}
|
|
}, [items.length, cursorIdx]);
|
|
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const isRewinding = rewindingPromptId !== null;
|
|
|
|
const handleRewind = (promptId: string | null) => {
|
|
if (!promptId || rewindingPromptId) return;
|
|
setRewindingPromptId(promptId);
|
|
setMessage(null);
|
|
rewind(promptId)
|
|
.then(() => {
|
|
onClose();
|
|
})
|
|
.catch((error: unknown) => {
|
|
onError(error);
|
|
setMessage(
|
|
t('rewind.failed', {
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
}),
|
|
);
|
|
setRewindingPromptId(null);
|
|
});
|
|
};
|
|
|
|
// Arrows move the cursor (highlight) only; Enter/click commits the cursor row
|
|
// as the confirmed target. The irreversible rewind stays behind the danger
|
|
// button, consistent with the other destructive dialogs (delete / release).
|
|
const commitRow = (index: number) => {
|
|
const item = items[index];
|
|
if (item) {
|
|
setCursorIdx(index);
|
|
setSelectedPromptId(item.snapshot.promptId);
|
|
}
|
|
};
|
|
const { keyboardMode } = useListboxKeyboard({
|
|
itemCount: items.length,
|
|
activeIndex: cursorIdx,
|
|
onActiveIndexChange: setCursorIdx,
|
|
onConfirm: commitRow,
|
|
enabled: !isRewinding,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const el = listRef.current?.children[cursorIdx] as HTMLElement | undefined;
|
|
el?.scrollIntoView({ block: 'nearest' });
|
|
}, [cursorIdx]);
|
|
|
|
// Snapshots load asynchronously: while loading, nothing in this dialog is
|
|
// focusable, so DialogShell parks focus on the dialog panel. Once the listbox
|
|
// mounts, pull focus into it — but only if focus is still parked on the panel
|
|
// — so screen readers announce the active option via aria-activedescendant
|
|
// instead of staying silent until the user tabs into the list.
|
|
useEffect(() => {
|
|
if (loading || items.length === 0) return;
|
|
const active = document.activeElement;
|
|
if (active?.getAttribute('role') === 'dialog') {
|
|
listRef.current?.focus();
|
|
}
|
|
}, [loading, items.length]);
|
|
|
|
if (loading) {
|
|
return <div className={dp('picker-empty')}>{t('rewind.loading')}</div>;
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return <div className={dp('picker-empty')}>{t('rewind.empty')}</div>;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.root}>
|
|
<div
|
|
className={`${styles.list} ${keyboardMode ? styles.keyboardOnly : ''}`}
|
|
ref={listRef}
|
|
role="listbox"
|
|
aria-label={t('rewind.title')}
|
|
tabIndex={0}
|
|
aria-activedescendant={
|
|
items.length > 0 ? optionId(cursorIdx) : undefined
|
|
}
|
|
>
|
|
{items.map(({ snapshot, promptText }, index) => {
|
|
const isCursor = index === cursorIdx;
|
|
const isSelected = selectedPromptId === snapshot.promptId;
|
|
const label =
|
|
promptText ||
|
|
t('rewind.promptFallback', {
|
|
id: snapshot.promptId.slice(-8),
|
|
});
|
|
return (
|
|
<div
|
|
key={snapshot.promptId}
|
|
id={optionId(index)}
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
aria-disabled={isRewinding || undefined}
|
|
className={`${styles.item} ${isCursor ? styles.itemCursor : ''} ${
|
|
isSelected ? styles.itemSelected : ''
|
|
} ${isRewinding ? styles.itemDisabled : ''}`}
|
|
onClick={() => {
|
|
if (!isRewinding) commitRow(index);
|
|
}}
|
|
onMouseMove={() => setCursorIdx(index)}
|
|
>
|
|
<div className={styles.prompt} title={label}>
|
|
<span className={styles.turn}>#{snapshot.turnIndex + 1}</span>{' '}
|
|
{label}
|
|
</div>
|
|
<div className={styles.time}>
|
|
{formatSnapshotTime(snapshot.timestamp)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className={styles.footer}>
|
|
{message && (
|
|
<span className={styles.footerMessage} role="alert">
|
|
{message}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={dp('dialog-inline-button')}
|
|
onClick={onClose}
|
|
disabled={rewindingPromptId !== null}
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${dp('dialog-danger-button')} ${styles.dangerButton}`}
|
|
onClick={() => handleRewind(selectedPromptId)}
|
|
disabled={!selectedPromptId || rewindingPromptId !== null}
|
|
>
|
|
{rewindingPromptId ? t('rewind.rewinding') : t('rewind.confirm')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|