mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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],
|
||||
|
||||
Reference in new issue
Block a user