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();