fix(web-shell): preserve wrapped drafts during arrow navigation (#14909)

* fix(web-shell): preserve wrapped drafts during arrow navigation

* fix(web-shell): handle bidi composer history boundaries

Compare caret and document edges by visual row so RTL and mixed-direction
drafts can reach history without breaking soft-wrap caret movement.

Cover genuine edits after history recall in both arrow directions and make
the selected-history regression exercise the single-line paging branch.

* fix(web-shell): reset navigation on history selection

Start a fresh navigation sequence when selecting a search or panel result,
preserving browse suppression while dropping stale prompt draft tags.

Cover prior paging, selected-draft restoration, and visual row decisions
with focused unit and browser regressions.

* fix(web-shell): keep focus on recalled prompts

Consume ArrowDown while an explicit recall has no next history entry,
keeping ordinary footer navigation after returning to the draft.

Cover both prior paging states and the subsequent normal footer handoff.
This commit is contained in:
jianyx1 authored and GitHub committed 2026-11-06 18:23:56 +08:00
1 parent ff775787f5
commit 666d0cae7b
3 files changed
+443 -13

No files matched your search

@@ -0,0 +1,262 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import {
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
} from './utils/mockDaemon';
const EDITOR = '[data-web-shell-composer-editor] .cm-content';
const paragraph = Array.from(
{ length: 8 },
() =>
'Keep this unsent paragraph while moving the caret between visual rows.',
).join(' ');
const history = [`Older prompt: ${paragraph}`, `Newest prompt: ${paragraph}`];
async function openSession(page: Page, baseURL: string) {
await page.addInitScript((entries) => {
localStorage.setItem('qwen-web-shell-history', JSON.stringify(entries));
}, history);
const scenario = createWebShellDaemonScenario();
const daemon = await installMockDaemon(page, scenario, { baseURL });
await page.goto(`/session/${scenario.sessionId}`);
await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({ sessionId: scenario.sessionId }),
);
const editor = page.locator(EDITOR);
await expect(editor).toBeEditable();
return editor;
}
async function caretOffset(editor: Locator) {
return editor.evaluate((element) => {
const selection = window.getSelection();
if (!selection?.focusNode || !element.contains(selection.focusNode)) {
throw new Error('The caret must remain in the composer');
}
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(selection.focusNode, selection.focusOffset);
const lineIndex = Array.from(
element.querySelectorAll('.cm-line'),
).findIndex((line) => line.contains(selection.focusNode));
if (lineIndex < 0)
throw new Error('The caret must be inside a document line');
return range.toString().length + lineIndex;
});
}
async function expectText(editor: Locator, text: string) {
await expect
.poll(() => editor.locator('.cm-line').allTextContents())
.toEqual(text.split('\n'));
}
async function expectWrapped(editor: Locator) {
await expect
.poll(() =>
editor.evaluate((element) => {
const lineHeight = parseFloat(getComputedStyle(element).lineHeight);
return Array.from(element.querySelectorAll('.cm-line')).some(
(line) => line.getBoundingClientRect().height > lineHeight * 2,
);
}),
)
.toBe(true);
}
for (const [name, draft] of [
['single paragraph', paragraph],
['explicit multiline draft with wrapped lines', `${paragraph}\n${paragraph}`],
]) {
for (const key of ['ArrowUp', 'ArrowDown']) {
test(`${key} moves within a wrapped ${name} without recalling history @smoke`, async ({
page,
baseURL,
}) => {
await page.setViewportSize({ width: 1100, height: 900 });
const editor = await openSession(page, baseURL!);
await editor.fill(draft!);
await expectText(editor, draft!);
await expectWrapped(editor);
if (key === 'ArrowDown') await editor.press('ControlOrMeta+Home');
const before = await caretOffset(editor);
await editor.press(key);
await expectText(editor, draft!);
if (key === 'ArrowUp') {
expect(await caretOffset(editor)).toBeLessThan(before);
expect(await caretOffset(editor)).toBeGreaterThan(0);
} else {
expect(await caretOffset(editor)).toBeGreaterThan(before);
expect(await caretOffset(editor)).toBeLessThan(draft!.length);
}
});
}
}
test('snaps to the edge before history and restores a wrapped draft after browsing wrapped entries @smoke', async ({
page,
baseURL,
}) => {
const editor = await openSession(page, baseURL!);
await editor.fill(paragraph);
await expectWrapped(editor);
await editor.press('ControlOrMeta+Home');
await editor.press('ArrowRight');
expect(await caretOffset(editor)).toBe(1);
await editor.press('ArrowUp');
await expect(editor).toHaveText(paragraph);
expect(await caretOffset(editor)).toBe(0);
await editor.press('ArrowUp');
await expect(editor).toHaveText(history[1]!);
await editor.press('ArrowUp');
await expect(editor).toHaveText(history[0]!);
await editor.press('ControlOrMeta+Home');
await editor.press('ArrowRight');
expect(await caretOffset(editor)).toBe(1);
await editor.press('ArrowDown');
await expect(editor).toHaveText(history[1]!);
await editor.press('ArrowDown');
await expect(editor).toHaveText(paragraph);
await editor.press('ArrowLeft');
await editor.press('ArrowDown');
await expect(editor).toHaveText(paragraph);
expect(await caretOffset(editor)).toBe(paragraph.length);
});
test('a short single row still enters history immediately @smoke', async ({
page,
baseURL,
}) => {
const editor = await openSession(page, baseURL!);
await editor.fill('short draft');
await editor.press('ArrowUp');
await expect(editor).toHaveText(history[1]!);
await editor.press('ArrowDown');
await expect(editor).toHaveText('short draft');
});
for (const [name, draft, wrapped] of [
['short RTL', 'שלום עולם זה טקסט בעברית', false],
['mixed LTR/RTL', 'abc שלום', false],
['mixed RTL/LTR', 'שלום abc', false],
[
'wrapped RTL',
Array.from({ length: 12 }, () => 'שלום עולם זה טקסט בעברית').join(' '),
true,
],
] as const) {
test(`a ${name} draft reaches history and is restored @smoke`, async ({
page,
baseURL,
}) => {
const editor = await openSession(page, baseURL!);
await editor.fill(draft);
await expectText(editor, draft);
if (wrapped) {
await expectWrapped(editor);
await editor.press('ControlOrMeta+Home');
await editor.press('ArrowDown');
await expectText(editor, draft);
expect(await caretOffset(editor)).toBeGreaterThan(0);
expect(await caretOffset(editor)).toBeLessThan(draft.length);
await editor.press('ControlOrMeta+Home');
expect(await caretOffset(editor)).toBe(0);
}
await editor.press('ArrowUp');
await expectText(editor, history[1]!);
await editor.press('ArrowDown');
await expectText(editor, draft);
});
}
for (const key of ['ArrowUp', 'ArrowDown']) {
test(`${key} moves inside an edited recalled entry instead of paging history @smoke`, async ({
page,
baseURL,
}) => {
const editor = await openSession(page, baseURL!);
await editor.fill('unsent draft');
await editor.press('ArrowUp');
await expectText(editor, history[1]!);
await editor.pressSequentially('x');
const edited = `${history[1]}x`;
await expectText(editor, edited);
await expectWrapped(editor);
if (key === 'ArrowDown') await editor.press('ControlOrMeta+Home');
const before = await caretOffset(editor);
await editor.press(key);
await expectText(editor, edited);
const after = await caretOffset(editor);
expect(after).toBeGreaterThan(0);
expect(after).toBeLessThan(edited.length);
if (key === 'ArrowUp') expect(after).toBeLessThan(before);
else expect(after).toBeGreaterThan(before);
});
}
test('active selections preserve both drafts and recalled history @smoke', async ({
page,
baseURL,
}) => {
const editor = await openSession(page, baseURL!);
await editor.fill('selected draft');
await editor.press('ControlOrMeta+A');
await editor.press('ArrowUp');
await expect(editor).toHaveText('selected draft');
await editor.press('ArrowUp');
await expectText(editor, history[1]!);
await editor.press('ControlOrMeta+A');
await editor.press('ArrowUp');
await expectText(editor, history[1]!);
});
for (const [recall, paged] of [
['search', false],
['panel', false],
['search', true],
['panel', true],
] as const) {
test(`a ${recall} recall keeps wrapped text while arrows move inside it (paged: ${paged}) @smoke`, async ({
page,
baseURL,
}) => {
const editor = await openSession(page, baseURL!);
await editor.fill('unsent draft');
if (paged) {
await editor.press('ArrowUp');
await expectText(editor, history[1]!);
}
await editor.press('Control+r');
const search = page.locator('[data-web-shell-composer-history-search]');
await search.fill('Older prompt:');
if (recall === 'search') {
await search.press('Tab');
} else {
await page.getByRole('button', { name: /Older prompt:/ }).click();
}
await expectText(editor, history[0]!);
await expectWrapped(editor);
await editor.press('ControlOrMeta+Home');
await editor.press('ArrowDown');
const middle = await caretOffset(editor);
expect(middle).toBeGreaterThan(0);
expect(middle).toBeLessThan(history[0]!.length);
await expectText(editor, history[0]!);
await expect(editor).toBeFocused();
await editor.press('ArrowUp');
await expectText(editor, history[0]!);
expect(await caretOffset(editor)).toBeLessThan(middle);
await expect(editor).toBeFocused();
await editor.press('ControlOrMeta+Home');
await editor.press('ArrowUp');
await expectText(editor, history[1]!);
await editor.press('ArrowDown');
await expectText(editor, history[0]!);
});
}
@@ -342,6 +342,128 @@ describe('useComposerCore tooltip portal', () => {
});
describe('useComposerCore history and drafts', () => {
it.each([
['panel', 'ArrowUp', false],
['panel', 'ArrowDown', false],
['search', 'ArrowUp', false],
['search', 'ArrowDown', false],
['panel', 'ArrowUp', true],
['panel', 'ArrowDown', true],
['search', 'ArrowUp', true],
['search', 'ArrowDown', true],
] as const)(
'keeps caret control after a wrapped %s recall followed by %s (paged: %s)',
async (recall, key, paged) => {
const entry = 'line one line two line three';
localStorage.setItem(
getPromptHistoryStorageKey(),
JSON.stringify([entry, 'middle prompt', 'newest prompt']),
);
const onFocusFooter = vi.fn(() => true);
await mount({ onFocusFooter });
act(() => latest!.setText('unsent draft'));
if (paged) {
act(() => pressHistoryKey('ArrowUp'));
expect(latest!.getText()).toBe('newest prompt');
}
act(() => latest!.searchState.openHistorySearch());
if (recall === 'panel') {
act(() => latest!.searchState.restoreSearchMatch!(entry));
act(() => latest!.searchState.closeSearch(false));
} else {
act(() =>
latest!.searchState.handleSearchInput({
target: { value: 'line two' },
nativeEvent: { isComposing: false },
} as unknown as React.ChangeEvent<HTMLInputElement>),
);
act(() =>
latest!.searchState.handleSearchKeyDown({
key: 'Tab',
nativeEvent: { isComposing: false },
preventDefault: vi.fn(),
} as unknown as React.KeyboardEvent<HTMLInputElement>),
);
}
expect(latest!.getText()).toBe(entry);
const view = latest!.viewRef.current!;
vi.spyOn(view, 'coordsAtPos').mockImplementation((position) => {
const top =
position === 0 ? 0 : position === view.state.doc.length ? 44 : 22;
return { left: 0, right: 1, top, bottom: top + 22 };
});
act(() => view.dispatch({ selection: { anchor: 12 } }));
act(() => pressHistoryKey(key));
expect(latest!.getText()).toBe(entry);
expect(onFocusFooter).not.toHaveBeenCalled();
},
);
it.each([false, true])(
'keeps ArrowDown in a short panel recall until paging returns to its draft (paged: %s)',
async (paged) => {
const entry = 'short entry';
localStorage.setItem(
getPromptHistoryStorageKey(),
JSON.stringify([entry, 'newest prompt']),
);
const onFocusFooter = vi.fn(() => true);
await mount({ onFocusFooter });
act(() => latest!.setText('unsent draft'));
if (paged) {
act(() => pressHistoryKey('ArrowUp'));
expect(latest!.getText()).toBe('newest prompt');
}
act(() => latest!.searchState.restoreSearchMatch!(entry));
act(() => pressHistoryKey('ArrowDown'));
expect(latest!.getText()).toBe(entry);
expect(onFocusFooter).not.toHaveBeenCalled();
act(() => pressHistoryKey('ArrowUp'));
expect(latest!.getText()).toBe('newest prompt');
act(() => pressHistoryKey('ArrowDown'));
expect(latest!.getText()).toBe(entry);
act(() => pressHistoryKey('ArrowDown'));
expect(onFocusFooter).toHaveBeenCalledTimes(1);
},
);
it.each(['ArrowUp', 'ArrowDown'] as const)(
'keeps a selected history entry when pressing %s',
async (key) => {
const entry = 'recalled entry';
localStorage.setItem(
getPromptHistoryStorageKey(),
JSON.stringify(['older prompt', entry]),
);
await mount();
act(() => pressHistoryKey('ArrowUp'));
expect(latest!.getText()).toBe(entry);
const view = latest!.viewRef.current!;
act(() => view.dispatch({ selection: { anchor: 2, head: 8 } }));
act(() => pressHistoryKey(key));
expect(latest!.getText()).toBe(entry);
},
);
it.each(['ArrowUp', 'ArrowDown'] as const)(
'keeps hard-newline history text while %s moves inside it',
async (key) => {
const entry = 'line one\nline two\nline three';
localStorage.setItem(
getPromptHistoryStorageKey(),
JSON.stringify(['older prompt', entry]),
);
await mount();
act(() => latest!.setText('unsent draft'));
act(() => pressHistoryKey('ArrowUp'));
const view = latest!.viewRef.current!;
act(() => view.dispatch({ selection: { anchor: 12 } }));
act(() => pressHistoryKey(key));
expect(latest!.getText()).toBe(entry);
},
);
it('places the caret after a draft restored on initial mount', async () => {
localStorage.setItem(
getSessionDraftKey('restored-session'),
@@ -3227,6 +3349,24 @@ describe('useComposerCore tags', () => {
expect(latest!.composerTags).toEqual([draftTag]);
});
it('does not restore old draft tags after paging from a panel recall', async () => {
await mount();
act(() => {
latest!.setText('previous prompt');
latest!.submitText();
latest!.setText('draft prompt');
latest!.addTags([
{ id: 'file:draft.txt', value: 'draft.txt', serialized: '@draft.txt' },
]);
});
act(() => pressHistoryKey('ArrowUp'));
act(() => latest!.searchState.restoreSearchMatch!('selected prompt'));
act(() => pressHistoryKey('ArrowUp'));
act(() => pressHistoryKey('ArrowDown'));
expect(latest!.getText()).toBe('selected prompt');
expect(latest!.composerTags).toEqual([]);
});
it('restores tags when Tab or a search-result selection recalls history', async () => {
const serialized = '<context id="search-orders">orders</context>';
const prompt = `inspect ${serialized} now`;
@@ -1457,27 +1457,39 @@ function shallowEqualSlashMenu(
type MultilineHistoryBoundary = 'editor' | 'handled' | 'history';
// Arrow paging needs both the browse flag (cleared on edits) and an armed index.
function handleMultilineHistoryBoundary(
view: EditorView,
direction: 'up' | 'down',
isPagingHistory: boolean,
): MultilineHistoryBoundary {
const doc = view.state.doc;
if (doc.lines <= 1) return 'history';
const selection = view.state.selection.main;
if (!selection.empty) return 'editor';
if (isPagingHistory && doc.lines === 1) return 'history';
const head = selection.head;
const line = doc.lineAt(head);
const caret = view.coordsAtPos(head, selection.assoc < 0 ? -1 : 1);
// Bidi visual sides may lie inside the logical line; compare vertical rows.
const sharesRowWith = (edge: number) => {
if (!caret) return doc.lineAt(head).number === doc.lineAt(edge).number;
const bounds = view.coordsAtPos(edge);
return (
bounds !== null && caret.top < bounds.bottom && bounds.top < caret.bottom
);
};
const onFirstRow = sharesRowWith(0);
const onLastRow = sharesRowWith(doc.length);
if (doc.lines === 1 && onFirstRow && onLastRow) return 'history';
// Let CodeMirror handle normal multi-line cursor movement first. Once the
// cursor is on the edge line, one more arrow key snaps to the true edge;
// Let CodeMirror move between visual rows, including soft wraps. Once the
// cursor is on the edge row, one more arrow key snaps to the true edge;
// the next press can browse prompt history instead of feeling stuck.
if (direction === 'up') {
if (line.number > 1) return 'editor';
if (head > line.from) {
if (!onFirstRow) return 'editor';
if (head > 0) {
view.dispatch({
selection: { anchor: line.from },
selection: { anchor: 0 },
scrollIntoView: true,
});
return 'handled';
@@ -1485,10 +1497,10 @@ function handleMultilineHistoryBoundary(
return 'history';
}
if (line.number < doc.lines) return 'editor';
if (head < line.to) {
if (!onLastRow) return 'editor';
if (head < doc.length) {
view.dispatch({
selection: { anchor: line.to },
selection: { anchor: doc.length },
scrollIntoView: true,
});
return 'handled';
@@ -2677,6 +2689,11 @@ export function useComposerCore(
// flag and re-arms persistence (handleMobileChange or a programmatic
// write on touch, the CodeMirror update listener on desktop).
historyBrowseActiveRef.current = true;
(shellModeRef.current
? shellHistoryActionsRef.current
: historyActionsRef.current
).reset();
if (!shellModeRef.current) clearPromptHistoryDraftTags();
if (!view) {
if (isTouchComposer) {
// Plain-text restore: inline tag chips are not recreated on the
@@ -2687,7 +2704,12 @@ export function useComposerCore(
}
restoreHistoryEntry(view, text);
},
[isTouchComposer, restoreHistoryEntry, setMobileText],
[
clearPromptHistoryDraftTags,
isTouchComposer,
restoreHistoryEntry,
setMobileText,
],
);
const composerInputRef = useRef(composerInput);
composerInputRef.current = composerInput;
@@ -3809,7 +3831,11 @@ export function useComposerCore(
closeSlashMenu();
closeAtMenu();
}
const multilineBoundary = handleMultilineHistoryBoundary(view, 'up');
const multilineBoundary = handleMultilineHistoryBoundary(
view,
'up',
isBrowsingHistory && history.isNavigating(),
);
if (multilineBoundary === 'handled') return true;
if (multilineBoundary === 'editor') return false;
if (shellModeRef.current) {
@@ -3867,6 +3893,7 @@ export function useComposerCore(
const multilineBoundary = handleMultilineHistoryBoundary(
view,
'down',
isBrowsingHistory && history.isNavigating(),
);
if (multilineBoundary === 'handled') return true;
if (multilineBoundary === 'editor') return false;
@@ -3879,6 +3906,7 @@ export function useComposerCore(
}
const next = history.navigateDown();
if (next === null) {
if (isBrowsingHistory) return true;
return onFocusFooterRef.current?.() ?? false;
}
const returningToPromptDraft = !history.isNavigating();