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 ? (