diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a2708de8bda3..ef076897f367 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -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 }), @@ -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, @@ -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, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 528024de4393..50056539ba18 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1207,7 +1207,6 @@ export interface LocalDispatchSnapshot { latestTurnStartedAt: string | null; latestTurnCompletedAt: string | null; sessionStatus: NonNullable["status"] | null; - sessionUpdatedAt: string | null; latestTurnStartFailureId: string | null; } @@ -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), }; } @@ -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) ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index db13c8f21c80..958b7d9b7d6c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -325,7 +325,6 @@ import { terminalContextReference, } from "../lib/composerContextRecords"; import { - latestCompletedToolActivityId, type QueuedComposerMessage, type QueuedMessageSendSettings, useQueuedMessages, @@ -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(), @@ -7686,7 +7684,6 @@ export default function ChatView(props: ChatViewProps) { previewAnnotations: [...composerPreviewAnnotations], reviewComments: [...composerReviewComments], sendSettings, - queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), createdAt: new Date().toISOString(), }); promptRef.current = ""; diff --git a/apps/web/src/components/QueuedMessageSender.test.tsx b/apps/web/src/components/QueuedMessageSender.test.tsx index dc31694f8b96..75bdbd45ad63 100644 --- a/apps/web/src/components/QueuedMessageSender.test.tsx +++ b/apps/web/src/components/QueuedMessageSender.test.tsx @@ -73,7 +73,6 @@ function enqueue(overrides: Partial = {}) { interactionMode: "default", promptEffort: null, }, - queuedAfterToolActivityId: null, createdAt: "2026-09-25T00:00:00Z", ...overrides, }); @@ -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) => ({ @@ -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 = () => @@ -144,7 +147,7 @@ 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"); @@ -152,11 +155,15 @@ describe("QueuedMessageSender", () => { 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"]); }); diff --git a/apps/web/src/components/QueuedMessageSender.tsx b/apps/web/src/components/QueuedMessageSender.tsx index 86dfa8a265fe..0ee81b090987 100644 --- a/apps/web/src/components/QueuedMessageSender.tsx +++ b/apps/web/src/components/QueuedMessageSender.tsx @@ -6,7 +6,6 @@ import { useShallow } from "zustand/react/shallow"; import { useComposerDraftStore } from "../composerDraftStore"; import { isQueuedMessageDue, - latestCompletedToolActivityId, useQueuedMessageStore, useQueuedMessages, } from "../queuedMessageStore"; @@ -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]); @@ -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); @@ -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; diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 76ea907b84cd..ec7a9546a432 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -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)} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d95e22517630..4a51016e7a3f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1106,7 +1106,6 @@ describe("deriveMessagesTimelineRows", () => { interactionMode: "default" as const, promptEffort: null, }, - queuedAfterToolActivityId: null, createdAt: "2026-01-01T00:00:01Z", }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8ac769f33e7f..af98f3302d09 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -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 (
diff --git a/apps/web/src/components/chat/sendQueuedMessage.ts b/apps/web/src/components/chat/sendQueuedMessage.ts index ac10dfef48c8..798b6370c663 100644 --- a/apps/web/src/components/chat/sendQueuedMessage.ts +++ b/apps/web/src/components/chat/sendQueuedMessage.ts @@ -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"; @@ -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]; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fc5082fcf1a4..51fb9750abc6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2733,7 +2733,7 @@ export function GeneralSettingsPanel() { { interactionMode: "default", promptEffort: null, }, - queuedAfterToolActivityId: null, createdAt: "2026-09-11T00:00:00.000Z", }; } @@ -46,27 +44,24 @@ describe("queuedMessageStore", () => { expect(queue("thread-b").map((message) => message.prompt)).toEqual(["other"]); }); - it("allows one send per thread and re-anchors the rest to the current tool boundary", () => { + it("allows one send per thread", () => { const { enqueue, beginSend } = useQueuedMessageStore.getState(); const first = enqueue("thread-a", makeMessage("first")); const second = enqueue("thread-a", makeMessage("second")); - expect(beginSend("thread-a", first.id, "tool-2")?.prompt).toBe("first"); - expect(beginSend("thread-a", first.id, "tool-2")).toBeNull(); - expect(beginSend("thread-a", second.id, "tool-2")).toBeNull(); + expect(beginSend("thread-a", first.id)?.prompt).toBe("first"); + expect(beginSend("thread-a", first.id)).toBeNull(); + expect(beginSend("thread-a", second.id)).toBeNull(); const [sending, waiting] = queue("thread-a"); expect(sending?.sending).toBe("preparing"); - expect(waiting?.queuedAfterToolActivityId).toBe("tool-2"); - expect( - isQueuedMessageDue({ message: waiting!, phase: "running", latestToolActivityId: "tool-2" }), - ).toBe(false); + expect(waiting?.sending).toBeUndefined(); }); it("finishSend drops the sent message", () => { const { enqueue, beginSend, finishSend } = useQueuedMessageStore.getState(); const first = enqueue("thread-a", makeMessage("first")); - beginSend("thread-a", first.id, null); + beginSend("thread-a", first.id); finishSend("thread-a", first.id); @@ -77,28 +72,26 @@ describe("queuedMessageStore", () => { const { enqueue, beginSend, failSend } = useQueuedMessageStore.getState(); enqueue("thread-a", makeMessage("first")); const second = enqueue("thread-a", makeMessage("second")); - beginSend("thread-a", second.id, null); + beginSend("thread-a", second.id); expect(failSend("thread-a", second.id)).toBe(true); const [head] = queue("thread-a"); expect(queue("thread-a").map((message) => message.prompt)).toEqual(["second", "first"]); expect(head?.sending).toBeUndefined(); - expect(isQueuedMessageDue({ message: head!, phase: "ready", latestToolActivityId: null })).toBe( - false, - ); + expect(isQueuedMessageDue({ message: head!, phase: "ready" })).toBe(false); }); - it("remove keeps the other messages' anchors and refuses a message being sent", () => { + it("remove keeps the other messages and refuses a message being sent", () => { const { enqueue, remove, beginSend } = useQueuedMessageStore.getState(); - const first = enqueue("thread-a", { ...makeMessage("first"), queuedAfterToolActivityId: "t1" }); + const first = enqueue("thread-a", makeMessage("first")); const second = enqueue("thread-a", makeMessage("second")); expect(remove("thread-a", second.id)?.prompt).toBe("second"); expect(remove("thread-a", second.id)).toBeNull(); expect(queue("thread-a")).toEqual([first]); - beginSend("thread-a", first.id, "t1"); + beginSend("thread-a", first.id); expect(remove("thread-a", first.id)).toBeNull(); }); @@ -110,17 +103,17 @@ describe("queuedMessageStore", () => { const second = enqueue("thread-a", makeMessage("second")); const third = enqueue("thread-a", makeMessage("third")); const lastDispatch = () => useQueuedMessageStore.getState().lastDispatchByThreadKey["thread-a"]; - beginSend("thread-a", first.id, null); + beginSend("thread-a", first.id); markDispatching("thread-a", first.id, earlier); finishSend("thread-a", first.id); // Fails before its turn start went out: the first send is still the one to wait on. - beginSend("thread-a", second.id, null); + beginSend("thread-a", second.id); failSend("thread-a", second.id); expect(lastDispatch()?.thread).toBe(earlier); // Fails after going out: it never reached the server, so the first still counts. - beginSend("thread-a", third.id, null); + beginSend("thread-a", third.id); markDispatching("thread-a", third.id, { ...earlier, startedAt: "later" }); failSend("thread-a", third.id); expect(lastDispatch()?.thread).toBe(earlier); @@ -131,7 +124,7 @@ describe("queuedMessageStore", () => { useQueuedMessageStore.getState(); const preparing = enqueue("thread-a", makeMessage("preparing")); enqueue("thread-a", makeMessage("waiting")); - beginSend("thread-a", preparing.id, null); + beginSend("thread-a", preparing.id); expect(drain("thread-a").map((message) => message.prompt)).toEqual(["preparing", "waiting"]); expect(markDispatching("thread-a", preparing.id, createLocalDispatchSnapshot(undefined))).toBe( @@ -141,7 +134,7 @@ describe("queuedMessageStore", () => { const dispatching = enqueue("thread-b", makeMessage("dispatching")); enqueue("thread-b", makeMessage("waiting")); - beginSend("thread-b", dispatching.id, null); + beginSend("thread-b", dispatching.id); expect( markDispatching("thread-b", dispatching.id, createLocalDispatchSnapshot(undefined)), ).toBe(true); @@ -152,43 +145,18 @@ describe("queuedMessageStore", () => { }); describe("queued message dispatch timing", () => { - const activities = [ - { id: "a1", kind: "tool.started", sequence: 1, createdAt: "2026-01-01T00:00:01Z" }, - { id: "a2", kind: "tool.completed", sequence: 2, createdAt: "2026-01-01T00:00:02Z" }, - { id: "a3", kind: "tool.updated", sequence: 3, createdAt: "2026-01-01T00:00:03Z" }, - ]; - - it("finds the newest completed tool call by sequence, not position", () => { - expect(latestCompletedToolActivityId(activities)).toBe("a2"); - expect(latestCompletedToolActivityId([])).toBeNull(); - expect( - latestCompletedToolActivityId([ - { id: "late", kind: "tool.completed", sequence: 9, createdAt: "2026-01-01T00:00:09Z" }, - { id: "early", kind: "tool.completed", sequence: 4, createdAt: "2026-01-01T00:00:04Z" }, - ]), - ).toBe("late"); + it("waits for the turn to end instead of sending mid-turn", () => { + expect(isQueuedMessageDue({ message: {}, phase: "running" })).toBe(false); }); - it("waits mid-turn until a tool call finishes after the message was queued", () => { - const message = { queuedAfterToolActivityId: "a2" }; - expect(isQueuedMessageDue({ message, phase: "running", latestToolActivityId: "a2" })).toBe( + it("never auto-sends a message held for user action", () => { + expect(isQueuedMessageDue({ message: { holdUntilUserAction: true }, phase: "ready" })).toBe( false, ); - expect(isQueuedMessageDue({ message, phase: "running", latestToolActivityId: "a4" })).toBe( - true, - ); - }); - - it("never auto-sends a message held for user action", () => { - const message = { queuedAfterToolActivityId: null, holdUntilUserAction: true }; - expect(isQueuedMessageDue({ message, phase: "ready", latestToolActivityId: "a4" })).toBe(false); }); it("is due as soon as the turn is over, but not while a send is connecting", () => { - const message = { queuedAfterToolActivityId: "a2" }; - expect(isQueuedMessageDue({ message, phase: "ready", latestToolActivityId: "a2" })).toBe(true); - expect(isQueuedMessageDue({ message, phase: "connecting", latestToolActivityId: "a4" })).toBe( - false, - ); + expect(isQueuedMessageDue({ message: {}, phase: "ready" })).toBe(true); + expect(isQueuedMessageDue({ message: {}, phase: "connecting" })).toBe(false); }); }); diff --git a/apps/web/src/queuedMessageStore.ts b/apps/web/src/queuedMessageStore.ts index 735cebf33b1c..d1265fe5f87a 100644 --- a/apps/web/src/queuedMessageStore.ts +++ b/apps/web/src/queuedMessageStore.ts @@ -39,12 +39,6 @@ export interface QueuedComposerMessage { previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; sendSettings: QueuedMessageSendSettings; - /** - * The newest completed tool activity at queue time. A different id later - * means a tool call finished after the user queued, which is the boundary - * the message goes out on. - */ - queuedAfterToolActivityId: string | null; /** * Set when the message was created by Stop or a failed restore, not by the * user pressing send. It waits for Send now instead of leaving on its own. @@ -78,14 +72,9 @@ interface QueuedMessageStoreState { enqueue: (threadKey: string, message: Omit) => QueuedComposerMessage; /** * Marks one message as sending and returns it, or null when it is gone or - * the thread already has a send under way. The other messages are - * re-anchored to `toolActivityId` so only one leaves per tool boundary. + * the thread already has a send under way. */ - beginSend: ( - threadKey: string, - id: string, - toolActivityId: string | null, - ) => QueuedComposerMessage | null; + beginSend: (threadKey: string, id: string) => QueuedComposerMessage | null; /** The turn start is going out. False when Stop took the message back first. */ markDispatching: (threadKey: string, id: string, thread: LocalDispatchSnapshot) => boolean; /** Drops a message whose send went out, or that had nothing left to send. */ @@ -137,18 +126,14 @@ export const useQueuedMessageStore = create()((set, get update(threadKey, [...queueOf(threadKey), entry]); return entry; }, - beginSend: (threadKey, id, toolActivityId) => { + beginSend: (threadKey, id) => { const queue = queueOf(threadKey); const entry = queue.find((message) => message.id === id); if (!entry || queue.some((message) => message.sending)) return null; update( threadKey, queue.map((message) => - message.id === id - ? { ...message, sending: "preparing" } - : message.queuedAfterToolActivityId === toolActivityId - ? message - : { ...message, queuedAfterToolActivityId: toolActivityId }, + message.id === id ? { ...message, sending: "preparing" } : message, ), ); return entry; @@ -218,47 +203,17 @@ export const useQueuedMessageStore = create()((set, get }); /** - * The newest finished tool call. Its id changing is the boundary a queued - * message goes out on. Live arrays are sorted, but a snapshot loaded from the - * database is not, so pick by sequence rather than position. - */ -export function latestCompletedToolActivityId( - activities: ReadonlyArray<{ - readonly id: string; - readonly kind: string; - readonly sequence?: number | undefined; - readonly createdAt: string; - }>, -): string | null { - let latest: (typeof activities)[number] | null = null; - for (const activity of activities) { - if (activity.kind !== "tool.completed") continue; - if ( - latest === null || - (activity.sequence ?? -1) > (latest.sequence ?? -1) || - ((activity.sequence ?? -1) === (latest.sequence ?? -1) && - activity.createdAt > latest.createdAt) - ) { - latest = activity; - } - } - return latest?.id ?? null; -} - -/** - * A queued message is due mid-turn once a tool call finished after it was - * queued, and as soon as the turn is over otherwise. "connecting" is the gap - * between a send and the provider picking it up, so nothing is due there. + * Queued messages wait for the turn to end, then the oldest starts a new turn. + * "connecting" is the gap between a send and the provider picking it up, so + * nothing is due there either. */ export function isQueuedMessageDue(input: { - message: Pick; + message: Pick; phase: "connecting" | "running" | "ready" | "disconnected"; - latestToolActivityId: string | null; }): boolean { - if (input.message.holdUntilUserAction) return false; - if (input.phase === "connecting") return false; - if (input.phase !== "running") return true; - return input.latestToolActivityId !== input.message.queuedAfterToolActivityId; + return ( + !input.message.holdUntilUserAction && input.phase !== "connecting" && input.phase !== "running" + ); } export function useQueuedMessages(threadKey: string): QueuedComposerMessage[] { diff --git a/docs/user/composer.md b/docs/user/composer.md index 7ee8edcbb121..405312446b6e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -35,9 +35,10 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin ## Send while the agent is working On web and desktop, a message sent during a running turn waits at the end of the conversation as a -dashed bubble. It goes out on its own when the agent finishes its next tool -call, or when the turn ends, even while you have another thread open. Use the arrow under the bubble to send it right -away, or the X to move it back into the composer. Stop returns every queued +dashed bubble. When the turn ends, the oldest queued message starts a new turn, +even while you have another thread open. Remaining messages wait for later +turns. Use the arrow under the bubble to send it right away, or the X to move it +back into the composer. Stop returns every queued message to the composer. In **Settings → General → Follow-up behavior**, choose **Queue** to keep this