diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index f0251e41..48a5a2c2 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -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([]); + const [queued, setQueued] = useState([]); + const setQueue = (next: QueuedPrompt[]) => { + queuedRef.current = next; + setQueued(next); + }; + const drainQueueRef = useRef<() => Promise>(async () => {}); + const [input, setInput] = useState(defaultInput); const [showJumpToBottom, setShowJumpToBottom] = useState(false); const [commandFeedback, setCommandFeedback] = useState(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 => { + 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, diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index 05bd5112..953caa2b 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -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) => { + +
@@ -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 && ( - ) : ( - )} +
{ // ── 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 ( +
+ {queued.map((item) => ( +
+ + {item.raw.trim()} + +
+ ))} +
+ ); +}; + type MicButtonProps = { recording: boolean; transcribing: boolean;