diff --git a/frontend/src/components/ChatView/ChatInputBar.jsx b/frontend/src/components/ChatView/ChatInputBar.jsx index 273e53822..3fbfa71c8 100644 --- a/frontend/src/components/ChatView/ChatInputBar.jsx +++ b/frontend/src/components/ChatView/ChatInputBar.jsx @@ -62,9 +62,10 @@ * ║ `_isTouchPrimary` is detected once via ║ * ║ `matchMedia('(hover: none) and (pointer: coarse)')` and ║ * ║ gates plain Enter. Touch devices: Enter inserts a ║ - * ║ newline. Desktop: Enter sends or steers. Cmd/Ctrl+Enter ║ - * ║ is an explicit hardware-keyboard shortcut for the same ║ - * ║ send/steer action. Shift+Enter always inserts a newline. ║ + * ║ newline. Desktop: Enter sends or steers queued text. ║ + * ║ Cmd/Ctrl+Enter fast-forwards composed text into a live ║ + * ║ turn when possible, otherwise it sends normally. ║ + * ║ Shift+Enter always inserts a newline. ║ * ║ ║ * ╚══════════════════════════════════════════════════════════════════╝ */ @@ -275,6 +276,8 @@ function FileChips({ files, onRemove }) { * input — current textarea value * onInputChange — receives new string * onSubmit — called with FormEvent | MouseEvent | TouchEvent + * onSubmitSteer — submits composed text and immediately steers + * it when a live turn can accept steering * inputRef — for caller to focus/blur (e.g. dismiss keyboard) * sending — agent is currently streaming * listening — voice input active @@ -292,6 +295,8 @@ function FileChips({ files, onRemove }) { * existing steer handler to reconcile/steer queued * messages, even before the visual fast-forward gate * is ready. + * canSubmitSteer — true when Cmd/Ctrl+Enter may submit the current + * draft through the live-turn steer path. * pendingFiles — file upload chips state * onAddFiles — receives FileList from file picker * onRemoveFile — receives chip id @@ -318,6 +323,7 @@ export default function ChatInputBar({ input, onInputChange, onSubmit, + onSubmitSteer, inputRef, sending, listening, @@ -328,6 +334,7 @@ export default function ChatInputBar({ onSteer, canSteer, canRequestSteer = canSteer, + canSubmitSteer = canRequestSteer, offline, sendFailure = null, submissionBlocked = false, @@ -418,6 +425,7 @@ export default function ChatInputBar({ hasInput, canSteer, canRequestSteer, + canSubmitSteer, isTouchPrimary: _isTouchPrimary, }) if (!action) return @@ -426,6 +434,10 @@ export default function ChatInputBar({ onSteer() return } + if (action === 'submit-steer') { + if (!submissionBlocked) onSubmitSteer(e) + return + } if (action === 'submit') { if (!submissionBlocked) onSubmit(e) } diff --git a/frontend/src/components/ChatView/ChatView.jsx b/frontend/src/components/ChatView/ChatView.jsx index c5b4afd8a..9637743df 100644 --- a/frontend/src/components/ChatView/ChatView.jsx +++ b/frontend/src/components/ChatView/ChatView.jsx @@ -2154,6 +2154,12 @@ export default function ChatView({ cidList: result.message?._consumed_cids, }) bridgeHook.markBridged() + } else if (opts.steerAfterQueue) { + // Ctrl/Cmd+Enter uses the same durable queue -> force-steer path + // as the visible per-row arrow. The queue acknowledgement gives + // the new row a canonical ts before steering, so a failed or + // racing steer naturally leaves the message safely queued. + await handleSteerOne(cid) } } // Mid-turn steer: the backend delivered the send into the live @@ -2605,6 +2611,12 @@ export default function ChatView({ doSend(input.trim()) } + function handleSubmitSteer(e) { + e.preventDefault() + if (isProviderSwitchBlocking(chatId)) return + doSend(input.trim(), { steerAfterQueue: true }) + } + // Cancel one queued message via DELETE. Keep reconciliation scoped to that // CID: full queue snapshots can arrive out of order when two rows are // cancelled quickly and would otherwise resurrect a sibling cancellation. @@ -3382,10 +3394,11 @@ export default function ChatView({ const canSteer = !hasPendingQuestion && connectionError !== 'disconnected' && !steerBusy && canFastForwardQueue(pendingQueue.pendingMessages, turnActive) - const canRequestSteer = !hasPendingQuestion + const canSubmitSteer = !hasPendingQuestion && connectionError !== 'disconnected' && !steerBusy && turnActive + const canRequestSteer = canSubmitSteer && pendingQueue.pendingMessages.length > 0 // ── Sticky "tap to resume" affordance ────────────────────────────── @@ -3962,6 +3975,7 @@ export default function ChatView({ input={input} onInputChange={handleComposerInputChange} onSubmit={handleSubmit} + onSubmitSteer={handleSubmitSteer} inputRef={inputRef} sending={composerBusy} listening={listening} @@ -3972,6 +3986,7 @@ export default function ChatView({ onSteer={handleSteer} canSteer={canSteer} canRequestSteer={canRequestSteer} + canSubmitSteer={canSubmitSteer} offline={!online} sendFailure={sendFailure} submissionBlocked={providerSwitching} diff --git a/frontend/src/components/ChatView/__tests__/buildPhaseRail.test.js b/frontend/src/components/ChatView/__tests__/buildPhaseRail.test.js index d24d5df58..34d76deb7 100644 --- a/frontend/src/components/ChatView/__tests__/buildPhaseRail.test.js +++ b/frontend/src/components/ChatView/__tests__/buildPhaseRail.test.js @@ -109,8 +109,10 @@ test('connection failure hides queued actions and disables composer steering', ( 'the lost-connection state should own the footer stack until Retry succeeds') assert.match(chatView, /const canSteer = !hasPendingQuestion[\s\S]*?connectionError !== 'disconnected' && !steerBusy[\s\S]*?canFastForwardQueue/, 'the visible composer steer action must be gated by pending QA and connection health') - assert.match(chatView, /const canRequestSteer = !hasPendingQuestion[\s\S]*?connectionError !== 'disconnected'[\s\S]*?!steerBusy[\s\S]*?turnActive/, - 'the keyboard steer path must be gated by pending QA and connection health too') + assert.match(chatView, /const canSubmitSteer = !hasPendingQuestion[\s\S]*?connectionError !== 'disconnected'[\s\S]*?!steerBusy[\s\S]*?turnActive/, + 'the composed-text keyboard steer path must be gated by pending QA and connection health too') + assert.match(chatView, /const canRequestSteer = canSubmitSteer[\s\S]*?pendingQueue\.pendingMessages\.length > 0/, + 'the empty-composer keyboard path must share the same gate and require queued work') }) test('a send that merely enqueues preserves the in-flight build rail', () => { diff --git a/frontend/src/components/ChatView/__tests__/composerShortcuts.test.js b/frontend/src/components/ChatView/__tests__/composerShortcuts.test.js index 8247da399..7a81fc088 100644 --- a/frontend/src/components/ChatView/__tests__/composerShortcuts.test.js +++ b/frontend/src/components/ChatView/__tests__/composerShortcuts.test.js @@ -5,22 +5,35 @@ import { resolveComposerEnterAction } from '../composerShortcuts.js' const enter = (overrides = {}) => ({ key: 'Enter', ...overrides }) -test('Cmd+Enter submits composer text', () => { +test('Cmd+Enter steers composer text when a live turn can accept it', () => { assert.equal( resolveComposerEnterAction(enter({ metaKey: true }), { hasInput: true, canSteer: true, + canSubmitSteer: true, isTouchPrimary: false, }), - 'submit', + 'submit-steer', ) }) -test('Ctrl+Enter submits composer text', () => { +test('Ctrl+Enter steers composer text when a live turn can accept it', () => { assert.equal( resolveComposerEnterAction(enter({ ctrlKey: true }), { hasInput: true, canSteer: false, + canSubmitSteer: true, + isTouchPrimary: false, + }), + 'submit-steer', + ) +}) + +test('Cmd/Ctrl+Enter submits normally when there is no steerable live turn', () => { + assert.equal( + resolveComposerEnterAction(enter({ metaKey: true }), { + hasInput: true, + canSubmitSteer: false, isTouchPrimary: false, }), 'submit', diff --git a/frontend/src/components/ChatView/__tests__/optimisticSteerQueue.test.js b/frontend/src/components/ChatView/__tests__/optimisticSteerQueue.test.js index 7918c32a5..309fb457a 100644 --- a/frontend/src/components/ChatView/__tests__/optimisticSteerQueue.test.js +++ b/frontend/src/components/ChatView/__tests__/optimisticSteerQueue.test.js @@ -74,3 +74,16 @@ test('a steer request disables sibling row actions until it settles', () => { assert.match(source, /steerBusy=\{steerBusy\}/, 'the queued tray should receive the in-flight state for its row buttons') }) + +test('the modified-Enter submit waits for durability, then reuses per-row steer', () => { + assert.match( + source, + /pendingQueue\.confirmQueued\(cid,[\s\S]*?else if \(opts\.steerAfterQueue\) \{[\s\S]*?await handleSteerOne\(cid\)/, + 'the composed message must be server-confirmed before the existing row steer consumes it', + ) + assert.match( + source, + /function handleSubmitSteer\(e\) \{[\s\S]*?doSend\(input\.trim\(\), \{ steerAfterQueue: true \}\)/, + 'the keyboard handler should opt into the queue-to-steer path without changing ordinary sends', + ) +}) diff --git a/frontend/src/components/ChatView/composerShortcuts.js b/frontend/src/components/ChatView/composerShortcuts.js index edd4bca04..d69146a3a 100644 --- a/frontend/src/components/ChatView/composerShortcuts.js +++ b/frontend/src/components/ChatView/composerShortcuts.js @@ -2,6 +2,7 @@ export function resolveComposerEnterAction(event, { hasInput = false, canSteer = false, canRequestSteer = canSteer, + canSubmitSteer = canRequestSteer, isTouchPrimary = false, } = {}) { if (!event || event.key !== 'Enter' || event.shiftKey) return null @@ -9,7 +10,10 @@ export function resolveComposerEnterAction(event, { const modifiedEnter = !!(event.metaKey || event.ctrlKey) if (!modifiedEnter && isTouchPrimary) return null - if (hasInput) return 'submit' + if (hasInput) { + if (modifiedEnter && canSubmitSteer) return 'submit-steer' + return 'submit' + } if (canRequestSteer) return 'steer' return 'noop' } diff --git a/tests/steer-queued.spec.mjs b/tests/steer-queued.spec.mjs index 7500f2eed..a6c7ea1df 100644 --- a/tests/steer-queued.spec.mjs +++ b/tests/steer-queued.spec.mjs @@ -195,6 +195,78 @@ test.describe('Steer queued messages (fast-forward into the live turn)', () => { expect(await page.locator('.queued__row').count()).toBe(0) }) + test('Ctrl+Enter queues durably, then automatically steers only the composed message', async ({ page }) => { + const STEER_TEXT = 'change course immediately' + const messagePosts = [] + + await page.route(/\/api\/chats\/[0-9a-f-]+\/messages$/, async (route) => { + let body = {} + try { body = JSON.parse(route.request().postData() || '{}') } catch { /* empty */ } + messagePosts.push(body) + + if (body.force_steer) { + return route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ + status: 'steered', + chat_id: 'mock', + pending_messages: [], + }), + }) + } + if (body.content === 'first message') { + return route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ + status: 'started', + message: { + role: 'user', content: body.content, ts: Date.now(), cid: body.cid, + }, + }), + }) + } + return route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ status: 'queued', ts: 778001, position: 1 }), + }) + }) + + await page.route(/\/api\/chats\/[0-9a-f-]+\/stream$/, async (route) => { + await new Promise(resolve => setTimeout(resolve, 8000)) + await route.fulfill({ + status: 200, + headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, + body: sseBody([{ type: 'catch_up_done' }, { type: 'text', content: 'streaming...' }]), + }).catch(() => {}) + }) + + await setupChat(page) + await newChat(page) + await sendMessage(page, 'first message') + await expect(page.locator('.chat__stop')).toBeVisible({ timeout: 5000 }) + + const input = page.getByRole('textbox', { name: 'Message Möbius…' }) + await input.fill(STEER_TEXT) + await page.keyboard.press('Control+Enter') + + await expect.poll( + () => messagePosts.filter(body => body.force_steer).length, + { timeout: 5000 }, + ).toBe(1) + + const queuePost = messagePosts.find(body => ( + !body.force_steer && body.content === STEER_TEXT + )) + const steerPost = messagePosts.find(body => body.force_steer) + expect(typeof queuePost.cid).toBe('string') + expect(steerPost.content).toBe(STEER_TEXT) + expect(steerPost.consume_pending_cids).toEqual([queuePost.cid]) + await expect(page.locator('.queued__row')).toHaveCount(0, { timeout: 5000 }) + }) + test('two queued messages steer with the exact "\\n\\n"-joined content', async ({ page }) => { // Verifies the frontend content join sent to the provider steer: the // non-empty trimmed contents joined