fix(web-shell): show and wire composer quick keys on mobile textarea (#11454)

The mobile composer's "more actions" panel hid its keyboard-hint column
entirely on the touch textarea backend, leaving a blank right side and
hint buttons that could never work (no CodeMirror to dispatch into).
Always render the hint grid and apply each key's desktop effect directly
to the textarea: arrows browse prompt history (now touch-capable in
useComposerCore) or move the caret, Esc exits shell mode or cancels the
running turn. Editing after a history walk re-arms draft persistence.

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-09 08:39:49 +00:00
1 parent 3a262e0914
commit 20402a65aa
4 files changed
+288 -40

No files matched your search

@@ -151,6 +151,10 @@ const composerCoreState = vi.hoisted(() => ({
clearImageDragState: vi.fn(),
addTags: vi.fn(),
imageDragActive: false,
navigatePrevHistory: vi.fn(),
navigateNextHistory: vi.fn(),
shellMode: false,
setShellMode: vi.fn(),
onFileUploadRequest: undefined as
| ((targetDir: string, restoreQuery?: () => void) => void)
| undefined,
@@ -268,8 +272,8 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
clear: vi.fn(),
retryLast: vi.fn(),
replaceEditorText: vi.fn(),
shellMode: false,
setShellMode: vi.fn(),
shellMode: composerCoreState.shellMode,
setShellMode: composerCoreState.setShellMode,
toggleShellMode: vi.fn(),
currentMode: 'default',
sessionName: undefined,
@@ -287,8 +291,8 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
handleSearchInput: vi.fn(),
handleSearchCompositionEnd: vi.fn(),
},
navigatePrevHistory: vi.fn(),
navigateNextHistory: vi.fn(),
navigatePrevHistory: composerCoreState.navigatePrevHistory,
navigateNextHistory: composerCoreState.navigateNextHistory,
showShortcutHints: false,
followupState: { isVisible: false, suggestion: '' },
disabled: false,
@@ -346,6 +350,10 @@ afterEach(() => {
composerCoreState.ingestFiles.mockReset();
composerCoreState.clearImageDragState.mockReset();
composerCoreState.addTags.mockReset();
composerCoreState.navigatePrevHistory.mockReset();
composerCoreState.navigateNextHistory.mockReset();
composerCoreState.setShellMode.mockReset();
composerCoreState.shellMode = false;
composerCoreState.workspaceActionsRef.current = undefined;
composerCoreState.imageDragActive = false;
composerCoreState.onFileUploadRequest = undefined;
@@ -383,6 +391,7 @@ interface ChatEditorRenderProps {
modeControlsDisabled?: boolean;
onTogglePlan?: () => void;
isRunning?: boolean;
onCancel?: () => void;
currentModel?: string;
availableModels?: Array<{ id: string; label?: string }>;
sessionWorkflowEnabled?: boolean;
@@ -2267,7 +2276,7 @@ describe('ChatEditor mobile composer quick actions', () => {
textareaRef: createRef<HTMLTextAreaElement>(),
value: '',
onChange: vi.fn(),
onPaste: vi.fn(),
onBlur: vi.fn(),
placeholder: '',
};
}
@@ -2296,7 +2305,7 @@ describe('ChatEditor mobile composer quick actions', () => {
});
});
it('hides the keyboard shortcut hints grid on the mobile composer', () => {
it('shows the keyboard shortcut hints grid on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const mobileContainer = renderChatEditor({});
@@ -2305,7 +2314,7 @@ describe('ChatEditor mobile composer quick actions', () => {
Array.from(mobileContainer.querySelectorAll('button')).some(
(button) => button.textContent === 'Tab',
),
).toBe(false);
).toBe(true);
composerCoreState.mobileComposer = null;
const desktopContainer = renderChatEditor({});
@@ -2318,6 +2327,98 @@ describe('ChatEditor mobile composer quick actions', () => {
});
});
it('walks prompt history from the hint arrows on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const container = renderChatEditor({});
openQuickActions(container);
const pressHint = (label: string) => {
const button = Array.from(container.querySelectorAll('button')).find(
(candidate) => candidate.textContent === label,
);
expect(button).not.toBeUndefined();
act(() => button!.click());
};
pressHint('↑');
expect(composerCoreState.navigatePrevHistory).toHaveBeenCalledTimes(1);
pressHint('↓');
expect(composerCoreState.navigateNextHistory).toHaveBeenCalledTimes(1);
// Tab has no completion target on the textarea backend.
pressHint('Tab');
expect(composerCoreState.navigatePrevHistory).toHaveBeenCalledTimes(1);
expect(composerCoreState.navigateNextHistory).toHaveBeenCalledTimes(1);
});
});
it('cancels the running turn from the Esc hint on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const onCancel = vi.fn();
const container = renderChatEditor({ isRunning: true, onCancel });
openQuickActions(container);
const escButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Esc',
);
expect(escButton).not.toBeUndefined();
act(() => escButton!.click());
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
it('moves the textarea caret from the cursor hints on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const container = renderChatEditor({});
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
expect(textarea).not.toBeNull();
openQuickActions(container);
// Setting the value after the panel opens keeps React's controlled
// state from resetting it on the panel re-render.
textarea!.value = 'abcd';
textarea!.setSelectionRange(1, 3);
const pressHint = (label: string) => {
const button = Array.from(container.querySelectorAll('button')).find(
(candidate) => candidate.textContent === label,
);
expect(button).not.toBeUndefined();
act(() => button!.click());
};
// An existing selection collapses to its far edge first.
pressHint('→');
expect(textarea!.selectionStart).toBe(3);
pressHint('→');
expect(textarea!.selectionStart).toBe(4);
pressHint('←');
expect(textarea!.selectionStart).toBe(3);
// Movement skips whole code points so an emoji is never split.
textarea!.value = 'a😀b';
textarea!.setSelectionRange(1, 1);
pressHint('→');
expect(textarea!.selectionStart).toBe(3);
pressHint('←');
expect(textarea!.selectionStart).toBe(1);
});
});
it('exits shell mode from the Esc hint on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
composerCoreState.shellMode = true;
const container = renderChatEditor({ isRunning: true });
openQuickActions(container);
const escButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Esc',
);
expect(escButton).not.toBeUndefined();
act(() => escButton!.click());
expect(composerCoreState.setShellMode).toHaveBeenCalledWith(false);
});
});
it('hides other toolbar actions while mobile voice capture is active', () => {
withTouchDevice(() => {
const container = renderChatEditor({
@@ -1425,18 +1425,51 @@ function SlashCommandPanel({
);
}
// The textarea backend cannot receive the CodeMirror keymap, so the arrow
// hint buttons move the caret directly. An existing selection collapses to
// its leading edge first, and movement steps whole code points so a caret
// never lands between an emoji's surrogate halves.
function moveTextareaCaret(
textarea: HTMLTextAreaElement | null,
forward: boolean,
) {
if (!textarea) return;
const { selectionStart, selectionEnd, value } = textarea;
const length = value.length;
if (selectionEnd !== selectionStart) {
textarea.setSelectionRange(
forward ? selectionEnd : selectionStart,
forward ? selectionEnd : selectionStart,
);
return;
}
let caret = selectionStart;
if (forward) {
if (caret >= length) return;
const next = value.codePointAt(caret) ?? 0;
caret += next > 0xffff ? 2 : 1;
} else {
if (caret <= 0) return;
const prev = value.charCodeAt(caret - 1);
const beforePrev = caret > 1 ? value.charCodeAt(caret - 2) : 0;
const overLowSurrogate =
prev >= 0xdc00 &&
prev <= 0xdfff &&
beforePrev >= 0xd800 &&
beforePrev <= 0xdbff;
caret -= overLowSurrogate ? 2 : 1;
}
textarea.setSelectionRange(caret, caret);
}
function QuickActionsPanel({
actions,
onRun,
onPressKey,
showKeyHints = true,
}: {
actions: readonly QuickActionItem[];
onRun: (action: QuickActionItem) => void;
onPressKey: (item: QuickKeyItem) => void;
// The keyboard shortcut grid is pointless without a hardware keyboard, so
// the mobile textarea backend hides it.
showKeyHints?: boolean;
}) {
const { t } = useI18n();
@@ -1460,22 +1493,20 @@ function QuickActionsPanel({
</button>
))}
</div>
{showKeyHints && (
<div className={styles.quickKeysGrid}>
{QUICK_KEY_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={styles.quickKey}
title={t(item.descriptionKey)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onPressKey(item)}
>
<span className={styles.quickKeyLabel}>{item.label}</span>
</button>
))}
</div>
)}
<div className={styles.quickKeysGrid}>
{QUICK_KEY_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={styles.quickKey}
title={t(item.descriptionKey)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onPressKey(item)}
>
<span className={styles.quickKeyLabel}>{item.label}</span>
</button>
))}
</div>
</div>
</div>
);
@@ -2328,14 +2359,46 @@ export const ChatEditor = memo(
},
[onSelectModel, core],
);
const showCancelButton = isRunning && !core.hasContent;
const composerPreparing = isPreparing || core.pendingImageBatchCount > 0;
const dispatchComposerKey = useCallback(
(event: QuickKeyItem['event']) => {
if (core.mobileComposer) {
// No CodeMirror to dispatch into. History search is the one key
// action with a non-keyboard equivalent; the rest are hidden on
// the textarea backend.
if (event.ctrlKey && event.key === 'r') {
core.searchState.openHistorySearch();
// No CodeMirror to dispatch into: apply the desktop keymap effects
// directly to the textarea backend.
switch (event.key) {
case 'ArrowUp':
core.navigatePrevHistory();
return;
case 'ArrowDown':
core.navigateNextHistory();
return;
case 'ArrowLeft':
case 'ArrowRight':
moveTextareaCaret(
core.mobileComposer.textareaRef.current,
event.key === 'ArrowRight',
);
return;
case 'Escape':
// Mirrors the CodeMirror Escape binding: exit shell mode, then
// fall through to canceling an in-flight turn.
if (core.shellMode) {
core.setShellMode(false);
} else if (isRunning && !composerPreparing) {
onCancel?.();
}
return;
case 'Tab':
// Tab accepts completions, which the textarea backend does not
// have; nothing to apply.
return;
case 'r':
if (event.ctrlKey) {
core.searchState.openHistorySearch();
}
return;
}
return;
}
@@ -2350,7 +2413,7 @@ export const ChatEditor = memo(
}),
);
},
[core],
[core, composerPreparing, isRunning, onCancel],
);
const runQuickAction = useCallback(
(action: QuickActionItem) => {
@@ -2510,8 +2573,6 @@ export const ChatEditor = memo(
const showModeLabel = toolbarLabelVisibility.mode;
const showPlanLabel = toolbarLabelVisibility.plan;
const showModelLabel = toolbarLabelVisibility.model;
const showCancelButton = isRunning && !core.hasContent;
const composerPreparing = isPreparing || core.pendingImageBatchCount > 0;
const mobileVoiceActive = showQuickActions && voiceActive;
useEffect(() => {
@@ -3822,7 +3883,6 @@ export const ChatEditor = memo(
actions={quickActions}
onRun={runQuickAction}
onPressKey={pressQuickKey}
showKeyHints={!core.mobileComposer}
/>
)}
<Dialog
@@ -455,4 +455,49 @@ describe('useComposerCore mobile textarea backend', () => {
).toBe('mobile draft text');
vi.useRealTimers();
});
it('walks prompt history from navigatePrevHistory/navigateNextHistory', async () => {
mockTouchDevice();
await mount();
typeText('first message');
act(() => latest!.submitText());
typeText('second message');
act(() => latest!.submitText());
typeText('working draft');
expect(latest!.mobileComposer!.value).toBe('working draft');
act(() => latest!.navigatePrevHistory());
expect(latest!.mobileComposer!.value).toBe('second message');
act(() => latest!.navigatePrevHistory());
expect(latest!.mobileComposer!.value).toBe('first message');
act(() => latest!.navigateNextHistory());
expect(latest!.mobileComposer!.value).toBe('second message');
act(() => latest!.navigateNextHistory());
expect(latest!.mobileComposer!.value).toBe('working draft');
});
it('persists the draft again once the user edits after a history walk', async () => {
mockTouchDevice();
await mount({
sessionId: 'mobile-session',
atWorkspaceCwd: '/workspace/mobile',
});
typeText('first message');
act(() => latest!.submitText());
typeText('draft text');
act(() => latest!.navigatePrevHistory());
expect(latest!.mobileComposer!.value).toBe('first message');
typeText('edited after walk');
act(() => {
container!
.querySelector('textarea')!
.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
expect(
localStorage.getItem(
'qwen-web-shell-session-draft:' + encodeURIComponent('mobile-session'),
),
).toBe('edited after walk');
});
});
@@ -2519,7 +2519,24 @@ export function useComposerCore(
const navigatePrevHistory = useCallback(() => {
if (disabledRef.current) return;
const view = viewRef.current;
if (!view) return;
if (!view) {
// Touch textarea backend: browse the same prompt history with a
// plain-text restore (inline tag chips are not recreated).
if (!isTouchComposer) return;
const history = shellModeRef.current
? shellHistoryActionsRef.current
: historyActionsRef.current;
const current = mobileTextRef.current;
if (!history.isNavigating()) {
saveCurrentDraftRef.current();
}
const prev = history.navigateUp(current);
if (prev !== null) {
historyBrowseActiveRef.current = true;
restoreSelectedHistoryMatch(prev);
}
return;
}
if (completionStatus(view.state) === 'active') {
moveCompletionSelection(false)(view);
view.focus();
@@ -2545,12 +2562,28 @@ export function useComposerCore(
restoreHistoryEntry(view, prev);
}
view.focus();
}, [rememberPromptHistoryDraftTags, restoreHistoryEntry]);
}, [
isTouchComposer,
rememberPromptHistoryDraftTags,
restoreHistoryEntry,
restoreSelectedHistoryMatch,
]);
const navigateNextHistory = useCallback(() => {
if (disabledRef.current) return;
const view = viewRef.current;
if (!view) return;
if (!view) {
if (!isTouchComposer) return;
const history = shellModeRef.current
? shellHistoryActionsRef.current
: historyActionsRef.current;
const next = history.navigateDown();
if (next !== null) {
historyBrowseActiveRef.current = history.isNavigating();
restoreSelectedHistoryMatch(next);
}
return;
}
if (completionStatus(view.state) === 'active') {
moveCompletionSelection(true)(view);
view.focus();
@@ -2574,10 +2607,19 @@ export function useComposerCore(
}
}
view.focus();
}, [restoreHistoryEntry, restorePromptHistoryDraftTags]);
}, [
isTouchComposer,
restoreHistoryEntry,
restorePromptHistoryDraftTags,
restoreSelectedHistoryMatch,
]);
const handleMobileChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
// Mirror the CodeMirror updateListener: a genuine edit ends history
// browsing so the draft starts persisting again. Programmatic restores
// go through setMobileText, never this handler.
historyBrowseActiveRef.current = false;
setMobileText(event.target.value);
},
[setMobileText],