diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..c7286853866 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -158,7 +158,10 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { resolveComposerSendLabel } from "~/outbox/composerSendLabel.logic"; +import { enqueueThreadOutboxMessage, useThreadOutboxQueue } from "~/outbox/threadOutbox"; +import { ThreadOutboxQueueList } from "./chat/ThreadOutboxQueueList"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; @@ -2012,6 +2015,21 @@ function ChatViewContent(props: ChatViewProps) { threadError, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + // Thread outbox ("queue"): submitting while the thread is busy/disconnected + // enqueues instead of erroring, and the drain sends the head once settled. + const outboxQueue = useThreadOutboxQueue(activeThread?.environmentId ?? null, activeThreadId); + const composerActiveThreadBusy = phase === "running" || isSendBusy || isRevertingCheckpoint; + const composerSendLabel = resolveComposerSendLabel({ + connectionState: activeEnvironmentConnectionPhase, + activeThreadBusy: composerActiveThreadBusy, + queueCount: outboxQueue.length, + }); + const composerQueueMode = + isServerThread && + !isLocalDraftThread && + !activePendingProgress && + !showPlanFollowUpPrompt && + composerSendLabel === "Queue"; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -4434,8 +4452,135 @@ function ChatViewContent(props: ChatViewProps) { ], ); + // Enqueue the current composer content into the thread outbox. Text-only: + // terminal/element/preview/review contexts are folded into the prompt like a + // normal send, but image attachments cannot survive a reload through + // localStorage and are not carried on a queued message. + const handleQueueComposerSubmission = useCallback(async () => { + if (!activeThread || !activeProject) return; + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx?.providerAvailable) return; + const { + images: composerImages, + terminalContexts: composerTerminalContexts, + elementContexts: composerElementContexts, + previewAnnotations: composerPreviewAnnotations, + reviewComments: composerReviewComments, + selectedProvider: ctxSelectedProvider, + selectedModel: ctxSelectedModel, + selectedProviderModels: ctxSelectedProviderModels, + selectedPromptEffort: ctxSelectedPromptEffort, + selectedModelSelection: ctxSelectedModelSelection, + } = sendCtx; + const promptForSend = promptRef.current; + const { sendableTerminalContexts, hasSendableContent } = deriveComposerSendState({ + prompt: promptForSend, + imageCount: composerImages.length, + terminalContexts: composerTerminalContexts, + elementContextCount: + composerElementContexts.length + + composerPreviewAnnotations.length + + composerReviewComments.length, + }); + if (!hasSendableContent) return; + + const messageTextWithContexts = appendElementContextsToPrompt( + appendTerminalContextsToPrompt(promptForSend, sendableTerminalContexts), + composerElementContexts, + ); + const messageTextWithPreviewAnnotations = composerPreviewAnnotations.reduce( + (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), + messageTextWithContexts, + ); + const messageTextForSend = appendReviewCommentsToPrompt( + messageTextWithPreviewAnnotations, + composerReviewComments, + ); + // The queue is text-only. If the composer holds nothing but image + // attachments (no text and no non-image context survived), there is nothing + // we can faithfully queue: enqueuing the image-only bootstrap prompt would + // deliver a message claiming images are attached while carrying none, and + // clearing the draft would destroy the image. Warn and leave the composer + // intact so the user can add text, or wait for the thread to idle and send. + if (messageTextForSend.trim().length === 0) { + if (composerImages.length > 0) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Images can't be queued", + description: + "Queued messages are text-only. Add a message to queue, or wait until the chat is idle to send with the image.", + }), + ); + } + return; + } + + const queuedText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: messageTextForSend, + }); + + try { + await enqueueThreadOutboxMessage({ + environmentId: activeThread.environmentId, + threadId: activeThread.id, + messageId: newMessageId(), + commandId: newCommandId(), + text: queuedText, + modelSelection: ctxSelectedModelSelection, + runtimeMode, + interactionMode, + createdAt: new Date().toISOString(), + }); + } catch (error) { + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to queue message.", + ); + return; + } + + // Warn about dropped images only after the text message actually entered the + // queue, so a failed enqueue never misinforms the user that images were + // "not added" when nothing was queued at all. + if (composerImages.length > 0) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Images aren't queued", + description: + "Queued messages are text-only; attached images were not added to the queued message.", + }), + ); + } + + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + }, [ + activeProject, + activeThread, + clearComposerDraftContent, + composerDraftTarget, + composerRef, + interactionMode, + promptRef, + runtimeMode, + setThreadError, + ]); + const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); + // Queue mode routes the submit through the thread outbox instead of the + // immediate-send path (which is gated on an idle, connected thread). + if (composerQueueMode) { + await handleQueueComposerSubmission(); + return; + } if ( !activeThread || isSendBusy || @@ -5782,6 +5927,10 @@ function ChatViewContent(props: ChatViewProps) { >
+ void; onInterrupt: () => void; @@ -445,6 +447,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable={props.isEnvironmentUnavailable} isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} + sendLabel={props.sendLabel} + canQueue={props.canQueue} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} @@ -507,6 +511,8 @@ export interface ChatComposerProps { routeKind: "server" | "draft"; routeThreadRef: ScopedThreadRef; draftId: DraftId | null; + /** Primary-action label: "Queue" when a submit will enqueue, else "Send". */ + sendLabel: "Send" | "Queue"; // Thread context activeThreadId: ThreadId | null; @@ -622,11 +628,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) routeKind, routeThreadRef, draftId, + sendLabel, activeThreadId, activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, - isServerThread: _isServerThread, - isLocalDraftThread: _isLocalDraftThread, + isServerThread, + isLocalDraftThread, forceExpandedOnMobile, projectSelectionRequired, phase, @@ -841,6 +848,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [providerInstanceEntries, selectedInstanceId], ); const noProviderAvailable = selectedProviderEntry === undefined; + // Queuing is only supported for existing server threads with a usable + // provider and project; the primary action shows "Queue" and moves Stop to a + // secondary control only under these conditions. + const canQueueSubmit = + isServerThread && !isLocalDraftThread && !noProviderAvailable && !projectSelectionRequired; // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. @@ -2732,6 +2744,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} + sendLabel={canQueueSubmit ? sendLabel : "Send"} + canQueue={canQueueSubmit} preserveComposerFocusOnPointerDown={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 3e664e226b7..4855def635b 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -25,6 +25,14 @@ interface ComposerPrimaryActionsProps { isEnvironmentUnavailable: boolean; isPreparingWorktree: boolean; hasSendableContent: boolean; + /** "Queue" when a submit will enqueue to the thread outbox, else "Send". */ + sendLabel?: "Send" | "Queue"; + /** + * Whether queuing is available for this thread. When true and the thread is + * running, the primary action becomes a "Queue" submit and Stop moves to a + * secondary control instead of being the sole primary action. + */ + canQueue?: boolean; preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; @@ -64,6 +72,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isEnvironmentUnavailable, isPreparingWorktree, hasSendableContent, + sendLabel = "Send", + canQueue = false, preserveComposerFocusOnPointerDown = false, onPreviousPendingQuestion, onInterrupt, @@ -74,6 +84,32 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : undefined; const stageBackdropVariant = useSidebarStageBackdropVariant(); + const stopButton = ( + + ); + + const queueButton = ( + + ); + if (pendingAction) { return (
@@ -126,19 +162,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } if (isRunning) { - return ( - - ); + // While the thread is busy, queuing (when available) becomes the primary + // action and Stop moves beside it as a secondary control. + if (canQueue) { + return ( +
+ {stopButton} + {queueButton} +
+ ); + } + return stopButton; } if (showPlanFollowUpPrompt) { @@ -195,6 +229,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } + // Idle but the submit will still enqueue (outbox non-empty, or disconnected): + // surface an explicit "Queue" control so Return/click reads as queuing. + if (sendLabel === "Queue" && canQueue) { + return queueButton; + } + return (
+ ); +} + +function QueuedMessageRow({ message }: { message: QueuedThreadMessage }) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(message.text); + const [busy, setBusy] = useState(false); + const messageIdRef = useRef(message.messageId); + + // Keep the local draft aligned with external updates while not editing. + useEffect(() => { + if (!isEditing) { + setDraft(message.text); + } + }, [isEditing, message.text]); + + // Release the editing hold if the row unmounts mid-edit (e.g. delivered). + useEffect(() => { + const id = messageIdRef.current; + return () => { + releaseEditingThreadOutboxMessage(id); + }; + }, []); + + const beginEditing = useCallback(() => { + holdEditingThreadOutboxMessage(message.messageId); + setDraft(message.text); + setIsEditing(true); + }, [message.messageId, message.text]); + + const cancelEditing = useCallback(() => { + setIsEditing(false); + setDraft(message.text); + releaseEditingThreadOutboxMessage(message.messageId); + }, [message.messageId, message.text]); + + const saveEditing = useCallback(async () => { + const nextText = draft.trim(); + if (nextText.length === 0 || nextText === message.text) { + cancelEditing(); + return; + } + setBusy(true); + try { + await updateThreadOutboxMessage({ ...message, text: nextText }); + } finally { + setBusy(false); + setIsEditing(false); + releaseEditingThreadOutboxMessage(message.messageId); + } + }, [cancelEditing, draft, message]); + + const remove = useCallback(async () => { + setBusy(true); + try { + await removeThreadOutboxMessage(message); + } finally { + setBusy(false); + } + }, [message]); + + if (isEditing) { + return ( +
  • +