send a queued prompt into the turn that is already running

The agent's input is a streaming iterable, and a message pushed onto it mid-turn is picked up at the
next step boundary — the running turn reads it and carries on with the added context. Verified rather
than assumed: a probe pushed a sentinel four seconds into a turn busy with `sleep` calls, and that
turn's own final answer quoted the late instruction and obeyed it. One turn, one result, nothing
interrupted.

So the design this was heading for — stop the turn, then re-send the message wrapped in "please
continue, but…" — is not needed. Nothing is abandoned mid-flight, no tool call dies half-applied, and
the agent is never told to stop something it was part-way through.

Enter on an empty composer delivers the queue now instead of waiting for the turn to end. That keystroke
was free: handleSend has always returned immediately on empty input. The queue still fills and still
shows as it did, so the default behaviour is unchanged — this is the impatient path, not a replacement.

Officer needed nothing: handleChat already pushes onto the live session rather than opening a new one
whenever `_claudeKill` is set, which is exactly the injection. The only thing in the way was the
client's own refusal to send while generating.

The affordance is stated above the queue because the keystroke is otherwise undiscoverable — Enter on an
empty box has never done anything, so nobody would try it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 02:08:37 +01:00
co-authored by Claude Opus 5
parent 928eaebe2c
commit 8d94d08d19
2 changed files with 50 additions and 2 deletions
@@ -187,6 +187,28 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
// it was created with — `queuedRef` would be right but `slashCommands`/`thinkingLevel` would be stale.
drainQueueRef.current = drainQueue;
/**
* Deliver the queue INTO the running turn instead of waiting for it to end.
*
* The agent's input is a streaming iterable, and a message pushed onto it mid-turn is picked up at the
* next step boundary — the running turn reads it and carries on with the added context. That was worth
* verifying rather than assuming: a probe pushed a sentinel four seconds into a turn busy with `sleep`
* calls, and the turn's own final answer quoted the late instruction and obeyed it. One turn, one
* result.
*
* So this needs no interrupt and no "carry on, but…" preamble wrapped around your words. Nothing is
* abandoned mid-flight, and the agent is never told to stop something it was part-way through.
*
* The path is the same `deliverBatch` the drain uses; officer pushes onto the live session rather than
* opening a new one whenever `_claudeKill` is set, which is exactly the injection.
*/
const sendQueuedNow = async () => {
if (queuedRef.current.length === 0) return;
const batch = queuedRef.current;
setQueue([]);
await deliverBatch(batch);
};
const handleSend = async () => {
const text = input.trim();
if (!text) return;
@@ -297,6 +319,12 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
if (ev.key === 'ArrowDown') return recallPrompt(ev, 1);
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
// Enter on an EMPTY composer with something queued means "don't wait — say it now". The keystroke
// was free: `handleSend` has always returned immediately on empty input, so nothing is displaced.
if (!input.trim() && queuedRef.current.length > 0) {
void sendQueuedNow();
return;
}
handleSend();
}
};
@@ -454,6 +482,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
handleKeyDown,
handleEscape,
queued,
sendQueuedNow,
unqueue: (id: string) => setQueue(queuedRef.current.filter((item) => item.id !== id)),
appendToInput,
attachments: attachmentManager.attachments,
@@ -39,6 +39,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
thinkingLevel,
setThinkingLevel,
queued,
sendQueuedNow,
unqueue,
} = manager;
@@ -65,7 +66,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
<BackgroundTaskTray messages={messages} />
<QueuedList queued={queued} onRemove={unqueue} />
<QueuedList queued={queued} onRemove={unqueue} onSendNow={sendQueuedNow} />
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
@@ -205,11 +206,29 @@ function useImageDrop(attachImage: (file: File) => void) {
* composer empties and nothing else changes, which reads exactly like the message having been lost.
* Each is removable right up until it goes.
*/
const QueuedList = ({ queued, onRemove }: { queued: QueuedPrompt[]; onRemove: (id: string) => void }) => {
const QueuedList = ({
queued,
onRemove,
onSendNow,
}: {
queued: QueuedPrompt[];
onRemove: (id: string) => void;
onSendNow: () => void;
}) => {
if (queued.length === 0) return null;
return (
<div className="mb-2 flex flex-col gap-1">
{/* The keystroke is otherwise undiscoverable — Enter on an empty composer has never done anything,
so nobody would try it. Stated once above the queue rather than on every row. */}
<button
type="button"
onClick={onSendNow}
title="Send these into the turn that is already running"
className="cursor-pointer self-start rounded px-1 text-[11px] text-amber-700/80 transition-colors hover:bg-amber-500/10 hover:text-amber-700"
>
Send now press Enter on an empty box to add {queued.length === 1 ? 'this' : 'these'} to the running turn
</button>
{queued.map((item) => (
<div
key={item.id}