Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions LAWS/CHAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/features/chat/ui/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,10 @@ export function ChatInput({
void handleSteerCurrentMessage();
return;
}
if (canSteerQueuedMessageWithShortcut) {
handleSteerQueuedMessage();
return;
}
}

void handleSend();
Expand Down
56 changes: 54 additions & 2 deletions src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}));
Expand All @@ -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();
Expand Down Expand Up @@ -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(
<ChatInput
onSend={onSend}
onSteerQueuedMessage={onSteerQueuedMessage}
canSteerQueuedMessage
isStreaming
queuedMessage={{ persona: { kind: "none" }, text: "queued msg" }}
/>,
);

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(<ChatInput onSend={vi.fn()} />);

Expand Down
109 changes: 108 additions & 1 deletion src/features/chat/ui/__tests__/ChatInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
<ChatInput
onSend={onSend}
onSteerQueuedMessage={onSteerQueuedMessage}
canSteerQueuedMessage
isStreaming
queuedMessage={{ persona: { kind: "none" }, text: "queued msg" }}
/>,
);

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(
<ChatInput
onSend={onSend}
onSteerQueuedMessage={onSteerQueuedMessage}
canSteerQueuedMessage
queuedMessage={{ persona: { kind: "none" }, text: "queued msg" }}
/>,
);

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(
<ChatInput
onSend={onSend}
onSteerQueuedMessage={onSteerQueuedMessage}
canSteerQueuedMessage
isStreaming
queuedMessages={[
{
recordId: "hidden-head",
payload: {
persona: { kind: "none" as const },
text: "startup handoff",
showInComposer: false,
},
},
{
recordId: "visible-tail",
payload: {
persona: { kind: "none" as const },
text: "queued msg",
},
},
]}
/>,
);

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(
<ChatInput
onSend={vi.fn()}
onSteerQueuedMessage={onSteerQueuedMessage}
canSteerQueuedMessage
isStreaming
queuedMessages={[
{
recordId: "head",
payload: { persona: { kind: "none" as const }, text: "queued msg" },
},
]}
onEditQueue={vi.fn(() => 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();
Expand Down