From 87927a4bb022e894123bf6b61331c57bb7e605dd Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Sun, 9 Aug 2026 05:48:19 +0100 Subject: [PATCH] deliver the whole queue as one message, not one turn each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queued prompts are almost always one thought arriving in pieces — a correction, then the thing you forgot. Answering them one turn at a time made the agent reply to the first without knowing the second existed, then re-answer once it did. Joined with a blank line between, in the order written, which is how they read anyway. The tray is unchanged: they stay separate rows, each removable right up until they go. What merges is the delivery, not the queue. Only a lone prompt can still be a slash command. Joined to anything else it is text that happens to start with a slash, and running it as a command would silently drop everything queued behind it. The drain now takes the whole queue at once, so it loops twice at most — again only if the batch was a handled command and something arrived while it ran. Co-Authored-By: Claude Opus 5 --- .../Chat/EmbeddableChat/useEmbeddableChat.ts | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 48a5a2c2..3b1e8cd8 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -122,16 +122,28 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp }; /** - * Hand one prompt to the agent. Returns whether it actually started a turn — a slash command the client - * handled itself (`/clear`, `/model`) never reaches the agent, so the drain below must keep going - * rather than wait for a completion that will not come. + * Hand a batch to the agent as ONE message. Returns whether it actually started a turn — a slash + * command the client handled itself (`/clear`, `/model`) never reaches the agent, so the drain below + * must keep going rather than wait for a completion that will not come. * - * Attachments are captured when the prompt is composed, not here, so a queued message keeps the files - * that were on it rather than picking up whatever happens to be attached when its turn arrives. + * Several queued prompts become one message rather than one turn each. They are almost always one + * thought arriving in pieces — a correction, then the thing you forgot — and answering them separately + * makes the agent reply to the first without knowing the second exists, then re-answer with it. Joined + * with a blank line between, in the order they were written, which is how they read. + * + * Attachments are captured when each prompt is composed, not here, so a queued message keeps the files + * that were on it; the batch carries all of them together. */ - const deliverPrompt = async (item: QueuedPrompt): Promise => { - const text = item.raw.trim(); - if (text.startsWith('/')) { + const deliverBatch = async (items: QueuedPrompt[]): Promise => { + const text = items + .map((item) => item.raw.trim()) + .filter(Boolean) + .join('\n\n'); + if (!text) return false; + + // Only a lone prompt can be a slash command. Joined to anything else it is text that happens to start + // with a slash, and running it as a command would silently drop everything queued behind it. + if (items.length === 1 && text.startsWith('/')) { const result = await slashCommands.execute(text); if (result.handled) { setCommandFeedback(result.feedback); @@ -140,15 +152,19 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp } setCommandFeedback(null); + const prefix = items.map((item) => item.prefix).join(''); + const ids = items.flatMap((item) => item.ids); + const images = items.flatMap((item) => item.images); + let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text; - if (item.prefix) prompt = `${item.prefix}${prompt}`; + if (prefix) prompt = `${prefix}${prompt}`; // Send the cwd on every message (not just the first): OpenCode rebuilds its working-directory // system prompt each turn, so it needs the cwd every time. It stays constant for a session. sendPrompt( prompt, - !sessionId && item.ids.length > 0 ? item.ids : undefined, - item.images.length > 0 ? item.images : undefined, + !sessionId && ids.length > 0 ? ids : undefined, + images.length > 0 ? images : undefined, cwd, undefined, thinkingLevel, @@ -158,11 +174,13 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp return true; }; + // Takes the whole queue at once, so the loop runs twice at most: again only if the batch turned out to + // be a handled slash command (no turn started) and something was queued while it ran. const drainQueue = async () => { while (queuedRef.current.length > 0) { - const [next, ...rest] = queuedRef.current; - setQueue(rest); - if (await deliverPrompt(next!)) return; // a turn is running; its completion drains the rest + const batch = queuedRef.current; + setQueue([]); + if (await deliverBatch(batch)) return; } }; // The completion effect is declared above this, and must call the current one rather than the closure @@ -200,7 +218,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp setQueue([...queuedRef.current, item]); return; } - await deliverPrompt(item); + await deliverBatch([item]); }; /**