queue prompts written while a turn is running
Send no longer refuses mid-turn. The prompt goes into a queue, and each turn's completion delivers the next — one per turn, which is the whole drain loop. A slash command the client handles itself never starts a turn, so delivery reports whether it did and the drain keeps going rather than waiting for a completion that will not come. Attachments are captured when the prompt is composed, not when it is delivered, so a queued message keeps the files it was written with instead of picking up whatever is in the tray when its turn arrives. The composer empties on queue as it does on send — a box that stayed full would read as "it didn't take", and you would send it twice. The send button turns amber with a different icon to say the press will not go anywhere yet, and sits BESIDE stop rather than replacing it: typing a follow-up should not cost you the ability to interrupt. A tray above the composer lists what is waiting, each item removable — without it a queued prompt is invisible until its turn, which looks exactly like having lost it. Stop clears the queue. Ending a turn is precisely the signal the drain waits for, so leaving it alone fired the next prompt the instant you pressed the button meant to halt things. Nothing is lost: a queued prompt was recorded in the prompt history when it was written, so Up brings it back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,16 @@ import { useAttachments } from '../useAttachments';
|
||||
import { useSlashCommands } from '../useSlashCommands';
|
||||
import { usePromptHistory } from './usePromptHistory';
|
||||
|
||||
/** A prompt written mid-turn, held with the attachments it was composed with. */
|
||||
export type QueuedPrompt = {
|
||||
id: string;
|
||||
/** Exactly what was typed, untrimmed — what the tray shows and what gets delivered. */
|
||||
raw: string;
|
||||
prefix: string;
|
||||
ids: string[];
|
||||
images: { filename: string; dataUrl: string }[];
|
||||
};
|
||||
|
||||
type UseEmbeddableChatParams = {
|
||||
sessionId?: string;
|
||||
initialModel?: string | null;
|
||||
@@ -70,9 +80,23 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
} else if (wasGeneratingRef.current) {
|
||||
wasGeneratingRef.current = false;
|
||||
onMessageComplete?.();
|
||||
// The turn that was blocking the queue has ended, so the next prompt goes now. Each delivery starts
|
||||
// a turn, whose completion runs this again — that is the whole drain loop, one prompt per turn.
|
||||
void drainQueueRef.current();
|
||||
}
|
||||
}, [isGenerating]);
|
||||
|
||||
// Prompts written while a turn was running, waiting their turn. The ref is what the drain reads — it
|
||||
// runs from an effect and from async continuations, both of which would otherwise see the array as it
|
||||
// was a render ago — and the state is only so the tray can show them.
|
||||
const queuedRef = useRef<QueuedPrompt[]>([]);
|
||||
const [queued, setQueued] = useState<QueuedPrompt[]>([]);
|
||||
const setQueue = (next: QueuedPrompt[]) => {
|
||||
queuedRef.current = next;
|
||||
setQueued(next);
|
||||
};
|
||||
const drainQueueRef = useRef<() => Promise<void>>(async () => {});
|
||||
|
||||
const [input, setInput] = useState(defaultInput);
|
||||
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
||||
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
|
||||
@@ -93,45 +117,90 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
const isLoadingOlderRef = useRef(isLoadingOlder);
|
||||
isLoadingOlderRef.current = isLoadingOlder;
|
||||
|
||||
const resetComposerHeight = () => {
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const deliverPrompt = async (item: QueuedPrompt): Promise<boolean> => {
|
||||
const text = item.raw.trim();
|
||||
if (text.startsWith('/')) {
|
||||
const result = await slashCommands.execute(text);
|
||||
if (result.handled) {
|
||||
setCommandFeedback(result.feedback);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setCommandFeedback(null);
|
||||
|
||||
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
|
||||
if (item.prefix) prompt = `${item.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,
|
||||
cwd,
|
||||
undefined,
|
||||
thinkingLevel,
|
||||
promptPrefix ? text : undefined,
|
||||
);
|
||||
userScrolledRef.current = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
// The completion effect is declared above this, and must call the current one rather than the closure
|
||||
// it was created with — `queuedRef` would be right but `slashCommands`/`thinkingLevel` would be stale.
|
||||
drainQueueRef.current = drainQueue;
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isGenerating) return;
|
||||
if (!text) return;
|
||||
|
||||
// Handle slash commands
|
||||
if (text.startsWith('/')) {
|
||||
// A slash command typed while nothing is running still executes immediately, and deliberately before
|
||||
// attachments are touched: `/clear` should not eat the files sitting in the tray.
|
||||
if (!isGenerating && text.startsWith('/')) {
|
||||
const result = await slashCommands.execute(text);
|
||||
if (result.handled) {
|
||||
setCommandFeedback(result.feedback);
|
||||
history.record(input); // a slash command is a prompt you sent — recallable like any other
|
||||
setInput('');
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
resetComposerHeight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCommandFeedback(null);
|
||||
|
||||
const { prefix, ids, images } = attachmentManager.processAttachments();
|
||||
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
|
||||
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.
|
||||
const displayText = promptPrefix ? text : undefined;
|
||||
sendPrompt(
|
||||
prompt,
|
||||
!sessionId && ids.length > 0 ? ids : undefined,
|
||||
images.length > 0 ? images : undefined,
|
||||
cwd,
|
||||
undefined,
|
||||
thinkingLevel,
|
||||
displayText,
|
||||
);
|
||||
const item: QueuedPrompt = { id: `${Date.now()}-${queuedRef.current.length}`, raw: input, prefix, ids, images };
|
||||
|
||||
// The composer empties either way. Queuing that did not clear the box would read as "it didn't take",
|
||||
// and you would send it twice.
|
||||
attachmentManager.clearAttachments();
|
||||
history.record(input); // verbatim, not the trimmed prompt or the attachment-prefixed one
|
||||
setInput('');
|
||||
userScrolledRef.current = false;
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
resetComposerHeight();
|
||||
|
||||
if (isGenerating) {
|
||||
setQueue([...queuedRef.current, item]);
|
||||
return;
|
||||
}
|
||||
await deliverPrompt(item);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -148,6 +217,11 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
*/
|
||||
const stopGeneration = () => {
|
||||
stopTurn();
|
||||
// Stop means stop, including whatever was lined up behind this turn. Ending a turn is exactly the
|
||||
// signal the drain waits for, so leaving the queue alone would have fired the next prompt the instant
|
||||
// you pressed the button meant to halt things. Nothing is really lost — a queued prompt was recorded
|
||||
// in the prompt history when it was written, so Up brings it straight back.
|
||||
setQueue([]);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
};
|
||||
|
||||
@@ -361,6 +435,8 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
handleSend,
|
||||
handleKeyDown,
|
||||
handleEscape,
|
||||
queued,
|
||||
unqueue: (id: string) => setQueue(queuedRef.current.filter((item) => item.id !== id)),
|
||||
appendToInput,
|
||||
attachments: attachmentManager.attachments,
|
||||
attachWebpage: attachmentManager.attachWebpage,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Mic, Send, Square } from 'lucide-react';
|
||||
import { Loader2, Mic, Send, Square, ListPlus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||
import { useAudioRecording } from '../useAudioRecording';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
@@ -37,6 +37,8 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
appendToInput,
|
||||
thinkingLevel,
|
||||
setThinkingLevel,
|
||||
queued,
|
||||
unqueue,
|
||||
} = manager;
|
||||
|
||||
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
|
||||
@@ -50,6 +52,8 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
|
||||
<BackgroundTaskTray messages={messages} />
|
||||
|
||||
<QueuedList queued={queued} onRemove={unqueue} />
|
||||
|
||||
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
|
||||
|
||||
<div className="flex items-end gap-1 md:gap-2">
|
||||
@@ -81,25 +85,32 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
rows={1}
|
||||
className="min-w-0 flex-1 resize-none rounded-lg border border-input bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
/>
|
||||
{isGenerating ? (
|
||||
{/* Stop stays put while a turn runs, and the send button sits beside it rather than replacing it:
|
||||
both are things you might want mid-turn, and swapping one for the other would mean typing a
|
||||
follow-up cost you the ability to interrupt. Amber and a different icon say the press will not
|
||||
go anywhere yet — the same amber the queued tray above uses. */}
|
||||
{isGenerating && (
|
||||
<Button
|
||||
onClick={stopGeneration}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
title="Stop"
|
||||
className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
size="icon"
|
||||
className="shrink-0 h-7 w-7 md:h-9 md:w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
size="icon"
|
||||
title={isGenerating ? 'Queue — sends when the current turn finishes' : 'Send'}
|
||||
className={`shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer disabled:opacity-40 ${
|
||||
isGenerating ? 'bg-amber-500 hover:bg-amber-500/90 text-amber-950' : 'bg-duck-teal hover:bg-duck-teal/90'
|
||||
}`}
|
||||
>
|
||||
{isGenerating ? <ListPlus className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<ModelSelector
|
||||
messages={messages}
|
||||
@@ -121,6 +132,37 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
/**
|
||||
* What is waiting to be sent. Without this a queued prompt is invisible until its turn comes — the
|
||||
* 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 }) => {
|
||||
if (queued.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-col gap-1">
|
||||
{queued.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1"
|
||||
>
|
||||
<ListPlus className="h-3 w-3 shrink-0 text-amber-600" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-foreground/80">{item.raw.trim()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(item.id)}
|
||||
title="Remove from queue"
|
||||
className="shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-black/10 hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type MicButtonProps = {
|
||||
recording: boolean;
|
||||
transcribing: boolean;
|
||||
|
||||
Reference in New Issue
Block a user