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:
@@ -187,6 +187,28 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
// it was created with — `queuedRef` would be right but `slashCommands`/`thinkingLevel` would be stale.
|
// it was created with — `queuedRef` would be right but `slashCommands`/`thinkingLevel` would be stale.
|
||||||
drainQueueRef.current = drainQueue;
|
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 handleSend = async () => {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -297,6 +319,12 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
if (ev.key === 'ArrowDown') return recallPrompt(ev, 1);
|
if (ev.key === 'ArrowDown') return recallPrompt(ev, 1);
|
||||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||||
ev.preventDefault();
|
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();
|
handleSend();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -454,6 +482,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleEscape,
|
handleEscape,
|
||||||
queued,
|
queued,
|
||||||
|
sendQueuedNow,
|
||||||
unqueue: (id: string) => setQueue(queuedRef.current.filter((item) => item.id !== id)),
|
unqueue: (id: string) => setQueue(queuedRef.current.filter((item) => item.id !== id)),
|
||||||
appendToInput,
|
appendToInput,
|
||||||
attachments: attachmentManager.attachments,
|
attachments: attachmentManager.attachments,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
setThinkingLevel,
|
setThinkingLevel,
|
||||||
queued,
|
queued,
|
||||||
|
sendQueuedNow,
|
||||||
unqueue,
|
unqueue,
|
||||||
} = manager;
|
} = manager;
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
|
|
||||||
<BackgroundTaskTray messages={messages} />
|
<BackgroundTaskTray messages={messages} />
|
||||||
|
|
||||||
<QueuedList queued={queued} onRemove={unqueue} />
|
<QueuedList queued={queued} onRemove={unqueue} onSendNow={sendQueuedNow} />
|
||||||
|
|
||||||
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
|
<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.
|
* composer empties and nothing else changes, which reads exactly like the message having been lost.
|
||||||
* Each is removable right up until it goes.
|
* 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;
|
if (queued.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex flex-col gap-1">
|
<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) => (
|
{queued.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user