Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 151 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -5782,13 +5927,18 @@ function ChatViewContent(props: ChatViewProps) {
>
<div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]">
<div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
<ThreadOutboxQueueList
environmentId={activeThread?.environmentId ?? null}
threadId={activeThreadId}
/>
<ChatComposer
composerRef={composerRef}
composerDraftTarget={composerDraftTarget}
environmentId={environmentId}
routeKind={routeKind}
routeThreadRef={routeThreadRef}
draftId={draftId}
sendLabel={composerSendLabel}
activeThreadId={activeThreadId}
activeThreadEnvironmentId={activeThread?.environmentId}
activeThread={activeThread}
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isConnecting: boolean;
isEnvironmentUnavailable: boolean;
hasSendableContent: boolean;
sendLabel: "Send" | "Queue";
canQueue: boolean;
preserveComposerFocusOnPointerDown?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand Down
66 changes: 53 additions & 13 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,6 +72,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
isEnvironmentUnavailable,
isPreparingWorktree,
hasSendableContent,
sendLabel = "Send",
canQueue = false,
preserveComposerFocusOnPointerDown = false,
onPreviousPendingQuestion,
onInterrupt,
Expand All @@ -74,6 +84,32 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
: undefined;
const stageBackdropVariant = useSidebarStageBackdropVariant();

const stopButton = (
<button
type="button"
className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none sm:h-8 sm:w-8"
{...pointerFocusProps}
onClick={onInterrupt}
aria-label="Stop generation"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<rect x="2" y="2" width="8" height="8" rx="1.5" />
</svg>
</button>
);

const queueButton = (
<Button
type="submit"
size="sm"
className={cn("rounded-full", compact ? "px-3" : "px-4")}
{...pointerFocusProps}
disabled={!hasSendableContent}
>
Queue
</Button>
);

if (pendingAction) {
return (
<div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
Expand Down Expand Up @@ -126,19 +162,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
}

if (isRunning) {
return (
<button
type="button"
className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none sm:h-8 sm:w-8"
{...pointerFocusProps}
onClick={onInterrupt}
aria-label="Stop generation"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<rect x="2" y="2" width="8" height="8" rx="1.5" />
</svg>
</button>
);
// While the thread is busy, queuing (when available) becomes the primary
// action and Stop moves beside it as a secondary control.
if (canQueue) {
return (
<div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
{stopButton}
{queueButton}
</div>
);
}
return stopButton;
}

if (showPlanFollowUpPrompt) {
Expand Down Expand Up @@ -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 (
<button
type="submit"
Expand Down
Loading
Loading