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
* 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<boolean> => {
const text = item.raw.trim();
if (text.startsWith('/')) {
const deliverBatch = async (items: QueuedPrompt[]): Promise<boolean> => {
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]);
};
/**