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,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.
45 changes: 41 additions & 4 deletions src/features/chat/ui/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -504,6 +506,30 @@ 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
// 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 &&
!attachmentWorkPending &&
!disabled &&
!sendDisabled &&
isStreaming &&
canSteerQueuedMessage &&
editingQueuedRecordId === null &&
queuedHeadIsVisible &&
Boolean(onSteerQueuedMessage);

const effectivePersonaId = editingQueuedPersona
? editingQueuedPersona.kind === "persona"
Expand Down Expand Up @@ -1148,6 +1174,10 @@ export function ChatInput({
void handleSteerCurrentMessage();
return;
}
if (canSteerQueuedMessageWithShortcut) {
handleSteerQueuedMessage();
return;
}
}
void handleSend();
return;
Expand Down Expand Up @@ -1183,6 +1213,10 @@ export function ChatInput({
void handleSteerCurrentMessage();
return;
}
if (canSteerQueuedMessageWithShortcut) {
handleSteerQueuedMessage();
return;
}
}

void handleSend();
Expand Down Expand Up @@ -1636,7 +1670,10 @@ export function ChatInput({
<span className="flex-1 truncate text-xs opacity-75">
{payload.text}
</span>
{index === 0 && isStreaming && canSteerQueuedMessage ? (
{index === 0 &&
isStreaming &&
canSteerQueuedMessage &&
queuedHeadIsVisible ? (
<button
type="button"
onClick={handleSteerQueuedMessage}
Expand Down
240 changes: 220 additions & 20 deletions src/features/chat/ui/__tests__/ChatInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ vi.mock("@/shared/api/system", () => ({
readImageAttachment: (path: string) => mockReadImageAttachment(path),
}));

// jsdom cannot decode image bytes; a controllable stand-in lets tests hold
// attachment work open (a pending resize) while asserting shortcut gating.
const mockResizeImage = vi.fn<
(file: File) => Promise<{ base64: string; mimeType: string }>
>((file) =>
Promise.resolve({ base64: `base64:${file.name}`, mimeType: file.type }),
);
vi.mock("@/features/chat/lib/resizeImage", () => ({
resizeImage: (file: File) => mockResizeImage(file),
normalizeImageBase64: (base64: string, mimeType: string | undefined) =>
Promise.resolve({ base64, mimeType }),
}));

vi.mock("@/features/skills/api/skills", () => ({
listSkills: vi.fn(() => immediatelyResolved([])),
}));
Expand Down Expand Up @@ -2350,6 +2363,213 @@ 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(
<ChatInput
onSend={onSend}
onSteerQueuedMessage={onSteerQueuedMessage}
canSteerQueuedMessage
isStreaming
queuedMessage={{ persona: { kind: "none" }, text: "queued msg" }}
/>,
);

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

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

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

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 attachment work is pending", async () => {
const onSend = vi.fn();
const onSteerQueuedMessage = vi.fn();
const user = userEvent.setup();
// Hold the image resize open so attachment admission stays in flight
// while Enter is pressed.
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");
}
fireEvent.drop(composer, {
dataTransfer: {
files: [new File(["img"], "shot.png", { type: "image/png" })],
items: [{ kind: "file" }],
types: ["Files"],
},
});

await user.click(textbox);
await user.keyboard("{Enter}");

expect(onSteerQueuedMessage).not.toHaveBeenCalled();
expect(onSend).not.toHaveBeenCalled();

// Once admission settles, the attachment lands in the composer — the
// lock is the in-flight work, not the attachment itself (and the staged
// attachment then keeps the shortcut on the draft as draft content).
releaseResize?.();
expect(
await screen.findByRole("button", { name: "View attachment 1" }),
).toBeInTheDocument();
});

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",
},
},
]}
/>,
);

// The visible pill must not offer a steer button that would act on the
// hidden head, and the empty-composer shortcut must stay inert.
expect(screen.queryByTitle("Steer queued message")).not.toBeInTheDocument();
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(
<ChatInput
onSend={onSend}
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("hides queue edit and dismiss actions when dismissal is disabled", () => {
render(
<ChatInput
Expand Down Expand Up @@ -3169,26 +3389,6 @@ describe("ChatInput", () => {
});
});

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

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