mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +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
158 lines
5 KiB
TypeScript
158 lines
5 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import type {
|
|
DaemonRewindSnapshotInfo,
|
|
DaemonTranscriptBlock,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import { I18nProvider } from '../../i18n';
|
|
import { RewindDialog } from './RewindDialog';
|
|
|
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
|
|
if (!Element.prototype.scrollIntoView) {
|
|
Element.prototype.scrollIntoView = () => {};
|
|
}
|
|
|
|
const blocks = [
|
|
{ kind: 'user', text: 'first turn' },
|
|
{ kind: 'user', text: 'second turn' },
|
|
] as unknown as DaemonTranscriptBlock[];
|
|
|
|
const snapshots: DaemonRewindSnapshotInfo[] = [
|
|
{ promptId: 'p0', turnIndex: 0, timestamp: '2026-01-01T00:00:00.000Z' },
|
|
{ promptId: 'p1', turnIndex: 1, timestamp: '2026-01-01T00:01:00.000Z' },
|
|
] as unknown as DaemonRewindSnapshotInfo[];
|
|
|
|
let container: HTMLDivElement | null = null;
|
|
let root: Root | null = null;
|
|
|
|
async function mount(rewind: (id: string) => Promise<void>) {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root!.render(
|
|
<I18nProvider language="en">
|
|
<RewindDialog
|
|
blocks={blocks}
|
|
loadSnapshots={() => Promise.resolve({ snapshots })}
|
|
rewind={rewind}
|
|
onError={vi.fn()}
|
|
onClose={vi.fn()}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
// Flush the async loadSnapshots() effect.
|
|
await act(async () => {});
|
|
}
|
|
|
|
function press(key: string) {
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key, cancelable: true }),
|
|
);
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
});
|
|
|
|
describe('RewindDialog keyboard', () => {
|
|
function rewindButton(): HTMLButtonElement {
|
|
return Array.from(container!.querySelectorAll('button')).find((el) =>
|
|
/rewind/i.test(el.textContent || ''),
|
|
) as HTMLButtonElement;
|
|
}
|
|
|
|
it('does not confirm a target until Enter — the button stays disabled', async () => {
|
|
const rewind = vi.fn().mockResolvedValue(undefined);
|
|
await mount(rewind);
|
|
|
|
// Moving the cursor with arrows must not confirm anything yet.
|
|
press('ArrowDown');
|
|
press('ArrowUp');
|
|
expect(rewind).not.toHaveBeenCalled();
|
|
expect(rewindButton().disabled).toBe(true);
|
|
|
|
// Enter commits the cursor row; it still does not run the rewind itself.
|
|
press('Enter');
|
|
expect(rewind).not.toHaveBeenCalled();
|
|
expect(rewindButton().disabled).toBe(false);
|
|
});
|
|
|
|
it('the button rewinds the snapshot confirmed via keyboard', async () => {
|
|
const rewind = vi.fn().mockResolvedValue(undefined);
|
|
await mount(rewind);
|
|
|
|
// Move cursor to the 2nd snapshot and confirm it with Enter.
|
|
press('ArrowDown');
|
|
press('Enter');
|
|
act(() => {
|
|
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
expect(rewind).toHaveBeenCalledWith('p1');
|
|
});
|
|
|
|
it('disables keyboard navigation while a rewind is in flight', async () => {
|
|
// A rewind that never settles keeps isRewinding true.
|
|
const rewind = vi.fn().mockReturnValue(new Promise<void>(() => {}));
|
|
await mount(rewind);
|
|
|
|
const activeDescendant = () =>
|
|
container!
|
|
.querySelector('[role="listbox"]')!
|
|
.getAttribute('aria-activedescendant');
|
|
|
|
press('ArrowDown');
|
|
press('Enter');
|
|
expect(activeDescendant()).toBe('rewind-snapshot-list-opt-1');
|
|
act(() => {
|
|
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
expect(rewind).toHaveBeenCalledWith('p1');
|
|
|
|
// While rewinding, arrows must not move the highlight (enabled: false).
|
|
press('ArrowUp');
|
|
expect(activeDescendant()).toBe('rewind-snapshot-list-opt-1');
|
|
});
|
|
|
|
it('shows an inline error and re-enables the button when rewind fails', async () => {
|
|
const rewind = vi.fn().mockRejectedValue(new Error('boom'));
|
|
await mount(rewind);
|
|
|
|
press('ArrowDown');
|
|
press('Enter');
|
|
await act(async () => {
|
|
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
|
|
// The failure is visible in-dialog (toasts may be deduplicated upstream)
|
|
// and the user can retry.
|
|
expect(container!.textContent).toContain('boom');
|
|
expect(rewindButton().disabled).toBe(false);
|
|
});
|
|
|
|
it('pulls focus into the listbox once snapshots arrive, if parked on the panel', async () => {
|
|
// Simulate DialogShell's fallback: nothing focusable during loading, so
|
|
// focus sits on the dialog panel.
|
|
const panel = document.createElement('div');
|
|
panel.setAttribute('role', 'dialog');
|
|
panel.tabIndex = -1;
|
|
document.body.appendChild(panel);
|
|
panel.focus();
|
|
expect(document.activeElement).toBe(panel);
|
|
|
|
await mount(vi.fn().mockResolvedValue(undefined));
|
|
|
|
const listbox = container!.querySelector<HTMLElement>('[role="listbox"]');
|
|
expect(document.activeElement).toBe(listbox);
|
|
panel.remove();
|
|
});
|
|
});
|