diff --git a/LAWS/CHAT.md b/LAWS/CHAT.md index 9216c0628..7aa0d095b 100644 --- a/LAWS/CHAT.md +++ b/LAWS/CHAT.md @@ -30,3 +30,5 @@ - A message that is not first in the queue MUST NOT steer the session. - A steering result MUST affect only the message that produced it. +- While the session is running, a send shortcut with an empty composer MAY steer the first queued message. +- A send shortcut MUST NOT steer a queued message while the composer holds draft content or a queued message is being edited. diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index f14b3622f..6480acc7e 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -478,10 +478,12 @@ export function ChatInput({ }; }, [scheduleResizeTextarea, surface]); - const visibleQueuedMessages = ( + const allQueuedMessages = queuedMessages ?? - (queuedMessage ? [{ recordId: "legacy", payload: queuedMessage }] : []) - ).filter(({ payload }) => payload.showInComposer !== false); + (queuedMessage ? [{ recordId: "legacy", payload: queuedMessage }] : []); + const visibleQueuedMessages = allQueuedMessages.filter( + ({ payload }) => payload.showInComposer !== false, + ); // A record being edited lives in the composer, so its pill is hidden to // avoid showing the same message both queued and in the composer. Queue // positions (head-only actions) still come from the unfiltered list. @@ -504,6 +506,30 @@ export function ChatInput({ canSteerMessage && visibleQueuedMessages.length === 0 && Boolean(onSteerMessage); + // Steering acts on the true queue head, so it is only offered when that + // head is also the message the user can see. In practice hidden records + // (reliable startup handoffs) cannot coexist with an active run today; + // this is a tripwire so a future longer-lived hidden record makes + // steering go inert instead of steering something off-screen. + const queuedHeadIsVisible = + allQueuedMessages.length > 0 && + allQueuedMessages[0].payload.showInComposer !== false; + // With an empty composer, the send shortcut steers the first queued + // message instead of no-oping — the double-enter flow (enter queues, + // enter again steers). Draft content keeps the shortcut on the draft so + // it can never discard or bypass what the user is composing, and an + // in-progress queue edit keeps the shortcut inert because the edited + // message lives in the composer, not the queue. + const canSteerQueuedMessageWithShortcut = + !hasDraftContent && + !attachmentWorkPending && + !disabled && + !sendDisabled && + isStreaming && + canSteerQueuedMessage && + editingQueuedRecordId === null && + queuedHeadIsVisible && + Boolean(onSteerQueuedMessage); const effectivePersonaId = editingQueuedPersona ? editingQueuedPersona.kind === "persona" @@ -1148,6 +1174,10 @@ export function ChatInput({ void handleSteerCurrentMessage(); return; } + if (canSteerQueuedMessageWithShortcut) { + handleSteerQueuedMessage(); + return; + } } void handleSend(); return; @@ -1183,6 +1213,10 @@ export function ChatInput({ void handleSteerCurrentMessage(); return; } + if (canSteerQueuedMessageWithShortcut) { + handleSteerQueuedMessage(); + return; + } } void handleSend(); @@ -1636,7 +1670,10 @@ export function ChatInput({ {payload.text} - {index === 0 && isStreaming && canSteerQueuedMessage ? ( + {index === 0 && + isStreaming && + canSteerQueuedMessage && + queuedHeadIsVisible ? (