fix(desktop): split steer and queue keyboard shortcuts (#69797)

Keep Enter as the Cursor-style live-turn steering gesture and assign
Ctrl/Cmd+Enter to queue the current draft. Align keybind settings,
tooltips, translations, and the Electron queue-boundary coverage.
This commit is contained in:
ethernet 2026-07-23 00:13:58 -04:00 committed by GitHub
parent 2ebeede006
commit 406d7a67f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 33 additions and 21 deletions

View file

@ -26,12 +26,18 @@ async function send(page: Page, text: string): Promise<void> {
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.click()
await composer.type(text, { delay: 5 })
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await primary.click()
await page.keyboard.press('Enter')
}
async function queue(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Control+Enter')
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
@ -72,15 +78,10 @@ test.describe('queued prompt turn boundary', () => {
test('submits a queued prompt only after the active turn completes', async () => {
const { mock, page } = fixture!
const composer = page.locator('[contenteditable="true"]').first()
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await composer.click()
await composer.type(QUEUED_PROMPT, { delay: 5 })
await queue.click()
await queue(page, QUEUED_PROMPT)
await expect(page.getByText('1 Queued')).toBeVisible()
// The mock keeps the active SSE stream open, so a queued prompt has no

View file

@ -65,15 +65,15 @@ describe('ComposerControls shortcut tooltips', () => {
await expectShortcutTooltip('Send', '↵')
})
it('shows Ctrl+Enter for Steer', async () => {
it('shows Enter for Steer', async () => {
renderControls({ busy: true, busyAction: 'steer' })
await expectShortcutTooltip('Steer the current run', 'Ctrl+↵')
await expectShortcutTooltip('Steer the current run', '↵')
})
it('shows Enter for Queue', async () => {
it('shows Ctrl+Enter for Queue', async () => {
renderControls({ busy: true, busyAction: 'queue' })
await expectShortcutTooltip('Queue message', '↵')
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})

View file

@ -81,7 +81,7 @@ export function ComposerControls({
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
{busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.send" text={c.queueMessage} />}>
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
aria-label={c.queueMessage}
className={GHOST_ICON_BTN}
@ -116,7 +116,7 @@ export function ComposerControls({
label={
busy ? (
<TipKeybindLabel
actionId={busyAction === 'steer' ? 'composer.steer' : 'composer.send'}
actionId={busyAction === 'steer' ? 'composer.steer' : busyAction === 'queue' ? 'composer.queue' : 'composer.send'}
text={busyLabel}
/>
) : (

View file

@ -587,14 +587,22 @@ export function ChatBar({
return
}
// Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
// Steer when there's a steerable draft, otherwise swallow it so it can't
// surprise-send. (Plain Enter still queues while busy / sends when idle.)
// Cmd/Ctrl+Enter queues a follow-up while a turn runs. Plain Enter steers
// a text-only draft, so both live-turn actions stay reachable by keyboard.
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
event.preventDefault()
if (canSteer) {
steerDraft()
if (busy && !disabled) {
// As with plain Enter, source the just-typed content from the DOM so a
// fast keypress cannot queue a stale draft.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
if (editorText !== draftRef.current) {
draftRef.current = editorText
setComposerText(editorText)
}
queueDraft()
}
return

View file

@ -292,6 +292,7 @@ export const en: Translations = {
'composer.send': 'Send message',
'composer.newline': 'Insert newline',
'composer.steer': 'Steer the running turn',
'composer.queue': 'Queue message',
'composer.sendQueued': 'Send next queued turn',
'composer.mention': 'Reference files, folders, URLs',
'composer.slash': 'Slash command palette',

View file

@ -283,6 +283,7 @@ export const zh: Translations = {
'composer.send': '发送消息',
'composer.newline': '插入换行',
'composer.steer': '引导正在运行的回合',
'composer.queue': '消息排队',
'composer.sendQueued': '发送下一条排队消息',
'composer.mention': '引用文件、文件夹、网址',
'composer.slash': '斜杠命令面板',

View file

@ -197,7 +197,8 @@ export interface KeybindReadonly {
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
{ id: 'composer.steer', category: 'composer', keys: ['mod+enter'] },
{ id: 'composer.steer', category: 'composer', keys: ['enter'] },
{ id: 'composer.queue', category: 'composer', keys: ['mod+enter'] },
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
{ id: 'composer.mention', category: 'composer', keys: ['@'] },
{ id: 'composer.slash', category: 'composer', keys: ['/'] },