From affd064747b2c8141ccc19c24e7865344366191b Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:33:43 -0700 Subject: [PATCH 1/3] feat(chat): steer the first queued message on enter with an empty composer While a session is running, pressing the send shortcut with nothing in the composer now steers the first queued message instead of no-oping, restoring 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. Codified in LAWS/CHAT.md. --- LAWS/CHAT.md | 2 + src/features/chat/ui/ChatInput.tsx | 23 +++ .../chat/ui/__tests__/ChatInput.test.tsx | 135 +++++++++++++++--- 3 files changed, 140 insertions(+), 20 deletions(-) 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..dcd164d4a 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -504,6 +504,21 @@ export function ChatInput({ canSteerMessage && visibleQueuedMessages.length === 0 && Boolean(onSteerMessage); + // 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 && + !disabled && + !sendDisabled && + isStreaming && + canSteerQueuedMessage && + editingQueuedRecordId === null && + visibleQueuedMessages.length > 0 && + Boolean(onSteerQueuedMessage); const effectivePersonaId = editingQueuedPersona ? editingQueuedPersona.kind === "persona" @@ -1148,6 +1163,10 @@ export function ChatInput({ void handleSteerCurrentMessage(); return; } + if (canSteerQueuedMessageWithShortcut) { + handleSteerQueuedMessage(); + return; + } } void handleSend(); return; @@ -1183,6 +1202,10 @@ export function ChatInput({ void handleSteerCurrentMessage(); return; } + if (canSteerQueuedMessageWithShortcut) { + handleSteerQueuedMessage(); + return; + } } void handleSend(); diff --git a/src/features/chat/ui/__tests__/ChatInput.test.tsx b/src/features/chat/ui/__tests__/ChatInput.test.tsx index a347497b0..eec1ea764 100644 --- a/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -2350,6 +2350,121 @@ describe("ChatInput", () => { ).toBeInTheDocument(); }); + it("steers the queued message on enter with an empty composer", async () => { + const onSend = vi.fn(); + const onSteerQueuedMessage = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("textbox")); + await user.keyboard("{Enter}"); + + expect(onSteerQueuedMessage).toHaveBeenCalledOnce(); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("steers the queued message on cmd-enter with an empty composer", async () => { + const onSend = vi.fn(); + const onSteerQueuedMessage = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("textbox")); + await user.keyboard("{Meta>}{Enter}{/Meta}"); + + expect(onSteerQueuedMessage).toHaveBeenCalledOnce(); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("does not steer the queued message on enter while the composer holds a draft", async () => { + const onSend = vi.fn(); + const onSteerQueuedMessage = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByRole("textbox"), "second follow up"); + await user.keyboard("{Enter}"); + + expect(onSteerQueuedMessage).not.toHaveBeenCalled(); + expect(onSend).toHaveBeenCalledWith("second follow up", null, undefined); + }); + + it("does not steer the queued message on enter when the session is idle", async () => { + const onSend = vi.fn(); + const onSteerQueuedMessage = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("textbox")); + await user.keyboard("{Enter}"); + + expect(onSteerQueuedMessage).not.toHaveBeenCalled(); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("does not steer the queued message on enter while a queued record is being edited", async () => { + const onSend = vi.fn(); + const onSteerQueuedMessage = vi.fn(); + const user = userEvent.setup(); + render( + true)} + onCancelQueueEdit={vi.fn(() => true)} + onDismissQueue={vi.fn()} + onUpdateQueue={vi.fn(() => true)} + />, + ); + + await user.click( + screen.getByRole("button", { name: "Edit queued message" }), + ); + await user.clear(screen.getByRole("textbox")); + await user.keyboard("{Enter}"); + + expect(onSteerQueuedMessage).not.toHaveBeenCalled(); + }); + it("hides queue edit and dismiss actions when dismissal is disabled", () => { render( { }); }); - it("does not steer a queued message from an empty composer on enter", async () => { - const onSend = vi.fn(); - const onSteerQueuedMessage = vi.fn(); - const user = userEvent.setup(); - render( - , - ); - - await user.keyboard("{Enter}"); - - expect(onSteerQueuedMessage).not.toHaveBeenCalled(); - expect(onSend).not.toHaveBeenCalled(); - }); - it("appends a draft without steering the queued head", async () => { const onSend = vi.fn(); const onSteerQueuedMessage = vi.fn(); From 22cde6f57a4778b3d9f7fa5569a3aaa398405479 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:45:44 -0700 Subject: [PATCH 2/3] fix(chat): only offer queued steering when the true queue head is visible Steering acts on the true queue head, but the steer button and the empty-composer shortcut were gated on the visible pill list. Hidden records (reliable startup handoffs) cannot coexist with an active run today, so this is a tripwire: if a future longer-lived hidden record ever heads the queue, steering goes inert instead of steering a message the user cannot see. --- src/features/chat/ui/ChatInput.tsx | 23 ++++++++--- .../chat/ui/__tests__/ChatInput.test.tsx | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index dcd164d4a..6912f5e02 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,14 @@ 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 @@ -517,7 +527,7 @@ export function ChatInput({ isStreaming && canSteerQueuedMessage && editingQueuedRecordId === null && - visibleQueuedMessages.length > 0 && + queuedHeadIsVisible && Boolean(onSteerQueuedMessage); const effectivePersonaId = editingQueuedPersona @@ -1659,7 +1669,10 @@ export function ChatInput({ {payload.text} - {index === 0 && isStreaming && canSteerQueuedMessage ? ( + {index === 0 && + isStreaming && + canSteerQueuedMessage && + queuedHeadIsVisible ? (