authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-14 23:12:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 23:12:16-07:00
logcc839c42b1adfddd059142bb6d7151719080c4ea
tree9cc405cc1d5071ff8cf72517a16aa52d4a85dc63
parent2c19283afed1e3b69a9af33360980527aeb37494
signature Signed by PGP key B5690EEEBB952194

feat(web): queue messages sent client-side while the agent is working (#11673)


10 files changed, 831 insertions(+), 38 deletions(-)

apps/web/src/components/ChatView.tsx+288-12
......@@ -309,6 +309,13 @@ import {
309309 reviewCommentContextLabel,
310310 terminalContextReference,
311311} from "../lib/composerContextRecords";
312import {
313 isQueuedMessageDue,
314 latestCompletedToolActivityId,
315 type QueuedComposerMessage,
316 useQueuedMessages,
317 useQueuedMessageStore,
318} from "../queuedMessageStore";
312319import { type ReviewCommentContext } from "../reviewCommentContext";
313320import { environmentCatalog } from "../connection/catalog";
314321import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
......@@ -512,6 +519,7 @@ import {
512519} from "./chat/composerPromptHistory";
513520
514521const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
522const EMPTY_QUEUED_MESSAGES: QueuedComposerMessage[] = [];
515523const EMPTY_PROVIDERS: ServerProvider[] = [];
516524const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
517525const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
......@@ -3906,10 +3914,18 @@ export default function ChatView(props: ChatViewProps) {
39063914
39073915 const interruptContextRef = useRef({ activeThread, phase, setThreadError });
39083916 interruptContextRef.current = { activeThread, phase, setThreadError };
3917 const restoreQueuedMessagesRef = useRef<(messages: ReadonlyArray<QueuedComposerMessage>) => void>(
3918 () => {},
3919 );
39093920 const onInterrupt = useCallback(async () => {
39103921 const { activeThread, phase, setThreadError } = interruptContextRef.current;
39113922 const input = buildRunningThreadTurnInterruptInput(activeThread, phase);
39123923 if (!input || !activeThread) return;
3924 restoreQueuedMessagesRef.current(
3925 useQueuedMessageStore
3926 .getState()
3927 .drain(scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id))),
3928 );
39133929 const result = await interruptThreadTurn({
39143930 environmentId: activeThread.environmentId,
39153931 input,
......@@ -7073,6 +7089,81 @@ export default function ChatView(props: ChatViewProps) {
70737089 }
70747090 };
70757091
7092 const queuedMessages = useQueuedMessages(activeThreadKey ?? "");
7093 // Puts queued messages back into the composer, e.g. after Stop or a failed
7094 // send. Prompts join with blank lines; attachments and contexts are added.
7095 const restoreQueuedMessagesToComposer = (messages: ReadonlyArray<QueuedComposerMessage>) => {
7096 if (messages.length === 0) return;
7097 const prompts = [promptRef.current, ...messages.map((message) => message.prompt)]
7098 .map((prompt) => prompt.trim())
7099 .filter((prompt) => prompt.length > 0);
7100 const nextPrompt = prompts.join("\n\n");
7101 promptRef.current = nextPrompt;
7102 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
7103 // The draft store silently drops attachments over the per-turn cap. Split
7104 // the overflow back into the queue so nothing is lost; the user can send
7105 // the first batch and the rest follows as a queued message.
7106 const attachmentRoom = Math.max(
7107 0,
7108 PROVIDER_SEND_TURN_MAX_ATTACHMENTS -
7109 composerImagesRef.current.length -
7110 composerFilesRef.current.length,
7111 );
7112 const attachments = messages.flatMap((message) => [...message.images, ...message.files]);
7113 const restored = attachments.slice(0, attachmentRoom);
7114 const overflow = attachments.slice(attachmentRoom);
7115 const restoredImages = restored.filter((attachment) => attachment.type === "image");
7116 const restoredFiles = restored.filter((attachment) => attachment.type === "file");
7117 // The composer syncs these refs from the draft in an effect; a send before
7118 // that effect runs must already see the restored content.
7119 composerImagesRef.current = [...composerImagesRef.current, ...restoredImages];
7120 composerFilesRef.current = [...composerFilesRef.current, ...restoredFiles];
7121 if (restoredImages.length > 0) addComposerDraftImages(composerDraftTarget, restoredImages);
7122 if (restoredFiles.length > 0) addComposerDraftFiles(composerDraftTarget, restoredFiles);
7123 if (overflow.length > 0 && activeThreadKey) {
7124 useQueuedMessageStore.getState().enqueue(activeThreadKey, {
7125 prompt: "",
7126 images: overflow.filter((attachment) => attachment.type === "image"),
7127 files: overflow.filter((attachment) => attachment.type === "file"),
7128 terminalContexts: [],
7129 previewAnnotations: [],
7130 reviewComments: [],
7131 submissionIntent: "foreground",
7132 queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities),
7133 // Restoration is not a send. The user decides when the overflow goes.
7134 holdUntilUserAction: true,
7135 createdAt: new Date().toISOString(),
7136 });
7137 toastManager.add(
7138 stackedThreadToast({
7139 type: "info",
7140 title: "Some attachments stayed queued",
7141 description: `A message holds at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments. Use Send now on the queued row when you want the rest to go.`,
7142 }),
7143 );
7144 }
7145 const restoredTerminalContexts = [
7146 ...composerTerminalContextsRef.current,
7147 ...messages.flatMap((message) => message.terminalContexts),
7148 ];
7149 composerTerminalContextsRef.current = restoredTerminalContexts;
7150 setComposerDraftTerminalContexts(composerDraftTarget, restoredTerminalContexts);
7151 const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget);
7152 setComposerDraftPreviewAnnotations(composerDraftTarget, [
7153 ...(draft?.previewAnnotations ?? []),
7154 ...messages.flatMap((message) => message.previewAnnotations),
7155 ]);
7156 setComposerDraftReviewComments(composerDraftTarget, [
7157 ...(draft?.reviewComments ?? []),
7158 ...messages.flatMap((message) => message.reviewComments),
7159 ]);
7160 composerRef.current?.resetCursorState({
7161 cursor: collapseExpandedComposerCursor(nextPrompt, nextPrompt.length),
7162 prompt: nextPrompt,
7163 detectTrigger: true,
7164 });
7165 };
7166
70767167 const onSend = async (
70777168 e?: { preventDefault: () => void },
70787169 submissionIntent: ComposerSubmissionIntent = "foreground",
......@@ -7080,6 +7171,8 @@ export default function ChatView(props: ChatViewProps) {
70807171 annotation: PreviewAnnotationPayload;
70817172 image: ComposerImageAttachment | null;
70827173 },
7174 /** A queued message being sent now instead of the live composer draft. */
7175 queuedMessage?: QueuedComposerMessage,
70837176 ) => {
70847177 e?.preventDefault();
70857178 // Typed out in full rather than picked from the menu. Attachments or contexts
......@@ -7088,6 +7181,7 @@ export default function ChatView(props: ChatViewProps) {
70887181 usageLimitsOffered &&
70897182 usageLimitsKey !== null &&
70907183 !directAnnotation &&
7184 !queuedMessage &&
70917185 !composerHasNonPromptContent &&
70927186 isUsageLimitsCommand(promptRef.current)
70937187 ) {
......@@ -7149,7 +7243,9 @@ export default function ChatView(props: ChatViewProps) {
71497243 return;
71507244 }
71517245 if (activePendingProgress) {
7152 if (directAnnotation) {
7246 // A queued message waits until the question is answered; it must not
7247 // be submitted as the answer.
7248 if (directAnnotation || queuedMessage) {
71537249 notifyDirectAnnotationAttached();
71547250 return;
71557251 }
......@@ -7167,6 +7263,8 @@ export default function ChatView(props: ChatViewProps) {
71677263 terminalContexts: composerTerminalContexts,
71687264 previewAnnotations: sendContextPreviewAnnotations,
71697265 reviewComments: composerReviewComments,
7266 } = queuedMessage ?? sendCtx;
7267 const {
71707268 selectedProvider: ctxSelectedProvider,
71717269 selectedModel: ctxSelectedModel,
71727270 selectedProviderModels: ctxSelectedProviderModels,
......@@ -7209,11 +7307,13 @@ export default function ChatView(props: ChatViewProps) {
72097307 : sendContextPreviewAnnotations;
72107308 // A direct "send annotation" writes the draft and sends in the same tick; the reference
72117309 // must be in the text now, not after the next render.
7212 const promptForSend = directAnnotation
7213 ? ensureInlineContextReferences(promptRef.current, [
7214 previewAnnotationContextReference(directAnnotation.annotation),
7215 ])
7216 : promptRef.current;
7310 const promptForSend = queuedMessage
7311 ? queuedMessage.prompt
7312 : directAnnotation
7313 ? ensureInlineContextReferences(promptRef.current, [
7314 previewAnnotationContextReference(directAnnotation.annotation),
7315 ])
7316 : promptRef.current;
72177317 const {
72187318 trimmedPrompt: trimmed,
72197319 sendableTerminalContexts: sendableComposerTerminalContexts,
......@@ -7234,7 +7334,7 @@ export default function ChatView(props: ChatViewProps) {
72347334 composerReviewComments.length === 0
72357335 ? parseCodexFeedbackCommand(trimmed)
72367336 : null;
7237 if (feedbackCommand) {
7337 if (feedbackCommand && !queuedMessage) {
72387338 if (!isServerThread || activeThread.session === null) {
72397339 toastManager.add(
72407340 stackedThreadToast({
......@@ -7285,6 +7385,7 @@ export default function ChatView(props: ChatViewProps) {
72857385 }
72867386 if (
72877387 !directAnnotation &&
7388 !queuedMessage &&
72887389 sendInteractionModeEnabled &&
72897390 showPlanFollowUpPrompt &&
72907391 activeProposedPlan &&
......@@ -7356,7 +7457,7 @@ export default function ChatView(props: ChatViewProps) {
73567457 composerReviewComments.length === 0
73577458 ? parseStandaloneComposerSlashCommand(trimmed)
73587459 : null;
7359 if (standaloneSlashCommand) {
7460 if (standaloneSlashCommand && !queuedMessage) {
73607461 handleInteractionModeChange(standaloneSlashCommand);
73617462 promptRef.current = "";
73627463 clearComposerDraftContent(composerDraftTarget);
......@@ -7377,6 +7478,12 @@ export default function ChatView(props: ChatViewProps) {
73777478 }),
73787479 );
73797480 }
7481 // A queued message whose only content expired would retry on every
7482 // boundary and block the rest of the queue. Nothing sendable is left
7483 // in it, so drop it and let the queue move on.
7484 if (queuedMessage && activeThreadKey) {
7485 useQueuedMessageStore.getState().remove(activeThreadKey, queuedMessage.id);
7486 }
73807487 return;
73817488 }
73827489 if (!activeProject) {
......@@ -7389,6 +7496,36 @@ export default function ChatView(props: ChatViewProps) {
73897496 );
73907497 return;
73917498 }
7499 // A send during a running turn waits in the queue. It leaves on the next
7500 // tool boundary, when the turn ends, or when the user clicks Steer. The
7501 // provider treats a mid-turn send as a steer of the active turn, so the
7502 // dispatch below is the same either way.
7503 if (!queuedMessage && !directAnnotation && phase === "running" && activeThreadKey) {
7504 if (composerRef.current?.validateProviderInput(promptForSend) === false) {
7505 return;
7506 }
7507 useQueuedMessageStore.getState().enqueue(activeThreadKey, {
7508 prompt: promptForSend,
7509 images: [...composerImages],
7510 files: [...composerFiles],
7511 terminalContexts: [...composerTerminalContexts],
7512 previewAnnotations: [...composerPreviewAnnotations],
7513 reviewComments: [...composerReviewComments],
7514 submissionIntent,
7515 queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities),
7516 createdAt: new Date().toISOString(),
7517 });
7518 promptRef.current = "";
7519 // Attachments move with the message; their uploads stay pending. The
7520 // refs clear now too, so a Stop before the composer's sync effect runs
7521 // does not restore the moved attachments twice.
7522 composerImagesRef.current = [];
7523 composerFilesRef.current = [];
7524 composerTerminalContextsRef.current = [];
7525 clearComposerDraftContent(composerDraftTarget);
7526 composerRef.current?.resetCursorState();
7527 return;
7528 }
73927529 const threadIdForSend = activeThread.id;
73937530 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
73947531 const baseBranchForWorktree =
......@@ -7444,6 +7581,11 @@ export default function ChatView(props: ChatViewProps) {
74447581 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
74457582 });
74467583 if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {
7584 // A queued message that no longer fits is held at the head for the
7585 // user to edit via Cancel, instead of failing on every boundary.
7586 if (queuedMessage && activeThreadKey) {
7587 useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage);
7588 }
74477589 return;
74487590 }
74497591
......@@ -7464,10 +7606,41 @@ export default function ChatView(props: ChatViewProps) {
74647606 };
74657607
74667608 sendInFlightRef.current = true;
7609 // Every early return above leaves a queued message in the queue for a
7610 // later retry. From here on a failure hands it back to the composer.
7611 if (queuedMessage) {
7612 const taken = activeThreadKey
7613 ? useQueuedMessageStore
7614 .getState()
7615 .take(
7616 activeThreadKey,
7617 queuedMessage.id,
7618 latestCompletedToolActivityId(threadActivities),
7619 )
7620 : null;
7621 if (!taken) {
7622 sendInFlightRef.current = false;
7623 return;
7624 }
7625 }
7626 // Stop drains the queue. A queued send whose upload was still running at
7627 // that moment must not start a turn afterwards; it checks this before
7628 // dispatch and hands the message back to the composer instead.
7629 const drainGenerationAtTake = useQueuedMessageStore.getState().drainGeneration;
7630 // A queued send that fails goes back to the head of the queue, held. The
7631 // messages behind it keep their order and wait; the composer is not
7632 // touched, which also keeps a failure after navigation off the new
7633 // thread's draft. The user retries with Send now or edits with Cancel.
7634 const abortQueuedReplay = () => {
7635 if (queuedMessage && activeThreadKey) {
7636 useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage);
7637 }
7638 };
74677639 const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities();
74687640 if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) {
74697641 sendInFlightRef.current = false;
74707642 setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason);
7643 abortQueuedReplay();
74717644 return;
74727645 }
74737646 const turnUsesAttachmentUploads =
......@@ -7487,15 +7660,26 @@ export default function ChatView(props: ChatViewProps) {
74877660 if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) {
74887661 sendInFlightRef.current = false;
74897662 setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason);
7663 abortQueuedReplay();
74907664 return;
74917665 }
74927666 if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) {
74937667 sendInFlightRef.current = false;
74947668 setThreadError(threadIdForSend, "Retry or remove failed uploads before sending.");
7669 abortQueuedReplay();
74957670 return;
74967671 }
74977672 }
74987673
7674 if (
7675 queuedMessage &&
7676 useQueuedMessageStore.getState().drainGeneration !== drainGenerationAtTake
7677 ) {
7678 sendInFlightRef.current = false;
7679 restoreQueuedMessagesToComposer([queuedMessage]);
7680 return;
7681 }
7682
74997683 const resolvedSubmissionIntent =
75007684 submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";
75017685 if (
......@@ -7528,6 +7712,7 @@ export default function ChatView(props: ChatViewProps) {
75287712 setDockedDraftHeroThreadKey((currentThreadKey) =>
75297713 currentThreadKey === activeThreadKey ? null : currentThreadKey,
75307714 );
7715 abortQueuedReplay();
75317716 return;
75327717 }
75337718 beginLocalDispatch({
......@@ -7628,9 +7813,11 @@ export default function ChatView(props: ChatViewProps) {
76287813 }),
76297814 );
76307815 }
7631 promptRef.current = "";
7632 clearComposerDraftContent(composerDraftTarget);
7633 composerRef.current?.resetCursorState();
7816 if (!queuedMessage) {
7817 promptRef.current = "";
7818 clearComposerDraftContent(composerDraftTarget);
7819 composerRef.current?.resetCursorState();
7820 }
76347821
76357822 let firstComposerImageName: string | null = null;
76367823 if (composerImagesSnapshot.length > 0) {
......@@ -7854,7 +8041,24 @@ export default function ChatView(props: ChatViewProps) {
78548041 }
78558042
78568043 if (failure !== null) {
7857 if (
8044 if (queuedMessage) {
8045 setOptimisticUserMessages((existing) => {
8046 const removed = existing.filter((message) => message.id === messageIdForSend);
8047 for (const message of removed) {
8048 revokeUserMessagePreviewUrls(message);
8049 }
8050 const next = existing.filter((message) => message.id !== messageIdForSend);
8051 return next.length === existing.length ? existing : next;
8052 });
8053 // The optimistic row's preview URLs were just revoked, so the images
8054 // need fresh ones before the row can show them again.
8055 if (activeThreadKey) {
8056 useQueuedMessageStore.getState().holdAtFront(activeThreadKey, {
8057 ...queuedMessage,
8058 images: queuedMessage.images.map(cloneComposerImageForRetry),
8059 });
8060 }
8061 } else if (
78588062 promptRef.current.length === 0 &&
78598063 composerImagesRef.current.length === 0 &&
78608064 composerFilesRef.current.length === 0 &&
......@@ -7920,6 +8124,75 @@ export default function ChatView(props: ChatViewProps) {
79208124 }
79218125 };
79228126
8127 // Sends the oldest queued message once it is due: a tool call finished
8128 // after it was queued, or the turn ended. Only one leaves per boundary; the
8129 // take inside onSend re-anchors the rest.
8130 const sendQueuedMessage = useEffectEvent((message: QueuedComposerMessage) => {
8131 void onSend(undefined, message.submissionIntent, undefined, message);
8132 });
8133 const nextQueuedMessage = queuedMessages[0] ?? null;
8134 const latestToolActivityId = useMemo(
8135 () => (nextQueuedMessage ? latestCompletedToolActivityId(threadActivities) : null),
8136 [nextQueuedMessage, threadActivities],
8137 );
8138 // Approvals and questions block the agent; a steer landing on top of them
8139 // would answer nothing and confuse the turn, so the queue holds until the
8140 // user resolves them.
8141 const queueBlockedByPendingRequest =
8142 activePendingApproval !== null || pendingUserInputs.length > 0;
8143 // onSend bails early on transient gates (environment offline, settings not
8144 // hydrated, checkpoint rewinding, messages loading, machine not chosen) and
8145 // leaves the message queued. Re-run when any of them clear so a due message
8146 // does not wait for an unrelated phase change.
8147 const queueSendGate =
8148 activeEnvironmentUnavailable ||
8149 !clientSettingsHydrated ||
8150 isRevertingCheckpoint ||
8151 threadDetailLoading ||
8152 needsLoadBalancing ||
8153 activeProviderStatus === null;
8154 useEffect(() => {
8155 if (!nextQueuedMessage || isSendBusy || queueBlockedByPendingRequest || queueSendGate) return;
8156 if (sendInFlightRef.current) return;
8157 if (!isQueuedMessageDue({ message: nextQueuedMessage, phase, latestToolActivityId })) return;
8158 sendQueuedMessage(nextQueuedMessage);
8159 }, [
8160 isSendBusy,
8161 latestToolActivityId,
8162 nextQueuedMessage,
8163 phase,
8164 queueBlockedByPendingRequest,
8165 queueSendGate,
8166 ]);
8167
8168 // The row handlers are read from refs at call-time so their identity stays
8169 // stable and does not bust TimelineRowCtx on every ChatView render.
8170 const queuedMessageActionsRef = useRef({
8171 steer: (_id: string) => {},
8172 remove: (_id: string) => {},
8173 });
8174 queuedMessageActionsRef.current = {
8175 steer: (id) => {
8176 const message = queuedMessages.find((entry) => entry.id === id);
8177 if (!message || sendInFlightRef.current || queueBlockedByPendingRequest) return;
8178 void onSend(undefined, message.submissionIntent, undefined, message);
8179 },
8180 remove: (id) => {
8181 if (!activeThreadKey) return;
8182 const message = useQueuedMessageStore.getState().remove(activeThreadKey, id);
8183 if (message) restoreQueuedMessagesToComposer([message]);
8184 },
8185 };
8186 const onSteerQueuedMessage = useCallback((id: string) => {
8187 queuedMessageActionsRef.current.steer(id);
8188 }, []);
8189 const onRemoveQueuedMessage = useCallback((id: string) => {
8190 queuedMessageActionsRef.current.remove(id);
8191 }, []);
8192 // Stop also cancels the queue: the messages return to the composer instead
8193 // of starting a new turn the moment the interrupted one settles.
8194 restoreQueuedMessagesRef.current = restoreQueuedMessagesToComposer;
8195
79238196 const onRespondToApproval = useCallback(
79248197 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
79258198 if (!activeThreadId) return;
......@@ -9190,6 +9463,9 @@ export default function ChatView(props: ChatViewProps) {
91909463 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
91919464 topFadeEnabled={!hasTimelineTopBanner}
91929465 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}
9466 queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages}
9467 onSteerQueuedMessage={onSteerQueuedMessage}
9468 onRemoveQueuedMessage={onRemoveQueuedMessage}
91939469 />
91949470
91959471 {/* scroll to end pill — shown when user has scrolled away from the live edge */}
apps/web/src/components/chat/ChatComposer.tsx-3
......@@ -1166,7 +1166,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
11661166 isEnvironmentUnavailable: boolean;
11671167 hasSendableContent: boolean;
11681168 preserveComposerFocusOnPointerDown?: boolean;
1169 showSendWhileRunning?: boolean;
11701169 onPreviousPendingQuestion: () => void;
11711170 onInterrupt: () => void;
11721171 onImplementPlanInNewThread: () => void;
......@@ -1200,7 +1199,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
12001199 isPreparingWorktree={props.isPreparingWorktree}
12011200 hasSendableContent={props.hasSendableContent}
12021201 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
1203 showSendWhileRunning={props.showSendWhileRunning ?? false}
12041202 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
12051203 onInterrupt={props.onInterrupt}
12061204 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
......@@ -6845,7 +6843,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
68456843 isPreparingWorktree={isPreparingWorktree}
68466844 hasSendableContent={composerSendState.hasSendableContent}
68476845 preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
6848 showSendWhileRunning={isMobileViewport}
68496846 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
68506847 onInterrupt={handleInterruptPrimaryAction}
68516848 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx+6-14
......@@ -44,7 +44,7 @@ function renderPendingActions(isRunning: boolean) {
4444 );
4545}
4646
47function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) {
47function renderRunningActions(hasSendableContent: boolean) {
4848 return renderToStaticMarkup(
4949 createElement(ComposerPrimaryActions, {
5050 compact: true,
......@@ -58,7 +58,6 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent:
5858 isEnvironmentUnavailable: false,
5959 isPreparingWorktree: false,
6060 hasSendableContent,
61 showSendWhileRunning,
6261 onPreviousPendingQuestion: () => {},
6362 onInterrupt: () => {},
6463 onImplementPlanInNewThread: () => {},
......@@ -125,25 +124,18 @@ describe("ComposerPrimaryActions", () => {
125124 expect(markup).not.toContain("stage-nightly");
126125 });
127126
128 it("only renders stop while running when Enter-to-send is available", () => {
129 const markup = renderRunningActions(false, true);
127 it("renders a queue action alongside stop while running with a sendable draft", () => {
128 const markup = renderRunningActions(true);
130129
131130 expect(markup).toContain('aria-label="Stop generation"');
132 expect(markup).not.toContain('aria-label="Send message"');
133 });
134
135 it("renders send alongside stop while running when Enter-to-send is unavailable", () => {
136 const markup = renderRunningActions(true, true);
137
138 expect(markup).toContain('aria-label="Stop generation"');
139 expect(markup).toContain('aria-label="Send message"');
131 expect(markup).toContain('aria-label="Queue message"');
140132 expect(markup).toContain('type="submit"');
141133 });
142134
143135 it("keeps stop as the only action while running with an empty composer", () => {
144 const markup = renderRunningActions(true, false);
136 const markup = renderRunningActions(false);
145137
146138 expect(markup).toContain('aria-label="Stop generation"');
147 expect(markup).not.toContain('aria-label="Send message"');
139 expect(markup).not.toContain('aria-label="Queue message"');
148140 });
149141});
apps/web/src/components/chat/ComposerPrimaryActions.tsx+7-7
......@@ -29,9 +29,6 @@ interface ComposerPrimaryActionsProps {
2929 isPreparingWorktree: boolean;
3030 hasSendableContent: boolean;
3131 preserveComposerFocusOnPointerDown?: boolean;
32 /** Enter-to-send is disabled on mobile viewports, where stop would otherwise
33 * be the only primary action and a running turn could not be steered. */
34 showSendWhileRunning?: boolean;
3532 onPreviousPendingQuestion: () => void;
3633 onInterrupt: () => void;
3734 onImplementPlanInNewThread: () => void;
......@@ -72,7 +69,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
7269 isPreparingWorktree,
7370 hasSendableContent,
7471 preserveComposerFocusOnPointerDown = false,
75 showSendWhileRunning = false,
7672 onPreviousPendingQuestion,
7773 onInterrupt,
7874 onImplementPlanInNewThread,
......@@ -93,7 +89,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
9389 "flex 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",
9490 insidePendingAction
9591 ? "size-8 sm:size-7"
96 : showSendWhileRunning && hasSendableContent
92 : hasSendableContent
9793 ? "size-9 sm:size-8"
9894 : "size-8 sm:h-8 sm:w-8",
9995 )}
......@@ -247,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
247243 ? "Preparing worktree"
248244 : isSendBusy
249245 ? "Sending"
250 : "Send message"
246 : isRunning
247 ? "Queue message"
248 : "Send message"
251249 }
252250 >
253251 {stageBackdropVariant ? (
......@@ -275,10 +273,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
275273 return sendButton;
276274 }
277275
276 // While a turn runs, a sendable draft queues for the next tool boundary, so
277 // the send button stays next to Stop on every viewport.
278278 return (
279279 <>
280280 {renderStopGenerationButton(false)}
281 {showSendWhileRunning && hasSendableContent ? sendButton : null}
281 {hasSendableContent ? sendButton : null}
282282 </>
283283 );
284284});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+34
......@@ -1092,6 +1092,40 @@ describe("resolveAssistantMessageCopyState", () => {
10921092});
10931093
10941094describe("deriveMessagesTimelineRows", () => {
1095 it("appends queued messages after the live rows, marking the oldest as next", () => {
1096 const queuedMessage = (id: string, prompt: string) => ({
1097 id,
1098 prompt,
1099 images: [],
1100 files: [],
1101 terminalContexts: [],
1102 previewAnnotations: [],
1103 reviewComments: [],
1104 submissionIntent: "foreground" as const,
1105 queuedAfterToolActivityId: null,
1106 createdAt: "2026-01-01T00:00:01Z",
1107 });
1108 const rows = deriveMessagesTimelineRows({
1109 timelineEntries: [],
1110 isWorking: true,
1111 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1112 turnDiffSummaries: [],
1113 supportsConversationRollback: false,
1114 queuedMessages: [queuedMessage("q1", "first"), queuedMessage("q2", "second")],
1115 });
1116
1117 expect(rows.map((row) => row.kind)).toEqual([
1118 "working",
1119 "thinking",
1120 "queued-message",
1121 "queued-message",
1122 ]);
1123 expect(rows.slice(2)).toMatchObject([
1124 { id: "queued-message:q1", isNext: true, queuedMessage: { prompt: "first" } },
1125 { id: "queued-message:q2", isNext: false, queuedMessage: { prompt: "second" } },
1126 ]);
1127 });
1128
10951129 it("shows the worktree setup card instead of the working placeholder", () => {
10961130 const snapshot: WorktreeSetupSnapshot = {
10971131 threadId: ThreadId.make("thread-setup"),
apps/web/src/components/chat/MessagesTimeline.logic.ts+27-2
......@@ -28,6 +28,7 @@ import {
2828 type WorkLogEntry,
2929} from "../../session-logic";
3030import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
31import type { QueuedComposerMessage } from "../../queuedMessageStore";
3132import {
3233 type MessageId,
3334 type OrchestrationLatestTurn,
......@@ -402,6 +403,14 @@ export type MessagesTimelineRow =
402403 snapshot: WorktreeSetupSnapshot;
403404 /** The agent already started; render only the script row under the turn header. */
404405 embedded: boolean;
406 }
407 | {
408 kind: "queued-message";
409 id: string;
410 createdAt: string;
411 queuedMessage: QueuedComposerMessage;
412 /** Oldest queued message, the one the next boundary sends. */
413 isNext: boolean;
405414 };
406415
407416export interface StableMessagesTimelineRowsState {
......@@ -872,6 +881,8 @@ export function deriveMessagesTimelineRows(input: {
872881 liveAgentTaskIds?: ReadonlySet<string> | undefined;
873882 /** Live bootstrap progress. Renders a stage card under the first user message. */
874883 worktreeSetup?: WorktreeSetupSnapshot | null;
884 /** Messages sent during the running turn, rendered after the live rows. */
885 queuedMessages?: ReadonlyArray<QueuedComposerMessage>;
875886}): MessagesTimelineRow[] {
876887 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
877888 for (const summary of input.turnDiffSummaries) {
......@@ -1329,8 +1340,17 @@ export function deriveMessagesTimelineRows(input: {
13291340 createdAt: input.activeTurnStartedAt,
13301341 });
13311342 }
1332
1333 return attachTrailingToolGroupsToAssistant(nextRows);
1343 const rows = attachTrailingToolGroupsToAssistant(nextRows);
1344 input.queuedMessages?.forEach((queuedMessage, index) => {
1345 rows.push({
1346 kind: "queued-message",
1347 id: `queued-message:${queuedMessage.id}`,
1348 createdAt: queuedMessage.createdAt,
1349 queuedMessage,
1350 isNext: index === 0,
1351 });
1352 });
1353 return rows;
13341354}
13351355
13361356export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
......@@ -1468,6 +1488,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
14681488 case "proposed-plan":
14691489 return a.proposedPlan === (b as typeof a).proposedPlan;
14701490
1491 case "queued-message": {
1492 const bq = b as typeof a;
1493 return a.queuedMessage === bq.queuedMessage && a.isNext === bq.isNext;
1494 }
1495
14711496 case "work": {
14721497 const bw = b as typeof a;
14731498 return (
apps/web/src/components/chat/MessagesTimeline.tsx+117
......@@ -1,3 +1,4 @@
1import { ArrowUpIcon, ClockIcon } from "lucide-react";
12import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview";
23import { useRightPanelStore } from "~/rightPanelStore";
34import {
......@@ -45,6 +46,8 @@ import {
4546
4647const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
4748const NOOP_OPEN_AGENTS = () => {};
49const EMPTY_QUEUED_MESSAGES: ReadonlyArray<QueuedComposerMessage> = [];
50const NOOP_QUEUED_MESSAGE_ACTION = (_id: string) => {};
4851const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
4952const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
5053import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
......@@ -131,6 +134,7 @@ import type {
131134 KnownComposerContextRecord,
132135} from "@t3tools/contracts";
133136import { Button } from "../ui/button";
137import type { QueuedComposerMessage } from "../../queuedMessageStore";
134138import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls";
135139import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
136140import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
......@@ -280,6 +284,8 @@ interface TimelineRowSharedState {
280284 onCancelWorktreeSetup: (() => void) | null;
281285 onWorktreeSetupWorkLocally: (() => void) | null;
282286 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;
287 onSteerQueuedMessage: (id: string) => void;
288 onRemoveQueuedMessage: (id: string) => void;
283289}
284290
285291interface TimelineRowActivityState {
......@@ -432,6 +438,10 @@ interface MessagesTimelineProps {
432438 topFadeEnabled?: boolean;
433439 /** Non-null when older turns exist beyond the loaded window. */
434440 loadEarlier?: CitationHistoryPage | null;
441 /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */
442 queuedMessages?: ReadonlyArray<QueuedComposerMessage>;
443 onSteerQueuedMessage?: (id: string) => void;
444 onRemoveQueuedMessage?: (id: string) => void;
435445}
436446
437447// ---------------------------------------------------------------------------
......@@ -484,6 +494,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
484494 hideEmptyPlaceholder = false,
485495 topFadeEnabled = false,
486496 loadEarlier = null,
497 queuedMessages = EMPTY_QUEUED_MESSAGES,
498 onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
499 onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
487500}: MessagesTimelineProps) {
488501 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
489502 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
......@@ -707,6 +720,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
707720 supportsConversationRollback,
708721 liveAgentTaskIds,
709722 worktreeSetup,
723 queuedMessages,
710724 },
711725 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
712726 ? previous.projection
......@@ -729,6 +743,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
729743 supportsConversationRollback,
730744 liveAgentTaskIds,
731745 worktreeSetup,
746 queuedMessages,
732747 ]);
733748 const rows = useStableRows(rawRows, listIdentityKey);
734749 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
......@@ -924,6 +939,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
924939 onCancelWorktreeSetup: onCancelWorktreeSetup ?? null,
925940 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
926941 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
942 onSteerQueuedMessage,
943 onRemoveQueuedMessage,
927944 }),
928945 [
929946 readyCitationRequest,
......@@ -954,6 +971,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
954971 onCancelWorktreeSetup,
955972 onWorktreeSetupWorkLocally,
956973 onOpenWorktreeSetupTerminal,
974 onSteerQueuedMessage,
975 onRemoveQueuedMessage,
957976 ],
958977 );
959978 const activityState = useMemo<TimelineRowActivityState>(
......@@ -1445,6 +1464,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
14451464 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
14461465 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
14471466 {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null}
1467 {row.kind === "queued-message" ? <QueuedMessageTimelineRow row={row} /> : null}
14481468 </div>
14491469 );
14501470});
......@@ -1474,6 +1494,103 @@ function WorktreeSetupTimelineRow({
14741494 );
14751495}
14761496
1497/** A message waiting for the running turn: a dashed user bubble with icon actions inside it. */
1498function QueuedMessageTimelineRow({
1499 row,
1500}: {
1501 row: Extract<TimelineRow, { kind: "queued-message" }>;
1502}) {
1503 const ctx = use(TimelineRowCtx);
1504 const { queuedMessage } = row;
1505 const attachmentCount = queuedMessage.images.length + queuedMessage.files.length;
1506 const contextCount =
1507 queuedMessage.terminalContexts.length +
1508 queuedMessage.previewAnnotations.length +
1509 queuedMessage.reviewComments.length;
1510 const text = queuedMessage.prompt.trim();
1511 const statusLabel = queuedMessage.holdUntilUserAction
1512 ? "Waits for Send now"
1513 : row.isNext
1514 ? "Sends after the next tool call or when the turn ends"
1515 : "Sends after the messages above it";
1516 return (
1517 <div className="flex flex-col items-end" data-queued-message-id={queuedMessage.id}>
1518 <div className="max-w-[80%] rounded-2xl border border-dashed border-border p-3 text-message-foreground/80">
1519 {text.length > 0 ? (
1520 <div className="whitespace-pre-wrap break-words text-sm">{text}</div>
1521 ) : null}
1522 {attachmentCount > 0 || contextCount > 0 ? (
1523 <div className={cn("text-secondary-label text-xs", text.length > 0 && "mt-1.5")}>
1524 {[
1525 attachmentCount > 0
1526 ? `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`
1527 : null,
1528 contextCount > 0
1529 ? `${contextCount} context item${contextCount === 1 ? "" : "s"}`
1530 : null,
1531 ]
1532 .filter(Boolean)
1533 .join(", ")}
1534 </div>
1535 ) : null}
1536 <div
1537 className="mt-2 flex items-center gap-4 text-secondary-label text-xs"
1538 data-scroll-anchor-ignore
1539 >
1540 <Tooltip>
1541 <TooltipTrigger
1542 render={<span className="inline-flex h-6 items-center gap-1" />}
1543 aria-label={`Queued. ${statusLabel}.`}
1544 >
1545 <ClockIcon className="size-3.5" aria-hidden />
1546 Queued
1547 </TooltipTrigger>
1548 <TooltipPopup side="bottom">{statusLabel}</TooltipPopup>
1549 </Tooltip>
1550 <div className="ml-auto flex items-center gap-0.5">
1551 <Tooltip>
1552 <TooltipTrigger
1553 render={
1554 <Button
1555 type="button"
1556 size="icon-micro"
1557 variant="ghost-muted"
1558 className="size-6"
1559 onPointerDown={(event) => event.preventDefault()}
1560 onClick={() => ctx.onSteerQueuedMessage(queuedMessage.id)}
1561 aria-label="Send now"
1562 />
1563 }
1564 >
1565 <ArrowUpIcon className="size-3.5" aria-hidden />
1566 </TooltipTrigger>
1567 <TooltipPopup side="bottom">Send now</TooltipPopup>
1568 </Tooltip>
1569 <Tooltip>
1570 <TooltipTrigger
1571 render={
1572 <Button
1573 type="button"
1574 size="icon-micro"
1575 variant="ghost-muted"
1576 className="size-6"
1577 onPointerDown={(event) => event.preventDefault()}
1578 onClick={() => ctx.onRemoveQueuedMessage(queuedMessage.id)}
1579 aria-label="Cancel and return to the composer"
1580 />
1581 }
1582 >
1583 <XIcon className="size-3.5" aria-hidden />
1584 </TooltipTrigger>
1585 <TooltipPopup side="bottom">Cancel and return to the composer</TooltipPopup>
1586 </Tooltip>
1587 </div>
1588 </div>
1589 </div>
1590 </div>
1591 );
1592}
1593
14771594function ContextCompactionTimelineRow({
14781595 row,
14791596}: {
apps/web/src/queuedMessageStore.test.ts created+143
......@@ -0,0 +1,143 @@
1import { beforeEach, describe, expect, it } from "vite-plus/test";
2
3import {
4 isQueuedMessageDue,
5 latestCompletedToolActivityId,
6 useQueuedMessageStore,
7 type QueuedComposerMessage,
8} from "./queuedMessageStore";
9
10function makeMessage(prompt: string): Omit<QueuedComposerMessage, "id"> {
11 return {
12 prompt,
13 images: [],
14 files: [],
15 terminalContexts: [],
16 previewAnnotations: [],
17 reviewComments: [],
18 submissionIntent: "foreground",
19 queuedAfterToolActivityId: null,
20 createdAt: "2026-09-11T00:00:00.000Z",
21 };
22}
23
24describe("queuedMessageStore", () => {
25 beforeEach(() => {
26 useQueuedMessageStore.setState({ queuesByThreadKey: {}, drainGeneration: 0 });
27 });
28
29 it("keeps messages in submission order per thread", () => {
30 const { enqueue } = useQueuedMessageStore.getState();
31 enqueue("thread-a", makeMessage("first"));
32 enqueue("thread-a", makeMessage("second"));
33 enqueue("thread-b", makeMessage("other"));
34
35 const queues = useQueuedMessageStore.getState().queuesByThreadKey;
36 expect(queues["thread-a"]?.map((message) => message.prompt)).toEqual(["first", "second"]);
37 expect(queues["thread-b"]?.map((message) => message.prompt)).toEqual(["other"]);
38 });
39
40 it("take hands the message to exactly one caller", () => {
41 const { enqueue, take } = useQueuedMessageStore.getState();
42 const entry = enqueue("thread-a", makeMessage("first"));
43
44 expect(take("thread-a", entry.id, null)?.prompt).toBe("first");
45 expect(take("thread-a", entry.id, null)).toBeNull();
46 expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toBeUndefined();
47 });
48
49 it("take re-anchors the remaining messages to the current tool boundary", () => {
50 const { enqueue, take } = useQueuedMessageStore.getState();
51 const first = enqueue("thread-a", makeMessage("first"));
52 enqueue("thread-a", makeMessage("second"));
53
54 take("thread-a", first.id, "tool-2");
55
56 const [second] = useQueuedMessageStore.getState().queuesByThreadKey["thread-a"] ?? [];
57 expect(second?.queuedAfterToolActivityId).toBe("tool-2");
58 expect(
59 isQueuedMessageDue({ message: second!, phase: "running", latestToolActivityId: "tool-2" }),
60 ).toBe(false);
61 });
62
63 it("remove keeps the other messages' anchors", () => {
64 const { enqueue, remove } = useQueuedMessageStore.getState();
65 const first = enqueue("thread-a", { ...makeMessage("first"), queuedAfterToolActivityId: "t1" });
66 const second = enqueue("thread-a", makeMessage("second"));
67
68 expect(remove("thread-a", second.id)?.prompt).toBe("second");
69 expect(remove("thread-a", second.id)).toBeNull();
70 expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toEqual([first]);
71 });
72
73 it("holdAtFront returns a failed message to the head, held", () => {
74 const { enqueue, take, holdAtFront } = useQueuedMessageStore.getState();
75 const first = enqueue("thread-a", makeMessage("first"));
76 enqueue("thread-a", makeMessage("second"));
77 const taken = take("thread-a", first.id, "t1")!;
78
79 holdAtFront("thread-a", taken);
80
81 const queue = useQueuedMessageStore.getState().queuesByThreadKey["thread-a"] ?? [];
82 expect(queue.map((message) => message.prompt)).toEqual(["first", "second"]);
83 expect(queue[0]?.holdUntilUserAction).toBe(true);
84 expect(
85 isQueuedMessageDue({ message: queue[0]!, phase: "ready", latestToolActivityId: null }),
86 ).toBe(false);
87 });
88
89 it("drain empties one thread's queue in order", () => {
90 const { enqueue, drain } = useQueuedMessageStore.getState();
91 enqueue("thread-a", makeMessage("first"));
92 enqueue("thread-a", makeMessage("second"));
93 enqueue("thread-b", makeMessage("other"));
94
95 expect(drain("thread-a").map((message) => message.prompt)).toEqual(["first", "second"]);
96 expect(useQueuedMessageStore.getState().drainGeneration).toBe(1);
97 expect(drain("thread-a")).toEqual([]);
98 expect(useQueuedMessageStore.getState().drainGeneration).toBe(1);
99 expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-b"]).toHaveLength(1);
100 });
101});
102
103describe("queued message dispatch timing", () => {
104 const activities = [
105 { id: "a1", kind: "tool.started", sequence: 1, createdAt: "2026-01-01T00:00:01Z" },
106 { id: "a2", kind: "tool.completed", sequence: 2, createdAt: "2026-01-01T00:00:02Z" },
107 { id: "a3", kind: "tool.updated", sequence: 3, createdAt: "2026-01-01T00:00:03Z" },
108 ];
109
110 it("finds the newest completed tool call by sequence, not position", () => {
111 expect(latestCompletedToolActivityId(activities)).toBe("a2");
112 expect(latestCompletedToolActivityId([])).toBeNull();
113 expect(
114 latestCompletedToolActivityId([
115 { id: "late", kind: "tool.completed", sequence: 9, createdAt: "2026-01-01T00:00:09Z" },
116 { id: "early", kind: "tool.completed", sequence: 4, createdAt: "2026-01-01T00:00:04Z" },
117 ]),
118 ).toBe("late");
119 });
120
121 it("waits mid-turn until a tool call finishes after the message was queued", () => {
122 const message = { queuedAfterToolActivityId: "a2" };
123 expect(isQueuedMessageDue({ message, phase: "running", latestToolActivityId: "a2" })).toBe(
124 false,
125 );
126 expect(isQueuedMessageDue({ message, phase: "running", latestToolActivityId: "a4" })).toBe(
127 true,
128 );
129 });
130
131 it("never auto-sends a message held for user action", () => {
132 const message = { queuedAfterToolActivityId: null, holdUntilUserAction: true };
133 expect(isQueuedMessageDue({ message, phase: "ready", latestToolActivityId: "a4" })).toBe(false);
134 });
135
136 it("is due as soon as the turn is over, but not while a send is connecting", () => {
137 const message = { queuedAfterToolActivityId: "a2" };
138 expect(isQueuedMessageDue({ message, phase: "ready", latestToolActivityId: "a2" })).toBe(true);
139 expect(isQueuedMessageDue({ message, phase: "connecting", latestToolActivityId: "a4" })).toBe(
140 false,
141 );
142 });
143});
apps/web/src/queuedMessageStore.ts created+201
......@@ -0,0 +1,201 @@
1import type { PreviewAnnotationPayload } from "@t3tools/contracts";
2import { create } from "zustand";
3
4import type { ComposerSubmissionIntent } from "./composer-logic";
5import type { ComposerFileAttachment, ComposerImageAttachment } from "./composerDraftStore";
6import type { TerminalContextDraft } from "./lib/terminalContext";
7import { randomUUID } from "./lib/utils";
8import type { ReviewCommentContext } from "./reviewCommentContext";
9
10/**
11 * A composer submission held back while the thread's turn is running. It
12 * carries the full draft snapshot so the send path can dispatch it later with
13 * the same text, attachments, and contexts the user pressed Enter on.
14 */
15export interface QueuedComposerMessage {
16 id: string;
17 prompt: string;
18 images: ComposerImageAttachment[];
19 files: ComposerFileAttachment[];
20 terminalContexts: TerminalContextDraft[];
21 previewAnnotations: PreviewAnnotationPayload[];
22 reviewComments: ReviewCommentContext[];
23 submissionIntent: ComposerSubmissionIntent;
24 /**
25 * The newest completed tool activity at queue time. A different id later
26 * means a tool call finished after the user queued, which is the boundary
27 * the message goes out on.
28 */
29 queuedAfterToolActivityId: string | null;
30 /**
31 * Set when the message was created by Stop or a failed restore, not by the
32 * user pressing send. It waits for Send now instead of leaving on its own.
33 */
34 holdUntilUserAction?: boolean;
35 createdAt: string;
36}
37
38interface QueuedMessageStoreState {
39 queuesByThreadKey: Record<string, QueuedComposerMessage[]>;
40 /**
41 * Bumped by `drain`. A send that took a message before a drain and finishes
42 * its upload after it compares this to the value it captured and gives up,
43 * so Stop cannot be followed by a queued message starting a new turn.
44 */
45 drainGeneration: number;
46 enqueue: (threadKey: string, message: Omit<QueuedComposerMessage, "id">) => QueuedComposerMessage;
47 /**
48 * Removes one message and returns it, or null when another caller already
49 * took it. The remaining messages are re-anchored to `toolActivityId` so
50 * only one queued message leaves per tool boundary.
51 */
52 take: (
53 threadKey: string,
54 id: string,
55 toolActivityId: string | null,
56 ) => QueuedComposerMessage | null;
57 /** Removes one message without touching the others' anchors. Null when already gone. */
58 remove: (threadKey: string, id: string) => QueuedComposerMessage | null;
59 /**
60 * Puts a message back at the head, held for user action. Used when its
61 * send failed: the queue keeps its order and nothing behind it overtakes.
62 */
63 holdAtFront: (threadKey: string, message: QueuedComposerMessage) => void;
64 /** Removes and returns every queued message for the thread, oldest first. */
65 drain: (threadKey: string) => QueuedComposerMessage[];
66}
67
68const EMPTY_QUEUE: QueuedComposerMessage[] = [];
69
70/** In-memory only: a queued message is a live intent, not a draft worth persisting. */
71export const useQueuedMessageStore = create<QueuedMessageStoreState>()((set, get) => ({
72 queuesByThreadKey: {},
73 drainGeneration: 0,
74 enqueue: (threadKey, message) => {
75 const entry: QueuedComposerMessage = { ...message, id: randomUUID() };
76 set((state) => ({
77 queuesByThreadKey: {
78 ...state.queuesByThreadKey,
79 [threadKey]: [...(state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE), entry],
80 },
81 }));
82 return entry;
83 },
84 take: (threadKey, id, toolActivityId) => {
85 const queue = get().queuesByThreadKey[threadKey];
86 const entry = queue?.find((message) => message.id === id);
87 if (!queue || !entry) {
88 return null;
89 }
90 set((state) => {
91 const remaining = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE)
92 .filter((message) => message.id !== id)
93 .map((message) =>
94 message.queuedAfterToolActivityId === toolActivityId
95 ? message
96 : { ...message, queuedAfterToolActivityId: toolActivityId },
97 );
98 const queuesByThreadKey = { ...state.queuesByThreadKey };
99 if (remaining.length === 0) {
100 delete queuesByThreadKey[threadKey];
101 } else {
102 queuesByThreadKey[threadKey] = remaining;
103 }
104 return { queuesByThreadKey };
105 });
106 return entry;
107 },
108 remove: (threadKey, id) => {
109 const queue = get().queuesByThreadKey[threadKey];
110 const entry = queue?.find((message) => message.id === id);
111 if (!queue || !entry) {
112 return null;
113 }
114 set((state) => {
115 const remaining = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE).filter(
116 (message) => message.id !== id,
117 );
118 const queuesByThreadKey = { ...state.queuesByThreadKey };
119 if (remaining.length === 0) {
120 delete queuesByThreadKey[threadKey];
121 } else {
122 queuesByThreadKey[threadKey] = remaining;
123 }
124 return { queuesByThreadKey };
125 });
126 return entry;
127 },
128 holdAtFront: (threadKey, message) => {
129 set((state) => {
130 const rest = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE).filter(
131 (entry) => entry.id !== message.id,
132 );
133 return {
134 queuesByThreadKey: {
135 ...state.queuesByThreadKey,
136 [threadKey]: [{ ...message, holdUntilUserAction: true }, ...rest],
137 },
138 };
139 });
140 },
141 drain: (threadKey) => {
142 const queue = get().queuesByThreadKey[threadKey];
143 if (!queue || queue.length === 0) {
144 return EMPTY_QUEUE;
145 }
146 set((state) => {
147 const queuesByThreadKey = { ...state.queuesByThreadKey };
148 delete queuesByThreadKey[threadKey];
149 return { queuesByThreadKey, drainGeneration: state.drainGeneration + 1 };
150 });
151 return queue;
152 },
153}));
154
155/**
156 * The newest finished tool call. Its id changing is the boundary a queued
157 * message goes out on. Live arrays are sorted, but a snapshot loaded from the
158 * database is not, so pick by sequence rather than position.
159 */
160export function latestCompletedToolActivityId(
161 activities: ReadonlyArray<{
162 readonly id: string;
163 readonly kind: string;
164 readonly sequence?: number | undefined;
165 readonly createdAt: string;
166 }>,
167): string | null {
168 let latest: (typeof activities)[number] | null = null;
169 for (const activity of activities) {
170 if (activity.kind !== "tool.completed") continue;
171 if (
172 latest === null ||
173 (activity.sequence ?? -1) > (latest.sequence ?? -1) ||
174 ((activity.sequence ?? -1) === (latest.sequence ?? -1) &&
175 activity.createdAt > latest.createdAt)
176 ) {
177 latest = activity;
178 }
179 }
180 return latest?.id ?? null;
181}
182
183/**
184 * A queued message is due mid-turn once a tool call finished after it was
185 * queued, and as soon as the turn is over otherwise. "connecting" is the gap
186 * between a send and the provider picking it up, so nothing is due there.
187 */
188export function isQueuedMessageDue(input: {
189 message: Pick<QueuedComposerMessage, "queuedAfterToolActivityId" | "holdUntilUserAction">;
190 phase: "connecting" | "running" | "ready" | "disconnected";
191 latestToolActivityId: string | null;
192}): boolean {
193 if (input.message.holdUntilUserAction) return false;
194 if (input.phase === "connecting") return false;
195 if (input.phase !== "running") return true;
196 return input.latestToolActivityId !== input.message.queuedAfterToolActivityId;
197}
198
199export function useQueuedMessages(threadKey: string): QueuedComposerMessage[] {
200 return useQueuedMessageStore((state) => state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE);
201}
docs/user/composer.md+8
......@@ -29,6 +29,14 @@ also send files to T3 Code through another app's system share sheet.
2929
3030See [images and videos](#images-and-videos-in-messages) for previewing and saving media.
3131
32## Send while the agent is working
33
34A message sent during a running turn waits at the end of the conversation as a
35dashed bubble. It goes out on its own when the agent finishes its next tool
36call, or when the turn ends. Use the arrow under the bubble to send it right
37away, or the X to move it back into the composer. Stop returns every queued
38message to the composer.
39
3240## Queue messages offline on mobile
3341
3442Mobile keeps local copies of draft attachments, so you can preview them and queue