diff --git a/LAWS/CHAT.md b/LAWS/CHAT.md
index e9cc48154..6bbd789d8 100644
--- a/LAWS/CHAT.md
+++ b/LAWS/CHAT.md
@@ -30,6 +30,8 @@
- 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 MUST steer the first queued message when steering is available.
+- A send shortcut MUST NOT steer a queued message while the composer holds draft content or a queued message is being edited.
## Subagent activity
diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx
index c1c70566e..94f152e98 100644
--- a/src/features/chat/ui/ChatInput.tsx
+++ b/src/features/chat/ui/ChatInput.tsx
@@ -1231,6 +1231,10 @@ export function ChatInput({
void handleSteerCurrentMessage();
return;
}
+ if (canSteerQueuedMessageWithShortcut) {
+ handleSteerQueuedMessage();
+ return;
+ }
}
void handleSend();
diff --git a/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx b/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx
index c1726d554..1c79ec413 100644
--- a/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx
+++ b/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx
@@ -97,11 +97,14 @@ vi.mock("@/shared/api/system", () => ({
readImageAttachment: (path: string) => mockReadImageAttachment(path),
}));
+const mockResizeImage = vi.fn((file: File) =>
+ Promise.resolve({ base64: `base64:${file.name}`, mimeType: file.type }),
+);
+
// jsdom cannot decode image bytes, so stand in for the normalize pipeline;
// its behavior is covered by useChatInputAttachments tests.
vi.mock("@/features/chat/lib/resizeImage", () => ({
- resizeImage: (file: File) =>
- Promise.resolve({ base64: `base64:${file.name}`, mimeType: file.type }),
+ resizeImage: (file: File) => mockResizeImage(file),
normalizeImageBase64: (base64: string, mimeType: string | undefined) =>
Promise.resolve({ base64, mimeType }),
}));
@@ -121,6 +124,10 @@ vi.mock("@tauri-apps/api/core", () => ({
describe("ChatInput attachments", () => {
beforeEach(() => {
+ mockResizeImage.mockReset();
+ mockResizeImage.mockImplementation((file) =>
+ Promise.resolve({ base64: `base64:${file.name}`, mimeType: file.type }),
+ );
mockSearchFilesForMentions.mockClear();
mockSearchFilesForMentions.mockResolvedValue([]);
mockInspectAttachmentPaths.mockClear();
@@ -247,6 +254,51 @@ describe("ChatInput attachments", () => {
expect(await screen.findByText("report.txt")).toBeInTheDocument();
});
+ it("does not steer a queued message while attachment work is pending", async () => {
+ const onSend = vi.fn();
+ const onSteerQueuedMessage = vi.fn();
+ const user = userEvent.setup();
+ let releaseResize: (() => void) | undefined;
+ mockResizeImage.mockImplementationOnce(
+ (file) =>
+ new Promise((resolve) => {
+ releaseResize = () =>
+ resolve({ base64: `base64:${file.name}`, mimeType: file.type });
+ }),
+ );
+ render(
+ ,
+ );
+
+ const textbox = screen.getByRole("textbox");
+ const composer = textbox.closest("div.rounded-composer");
+ if (!composer) {
+ throw new Error("Expected composer container");
+ }
+ const dataTransfer = {
+ files: [new File(["img"], "shot.png", { type: "image/png" })],
+ items: [{ kind: "file" }],
+ types: ["Files"],
+ } as unknown as DataTransfer;
+ fireEvent.drop(composer, { dataTransfer });
+
+ await user.keyboard("{Enter}");
+
+ expect(onSteerQueuedMessage).not.toHaveBeenCalled();
+ expect(onSend).not.toHaveBeenCalled();
+
+ releaseResize?.();
+ expect(
+ await screen.findByRole("button", { name: "View attachment 1" }),
+ ).toBeInTheDocument();
+ });
+
it("does not cancel non-file drops into the composer", () => {
render();
diff --git a/src/features/chat/ui/__tests__/ChatInput.test.tsx b/src/features/chat/ui/__tests__/ChatInput.test.tsx
index e7003a4a0..b9dd1acad 100644
--- a/src/features/chat/ui/__tests__/ChatInput.test.tsx
+++ b/src/features/chat/ui/__tests__/ChatInput.test.tsx
@@ -3194,7 +3194,7 @@ describe("ChatInput", () => {
});
});
- it("does not steer a queued message from an empty composer on enter", async () => {
+ it("steers the queued message on enter with an empty composer", async () => {
const onSend = vi.fn();
const onSteerQueuedMessage = vi.fn();
const user = userEvent.setup();
@@ -3210,10 +3210,117 @@ describe("ChatInput", () => {
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.keyboard("{Meta>}{Enter}{/Meta}");
+
+ expect(onSteerQueuedMessage).toHaveBeenCalledOnce();
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ 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.keyboard("{Enter}");
+
+ expect(onSteerQueuedMessage).not.toHaveBeenCalled();
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("does not offer steering while a hidden record heads the queue", async () => {
+ const onSend = vi.fn();
+ const onSteerQueuedMessage = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ expect(screen.queryByTitle("Steer queued message")).not.toBeInTheDocument();
+ await user.keyboard("{Enter}");
+
expect(onSteerQueuedMessage).not.toHaveBeenCalled();
expect(onSend).not.toHaveBeenCalled();
});
+ it("does not steer the queued message while it is being edited", async () => {
+ 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("appends a draft without steering the queued head", async () => {
const onSend = vi.fn();
const onSteerQueuedMessage = vi.fn();