Skip to content
Closed
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
49 changes: 45 additions & 4 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1829,6 +1829,47 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(false);
});

it("keeps a queued follow-up busy when the previous turn's checkpoint arrives late", () => {
const localDispatch = createLocalDispatchSnapshot(
makeThread({ latestTurn: completedTurn, session: readySession }),
);

expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "ready",
latestTurn: { ...completedTurn, completedAt: "2026-03-29T00:01:00.000Z" },
latestUserMessageId: MessageId.make("message-followup"),
session: readySession,
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
}),
).toBe(false);
});

it.each(["ready", "interrupted"] as const)(
"ignores a delayed ready session update after queueing from %s",
(status) => {
const localDispatch = createLocalDispatchSnapshot(
makeThread({ latestTurn: completedTurn, session: { ...readySession, status } }),
);

expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "ready",
latestTurn: completedTurn,
latestUserMessageId: MessageId.make("message-followup"),
session: { ...readySession, updatedAt: "2026-03-29T00:01:00.000Z" },
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
}),
).toBe(false);
},
);

it("keeps a follow-up active while its provider session is starting", () => {
const localDispatch = createLocalDispatchSnapshot(
makeThread({ latestTurn: completedTurn, session: readySession }),
Expand Down Expand Up @@ -1925,7 +1966,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(true);
});

it("acknowledges a steering message projected onto the current running turn", () => {
it.each(["running", "ready"] as const)("acknowledges a steer while %s", (status) => {
const runningTurn = {
...completedTurn,
state: "running" as const,
Expand Down Expand Up @@ -1957,10 +1998,10 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "running",
latestTurn: runningTurn,
phase: status,
latestTurn: status === "running" ? runningTurn : completedTurn,
latestUserMessageId: MessageId.make("message-steer"),
session: runningSession,
session: status === "running" ? runningSession : readySession,
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
Expand Down
9 changes: 4 additions & 5 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,7 +1207,6 @@ export interface LocalDispatchSnapshot {
latestTurnStartedAt: string | null;
latestTurnCompletedAt: string | null;
sessionStatus: NonNullable<Thread["session"]>["status"] | null;
sessionUpdatedAt: string | null;
latestTurnStartFailureId: string | null;
}

Expand Down Expand Up @@ -1248,7 +1247,6 @@ export function createLocalDispatchSnapshot(
latestTurnStartedAt: latestTurn?.startedAt ?? null,
latestTurnCompletedAt: latestTurn?.completedAt ?? null,
sessionStatus: session?.status ?? null,
sessionUpdatedAt: session?.updatedAt ?? null,
latestTurnStartFailureId: latestTurnStartFailureId(activeThread, latestUserMessage?.id ?? null),
};
}
Expand Down Expand Up @@ -1315,10 +1313,11 @@ export function hasServerAcknowledgedLocalDispatch(input: {
return true;
}

// Late checkpoint and session updates from the previous turn do not
// acknowledge a new one. A steer can be acknowledged after its turn ended.
return (
latestTurnChanged ||
input.localDispatch.sessionStatus !== (session?.status ?? null) ||
input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
(input.localDispatch.sessionStatus === "running" && latestUserMessageChanged)
);
}

Expand Down
3 changes: 0 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,6 @@ import {
terminalContextReference,
} from "../lib/composerContextRecords";
import {
latestCompletedToolActivityId,
type QueuedComposerMessage,
type QueuedMessageSendSettings,
useQueuedMessages,
Expand Down Expand Up @@ -7280,7 +7279,6 @@ export default function ChatView(props: ChatViewProps) {
previewAnnotations: [],
reviewComments: [],
sendSettings: sendCtx ? readComposerSendSettings(sendCtx) : firstMessage.sendSettings,
queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities),
// Restoration is not a send. The user decides when the overflow goes.
holdUntilUserAction: true,
createdAt: new Date().toISOString(),
Expand Down Expand Up @@ -7686,7 +7684,6 @@ export default function ChatView(props: ChatViewProps) {
previewAnnotations: [...composerPreviewAnnotations],
reviewComments: [...composerReviewComments],
sendSettings,
queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities),
createdAt: new Date().toISOString(),
});
promptRef.current = "";
Expand Down
21 changes: 14 additions & 7 deletions apps/web/src/components/QueuedMessageSender.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ function enqueue(overrides: Partial<QueuedComposerMessage> = {}) {
interactionMode: "default",
promptEffort: null,
},
queuedAfterToolActivityId: null,
createdAt: "2026-09-25T00:00:00Z",
...overrides,
});
Expand Down Expand Up @@ -104,7 +103,11 @@ afterEach(() => {
describe("QueuedMessageSender", () => {
const thread = (
status: string,
{ toolActivityIds = [] as string[], userMessageIds = [] as string[] } = {},
{
toolActivityIds = [] as string[],
userMessageIds = [] as string[],
latestTurnId = null as string | null,
} = {},
) => ({
session: { status, activeTurnId: null, updatedAt: status },
activities: toolActivityIds.map((id, index) => ({
Expand All @@ -114,7 +117,7 @@ describe("QueuedMessageSender", () => {
createdAt: "2026-09-25T00:00:01Z",
})),
messages: userMessageIds.map((id) => ({ id, role: "user" })),
latestTurn: null,
latestTurn: latestTurnId === null ? null : { turnId: latestTurnId },
});
let root: ReactTestRenderer | null = null;
const render = () =>
Expand Down Expand Up @@ -144,19 +147,23 @@ describe("QueuedMessageSender", () => {
expect(queue()).toBeUndefined();
});

it("holds the next message until the server picks up the one before it", async () => {
it("holds the next message until the turn the one before it started has ended", async () => {
enqueue({ prompt: "first" });
enqueue({ prompt: "second" });
io.thread = thread("ready");
await render();
await render();
expect(commandsRun()).toEqual(["start"]);

// The first message started a turn; the second waits for its next tool call.
io.thread = thread("running", { userMessageIds: ["first"] });
// The first message started a turn. A finished tool call does not release
// the second; the end of that turn does.
const started = { userMessageIds: ["first"], latestTurnId: "turn-1" };
io.thread = thread("running", started);
await render();
io.thread = thread("running", { ...started, toolActivityIds: ["tool-1"] });
await render();
expect(commandsRun()).toEqual(["start"]);
io.thread = thread("running", { userMessageIds: ["first"], toolActivityIds: ["tool-1"] });
io.thread = thread("ready", { ...started, toolActivityIds: ["tool-1"] });
await render();
expect(commandsRun()).toEqual(["start", "start"]);
});
Expand Down
14 changes: 3 additions & 11 deletions apps/web/src/components/QueuedMessageSender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useShallow } from "zustand/react/shallow";
import { useComposerDraftStore } from "../composerDraftStore";
import {
isQueuedMessageDue,
latestCompletedToolActivityId,
useQueuedMessageStore,
useQueuedMessages,
} from "../queuedMessageStore";
Expand All @@ -29,8 +28,8 @@ export function QueuedMessageSender() {

/**
* Watches one thread while it has queued messages. Reading the thread keeps
* its detail subscribed, so tool boundaries and the end of the turn are
* visible while the user is elsewhere.
* its detail subscribed, so the end of the turn is visible while the user is
* elsewhere.
*/
function ThreadQueueSender({ threadKey }: { threadKey: string }) {
const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]);
Expand All @@ -45,10 +44,6 @@ function ThreadQueueSender({ threadKey }: { threadKey: string }) {
const next = queue[0];
const sending = queue.some((message) => message.sending);
const activities = thread?.activities;
const latestToolActivityId = useMemo(
() => latestCompletedToolActivityId(activities ?? []),
[activities],
);
const pendingRequests = useMemo(() => derivePendingRequests(activities ?? []), [activities]);
const phase = derivePhase(thread?.session ?? null);

Expand Down Expand Up @@ -87,10 +82,7 @@ function ThreadQueueSender({ threadKey }: { threadKey: string }) {
waitingForServer ||
pendingRequests.approvals.length > 0 ||
pendingRequests.userInputs.length > 0;
const due =
next !== undefined &&
!blocked &&
isQueuedMessageDue({ message: next, phase, latestToolActivityId });
const due = next !== undefined && !blocked && isQueuedMessageDue({ message: next, phase });
const nextId = next?.id;
useEffect(() => {
if (!due || !threadRef || nextId === undefined) return;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
return sendButton;
}

// While a turn runs, a sendable draft queues for the next tool boundary, so
// the send button stays next to Stop on every viewport.
// While a turn runs, a sendable draft can queue or steer, so the send button
// stays next to Stop on every viewport.
return (
<>
{renderStopGenerationButton(false)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1106,7 +1106,6 @@ describe("deriveMessagesTimelineRows", () => {
interactionMode: "default" as const,
promptEffort: null,
},
queuedAfterToolActivityId: null,
createdAt: "2026-01-01T00:00:01Z",
});

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1795,7 +1795,7 @@ function QueuedMessageTimelineRow({
: queuedMessage.holdUntilUserAction
? "Waits for Send now"
: row.isNext
? "Sends after the next tool call or when the turn ends"
? "Sends when the turn ends"
: "Sends after the messages above it";
return (
<div className="flex flex-col items-end" data-queued-message-id={queuedMessage.id}>
Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/components/chat/sendQueuedMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
startAttachmentUpload,
} from "../../lib/attachmentUploadQueue";
import { newMessageId } from "../../lib/utils";
import { latestCompletedToolActivityId, useQueuedMessageStore } from "../../queuedMessageStore";
import { useQueuedMessageStore } from "../../queuedMessageStore";
import { appAtomRegistry } from "../../rpc/atomRegistry";
import { readThread, readThreadShell } from "../../state/entities";
import { environmentServerConfigsAtom } from "../../state/server";
Expand Down Expand Up @@ -52,11 +52,7 @@ export async function sendQueuedMessage(
const { environmentId, threadId } = threadRef;
const threadKey = scopedThreadKey(threadRef);
const queue = useQueuedMessageStore.getState();
const message = queue.beginSend(
threadKey,
messageId,
latestCompletedToolActivityId(readThread(threadRef)?.activities ?? []),
);
const message = queue.beginSend(threadKey, messageId);
if (!message) return;
const { sendSettings } = message;
const attachments = [...message.images, ...message.files];
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2733,7 +2733,7 @@ export function GeneralSettingsPanel() {
<SettingsRow
{...searchableSetting("follow-up-behavior")}
description={
"Queue follow-ups while the agent runs or steer the current run. " +
"Queue follow-ups until the turn ends or steer the current turn. " +
(settings.sendShortcut === "mod-enter-multiline"
? `Press ${modifierLabel} + Enter for single-line prompts or ${modifierLabel} + Shift + Enter for multiline prompts to do the opposite for one message.`
: `Press ${modifierLabel}${settings.sendShortcut === "mod-enter" ? " + Shift" : ""} + Enter to do the opposite for one message.`)
Expand Down
Loading
Loading