From 8d94d08d19c18fc7e26b7f181f1c9c812d908906 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 02:08:37 +0100 Subject: [PATCH] send a queued prompt into the turn that is already running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Chat/EmbeddableChat/useEmbeddableChat.ts | 29 +++++++++++++++++++ .../src/apps/Chat/components/InputArea.tsx | 23 +++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 3b1e8cd8..6fddaead 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -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, diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index edf6a2ad..5d900e03 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -39,6 +39,7 @@ export const InputArea = ({ manager }: InputAreaProps) => { thinkingLevel, setThinkingLevel, queued, + sendQueuedNow, unqueue, } = manager; @@ -65,7 +66,7 @@ export const InputArea = ({ manager }: InputAreaProps) => { - + @@ -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 (
+ {/* 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. */} + {queued.map((item) => (