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) => (