deliver the whole queue as one message, not one turn each

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 05:48:19 +01:00
co-authored by Claude Opus 5
parent 856a63b840
commit 87927a4bb0
@@ -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 * Hand a batch to the agent as ONE message. Returns whether it actually started a turn — a slash
* handled itself (`/clear`, `/model`) never reaches the agent, so the drain below must keep going * command the client handled itself (`/clear`, `/model`) never reaches the agent, so the drain below
* rather than wait for a completion that will not come. * 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 * Several queued prompts become one message rather than one turn each. They are almost always one
* that were on it rather than picking up whatever happens to be attached when its turn arrives. * 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<boolean> => { const deliverBatch = async (items: QueuedPrompt[]): Promise<boolean> => {
const text = item.raw.trim(); const text = items
if (text.startsWith('/')) { .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); const result = await slashCommands.execute(text);
if (result.handled) { if (result.handled) {
setCommandFeedback(result.feedback); setCommandFeedback(result.feedback);
@@ -140,15 +152,19 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
} }
setCommandFeedback(null); 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; 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 // 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. // system prompt each turn, so it needs the cwd every time. It stays constant for a session.
sendPrompt( sendPrompt(
prompt, prompt,
!sessionId && item.ids.length > 0 ? item.ids : undefined, !sessionId && ids.length > 0 ? ids : undefined,
item.images.length > 0 ? item.images : undefined, images.length > 0 ? images : undefined,
cwd, cwd,
undefined, undefined,
thinkingLevel, thinkingLevel,
@@ -158,11 +174,13 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
return true; 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 () => { const drainQueue = async () => {
while (queuedRef.current.length > 0) { while (queuedRef.current.length > 0) {
const [next, ...rest] = queuedRef.current; const batch = queuedRef.current;
setQueue(rest); setQueue([]);
if (await deliverPrompt(next!)) return; // a turn is running; its completion drains the rest if (await deliverBatch(batch)) return;
} }
}; };
// The completion effect is declared above this, and must call the current one rather than the closure // 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]); setQueue([...queuedRef.current, item]);
return; return;
} }
await deliverPrompt(item); await deliverBatch([item]);
}; };
/** /**