diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index 2c2b13288f7..d5650ee66e3 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -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(), 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('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({ diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 23d2fa45fc0..987dbb5f9ba 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -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({ ))} - {showKeyHints && ( -
- {QUICK_KEY_ITEMS.map((item) => ( - - ))} -
- )} +
+ {QUICK_KEY_ITEMS.map((item) => ( + + ))} +
); @@ -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} /> )} { ).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'); + }); }); diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index ba1e3e32f86..a757fe5007e 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -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) => { + // 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],