Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions frontend/src/components/ChatView/ChatInputBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. ║
* ║ ║
* ╚══════════════════════════════════════════════════════════════════╝
*/
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -318,6 +323,7 @@ export default function ChatInputBar({
input,
onInputChange,
onSubmit,
onSubmitSteer,
inputRef,
sending,
listening,
Expand All @@ -328,6 +334,7 @@ export default function ChatInputBar({
onSteer,
canSteer,
canRequestSteer = canSteer,
canSubmitSteer = canRequestSteer,
offline,
sendFailure = null,
submissionBlocked = false,
Expand Down Expand Up @@ -418,6 +425,7 @@ export default function ChatInputBar({
hasInput,
canSteer,
canRequestSteer,
canSubmitSteer,
isTouchPrimary: _isTouchPrimary,
})
if (!action) return
Expand All @@ -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)
}
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/components/ChatView/ChatView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ──────────────────────────────
Expand Down Expand Up @@ -3962,6 +3975,7 @@ export default function ChatView({
input={input}
onInputChange={handleComposerInputChange}
onSubmit={handleSubmit}
onSubmitSteer={handleSubmitSteer}
inputRef={inputRef}
sending={composerBusy}
listening={listening}
Expand All @@ -3972,6 +3986,7 @@ export default function ChatView({
onSteer={handleSteer}
canSteer={canSteer}
canRequestSteer={canRequestSteer}
canSubmitSteer={canSubmitSteer}
offline={!online}
sendFailure={sendFailure}
submissionBlocked={providerSwitching}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
})
6 changes: 5 additions & 1 deletion frontend/src/components/ChatView/composerShortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ export function resolveComposerEnterAction(event, {
hasInput = false,
canSteer = false,
canRequestSteer = canSteer,
canSubmitSteer = canRequestSteer,
isTouchPrimary = false,
} = {}) {
if (!event || event.key !== 'Enter' || event.shiftKey) return null

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'
}
72 changes: 72 additions & 0 deletions tests/steer-queued.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading