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 {...@@ -309,6 +309,13 @@ import {
309 reviewCommentContextLabel,309 reviewCommentContextLabel,
310 terminalContextReference,310 terminalContextReference,
311} from "../lib/composerContextRecords";311} from "../lib/composerContextRecords";
312import {
313 isQueuedMessageDue,
314 latestCompletedToolActivityId,
315 type QueuedComposerMessage,
316 useQueuedMessages,
317 useQueuedMessageStore,
318} from "../queuedMessageStore";
312import { type ReviewCommentContext } from "../reviewCommentContext";319import { type ReviewCommentContext } from "../reviewCommentContext";
313import { environmentCatalog } from "../connection/catalog";320import { environmentCatalog } from "../connection/catalog";
314import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";321import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
...@@ -512,6 +519,7 @@ import {...@@ -512,6 +519,7 @@ import {
512} from "./chat/composerPromptHistory";519} from "./chat/composerPromptHistory";
513520
514const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];521const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
522const EMPTY_QUEUED_MESSAGES: QueuedComposerMessage[] = [];
515const EMPTY_PROVIDERS: ServerProvider[] = [];523const EMPTY_PROVIDERS: ServerProvider[] = [];
516const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];524const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
517const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];525const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
...@@ -3906,10 +3914,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -3906,10 +3914,18 @@ export default function ChatView(props: ChatViewProps) {
39063914
3907 const interruptContextRef = useRef({ activeThread, phase, setThreadError });3915 const interruptContextRef = useRef({ activeThread, phase, setThreadError });
3908 interruptContextRef.current = { activeThread, phase, setThreadError };3916 interruptContextRef.current = { activeThread, phase, setThreadError };
3917 const restoreQueuedMessagesRef = useRef<(messages: ReadonlyArray<QueuedComposerMessage>) => void>(
3918 () => {},
3919 );
3909 const onInterrupt = useCallback(async () => {3920 const onInterrupt = useCallback(async () => {
3910 const { activeThread, phase, setThreadError } = interruptContextRef.current;3921 const { activeThread, phase, setThreadError } = interruptContextRef.current;
3911 const input = buildRunningThreadTurnInterruptInput(activeThread, phase);3922 const input = buildRunningThreadTurnInterruptInput(activeThread, phase);
3912 if (!input || !activeThread) return;3923 if (!input || !activeThread) return;
3924 restoreQueuedMessagesRef.current(
3925 useQueuedMessageStore
3926 .getState()
3927 .drain(scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id))),
3928 );
3913 const result = await interruptThreadTurn({3929 const result = await interruptThreadTurn({
3914 environmentId: activeThread.environmentId,3930 environmentId: activeThread.environmentId,
3915 input,3931 input,
...@@ -7073,6 +7089,81 @@ export default function ChatView(props: ChatViewProps) {...@@ -7073,6 +7089,81 @@ export default function ChatView(props: ChatViewProps) {
7073 }7089 }
7074 };7090 };
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
7076 const onSend = async (7167 const onSend = async (
7077 e?: { preventDefault: () => void },7168 e?: { preventDefault: () => void },
7078 submissionIntent: ComposerSubmissionIntent = "foreground",7169 submissionIntent: ComposerSubmissionIntent = "foreground",
...@@ -7080,6 +7171,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -7080,6 +7171,8 @@ export default function ChatView(props: ChatViewProps) {
7080 annotation: PreviewAnnotationPayload;7171 annotation: PreviewAnnotationPayload;
7081 image: ComposerImageAttachment | null;7172 image: ComposerImageAttachment | null;
7082 },7173 },
7174 /** A queued message being sent now instead of the live composer draft. */
7175 queuedMessage?: QueuedComposerMessage,
7083 ) => {7176 ) => {
7084 e?.preventDefault();7177 e?.preventDefault();
7085 // Typed out in full rather than picked from the menu. Attachments or contexts7178 // Typed out in full rather than picked from the menu. Attachments or contexts
...@@ -7088,6 +7181,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7088,6 +7181,7 @@ export default function ChatView(props: ChatViewProps) {
7088 usageLimitsOffered &&7181 usageLimitsOffered &&
7089 usageLimitsKey !== null &&7182 usageLimitsKey !== null &&
7090 !directAnnotation &&7183 !directAnnotation &&
7184 !queuedMessage &&
7091 !composerHasNonPromptContent &&7185 !composerHasNonPromptContent &&
7092 isUsageLimitsCommand(promptRef.current)7186 isUsageLimitsCommand(promptRef.current)
7093 ) {7187 ) {
...@@ -7149,7 +7243,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -7149,7 +7243,9 @@ export default function ChatView(props: ChatViewProps) {
7149 return;7243 return;
7150 }7244 }
7151 if (activePendingProgress) {7245 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) {
7153 notifyDirectAnnotationAttached();7249 notifyDirectAnnotationAttached();
7154 return;7250 return;
7155 }7251 }
...@@ -7167,6 +7263,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -7167,6 +7263,8 @@ export default function ChatView(props: ChatViewProps) {
7167 terminalContexts: composerTerminalContexts,7263 terminalContexts: composerTerminalContexts,
7168 previewAnnotations: sendContextPreviewAnnotations,7264 previewAnnotations: sendContextPreviewAnnotations,
7169 reviewComments: composerReviewComments,7265 reviewComments: composerReviewComments,
7266 } = queuedMessage ?? sendCtx;
7267 const {
7170 selectedProvider: ctxSelectedProvider,7268 selectedProvider: ctxSelectedProvider,
7171 selectedModel: ctxSelectedModel,7269 selectedModel: ctxSelectedModel,
7172 selectedProviderModels: ctxSelectedProviderModels,7270 selectedProviderModels: ctxSelectedProviderModels,
...@@ -7209,11 +7307,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -7209,11 +7307,13 @@ export default function ChatView(props: ChatViewProps) {
7209 : sendContextPreviewAnnotations;7307 : sendContextPreviewAnnotations;
7210 // A direct "send annotation" writes the draft and sends in the same tick; the reference7308 // A direct "send annotation" writes the draft and sends in the same tick; the reference
7211 // must be in the text now, not after the next render.7309 // must be in the text now, not after the next render.
7212 const promptForSend = directAnnotation7310 const promptForSend = queuedMessage
7213 ? ensureInlineContextReferences(promptRef.current, [7311 ? queuedMessage.prompt
7214 previewAnnotationContextReference(directAnnotation.annotation),7312 : directAnnotation
7215 ])7313 ? ensureInlineContextReferences(promptRef.current, [
7216 : promptRef.current;7314 previewAnnotationContextReference(directAnnotation.annotation),
7315 ])
7316 : promptRef.current;
7217 const {7317 const {
7218 trimmedPrompt: trimmed,7318 trimmedPrompt: trimmed,
7219 sendableTerminalContexts: sendableComposerTerminalContexts,7319 sendableTerminalContexts: sendableComposerTerminalContexts,
...@@ -7234,7 +7334,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7234,7 +7334,7 @@ export default function ChatView(props: ChatViewProps) {
7234 composerReviewComments.length === 07334 composerReviewComments.length === 0
7235 ? parseCodexFeedbackCommand(trimmed)7335 ? parseCodexFeedbackCommand(trimmed)
7236 : null;7336 : null;
7237 if (feedbackCommand) {7337 if (feedbackCommand && !queuedMessage) {
7238 if (!isServerThread || activeThread.session === null) {7338 if (!isServerThread || activeThread.session === null) {
7239 toastManager.add(7339 toastManager.add(
7240 stackedThreadToast({7340 stackedThreadToast({
...@@ -7285,6 +7385,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7285,6 +7385,7 @@ export default function ChatView(props: ChatViewProps) {
7285 }7385 }
7286 if (7386 if (
7287 !directAnnotation &&7387 !directAnnotation &&
7388 !queuedMessage &&
7288 sendInteractionModeEnabled &&7389 sendInteractionModeEnabled &&
7289 showPlanFollowUpPrompt &&7390 showPlanFollowUpPrompt &&
7290 activeProposedPlan &&7391 activeProposedPlan &&
...@@ -7356,7 +7457,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7356,7 +7457,7 @@ export default function ChatView(props: ChatViewProps) {
7356 composerReviewComments.length === 07457 composerReviewComments.length === 0
7357 ? parseStandaloneComposerSlashCommand(trimmed)7458 ? parseStandaloneComposerSlashCommand(trimmed)
7358 : null;7459 : null;
7359 if (standaloneSlashCommand) {7460 if (standaloneSlashCommand && !queuedMessage) {
7360 handleInteractionModeChange(standaloneSlashCommand);7461 handleInteractionModeChange(standaloneSlashCommand);
7361 promptRef.current = "";7462 promptRef.current = "";
7362 clearComposerDraftContent(composerDraftTarget);7463 clearComposerDraftContent(composerDraftTarget);
...@@ -7377,6 +7478,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -7377,6 +7478,12 @@ export default function ChatView(props: ChatViewProps) {
7377 }),7478 }),
7378 );7479 );
7379 }7480 }
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 }
7380 return;7487 return;
7381 }7488 }
7382 if (!activeProject) {7489 if (!activeProject) {
...@@ -7389,6 +7496,36 @@ export default function ChatView(props: ChatViewProps) {...@@ -7389,6 +7496,36 @@ export default function ChatView(props: ChatViewProps) {
7389 );7496 );
7390 return;7497 return;
7391 }7498 }
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 }
7392 const threadIdForSend = activeThread.id;7529 const threadIdForSend = activeThread.id;
7393 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;7530 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
7394 const baseBranchForWorktree =7531 const baseBranchForWorktree =
...@@ -7444,6 +7581,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -7444,6 +7581,11 @@ export default function ChatView(props: ChatViewProps) {
7444 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,7581 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
7445 });7582 });
7446 if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {7583 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 }
7447 return;7589 return;
7448 }7590 }
74497591
...@@ -7464,10 +7606,41 @@ export default function ChatView(props: ChatViewProps) {...@@ -7464,10 +7606,41 @@ export default function ChatView(props: ChatViewProps) {
7464 };7606 };
74657607
7466 sendInFlightRef.current = true;7608 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 };
7467 const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities();7639 const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities();
7468 if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) {7640 if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) {
7469 sendInFlightRef.current = false;7641 sendInFlightRef.current = false;
7470 setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason);7642 setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason);
7643 abortQueuedReplay();
7471 return;7644 return;
7472 }7645 }
7473 const turnUsesAttachmentUploads =7646 const turnUsesAttachmentUploads =
...@@ -7487,15 +7660,26 @@ export default function ChatView(props: ChatViewProps) {...@@ -7487,15 +7660,26 @@ export default function ChatView(props: ChatViewProps) {
7487 if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) {7660 if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) {
7488 sendInFlightRef.current = false;7661 sendInFlightRef.current = false;
7489 setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason);7662 setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason);
7663 abortQueuedReplay();
7490 return;7664 return;
7491 }7665 }
7492 if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) {7666 if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) {
7493 sendInFlightRef.current = false;7667 sendInFlightRef.current = false;
7494 setThreadError(threadIdForSend, "Retry or remove failed uploads before sending.");7668 setThreadError(threadIdForSend, "Retry or remove failed uploads before sending.");
7669 abortQueuedReplay();
7495 return;7670 return;
7496 }7671 }
7497 }7672 }
74987673
7674 if (
7675 queuedMessage &&
7676 useQueuedMessageStore.getState().drainGeneration !== drainGenerationAtTake
7677 ) {
7678 sendInFlightRef.current = false;
7679 restoreQueuedMessagesToComposer([queuedMessage]);
7680 return;
7681 }
7682
7499 const resolvedSubmissionIntent =7683 const resolvedSubmissionIntent =
7500 submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";7684 submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";
7501 if (7685 if (
...@@ -7528,6 +7712,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7528,6 +7712,7 @@ export default function ChatView(props: ChatViewProps) {
7528 setDockedDraftHeroThreadKey((currentThreadKey) =>7712 setDockedDraftHeroThreadKey((currentThreadKey) =>
7529 currentThreadKey === activeThreadKey ? null : currentThreadKey,7713 currentThreadKey === activeThreadKey ? null : currentThreadKey,
7530 );7714 );
7715 abortQueuedReplay();
7531 return;7716 return;
7532 }7717 }
7533 beginLocalDispatch({7718 beginLocalDispatch({
...@@ -7628,9 +7813,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -7628,9 +7813,11 @@ export default function ChatView(props: ChatViewProps) {
7628 }),7813 }),
7629 );7814 );
7630 }7815 }
7631 promptRef.current = "";7816 if (!queuedMessage) {
7632 clearComposerDraftContent(composerDraftTarget);7817 promptRef.current = "";
7633 composerRef.current?.resetCursorState();7818 clearComposerDraftContent(composerDraftTarget);
7819 composerRef.current?.resetCursorState();
7820 }
76347821
7635 let firstComposerImageName: string | null = null;7822 let firstComposerImageName: string | null = null;
7636 if (composerImagesSnapshot.length > 0) {7823 if (composerImagesSnapshot.length > 0) {
...@@ -7854,7 +8041,24 @@ export default function ChatView(props: ChatViewProps) {...@@ -7854,7 +8041,24 @@ export default function ChatView(props: ChatViewProps) {
7854 }8041 }
78558042
7856 if (failure !== null) {8043 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 (
7858 promptRef.current.length === 0 &&8062 promptRef.current.length === 0 &&
7859 composerImagesRef.current.length === 0 &&8063 composerImagesRef.current.length === 0 &&
7860 composerFilesRef.current.length === 0 &&8064 composerFilesRef.current.length === 0 &&
...@@ -7920,6 +8124,75 @@ export default function ChatView(props: ChatViewProps) {...@@ -7920,6 +8124,75 @@ export default function ChatView(props: ChatViewProps) {
7920 }8124 }
7921 };8125 };
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
7923 const onRespondToApproval = useCallback(8196 const onRespondToApproval = useCallback(
7924 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {8197 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
7925 if (!activeThreadId) return;8198 if (!activeThreadId) return;
...@@ -9190,6 +9463,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -9190,6 +9463,9 @@ export default function ChatView(props: ChatViewProps) {
9190 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}9463 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
9191 topFadeEnabled={!hasTimelineTopBanner}9464 topFadeEnabled={!hasTimelineTopBanner}
9192 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}9465 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}
9466 queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages}
9467 onSteerQueuedMessage={onSteerQueuedMessage}
9468 onRemoveQueuedMessage={onRemoveQueuedMessage}
9193 />9469 />
91949470
9195 {/* scroll to end pill — shown when user has scrolled away from the live edge */}9471 {/* 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(...@@ -1166,7 +1166,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1166 isEnvironmentUnavailable: boolean;1166 isEnvironmentUnavailable: boolean;
1167 hasSendableContent: boolean;1167 hasSendableContent: boolean;
1168 preserveComposerFocusOnPointerDown?: boolean;1168 preserveComposerFocusOnPointerDown?: boolean;
1169 showSendWhileRunning?: boolean;
1170 onPreviousPendingQuestion: () => void;1169 onPreviousPendingQuestion: () => void;
1171 onInterrupt: () => void;1170 onInterrupt: () => void;
1172 onImplementPlanInNewThread: () => void;1171 onImplementPlanInNewThread: () => void;
...@@ -1200,7 +1199,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -1200,7 +1199,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1200 isPreparingWorktree={props.isPreparingWorktree}1199 isPreparingWorktree={props.isPreparingWorktree}
1201 hasSendableContent={props.hasSendableContent}1200 hasSendableContent={props.hasSendableContent}
1202 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}1201 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
1203 showSendWhileRunning={props.showSendWhileRunning ?? false}
1204 onPreviousPendingQuestion={props.onPreviousPendingQuestion}1202 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
1205 onInterrupt={props.onInterrupt}1203 onInterrupt={props.onInterrupt}
1206 onImplementPlanInNewThread={props.onImplementPlanInNewThread}1204 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
...@@ -6845,7 +6843,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6845,7 +6843,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6845 isPreparingWorktree={isPreparingWorktree}6843 isPreparingWorktree={isPreparingWorktree}
6846 hasSendableContent={composerSendState.hasSendableContent}6844 hasSendableContent={composerSendState.hasSendableContent}
6847 preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}6845 preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
6848 showSendWhileRunning={isMobileViewport}
6849 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}6846 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
6850 onInterrupt={handleInterruptPrimaryAction}6847 onInterrupt={handleInterruptPrimaryAction}
6851 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}6848 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx+6-14
...@@ -44,7 +44,7 @@ function renderPendingActions(isRunning: boolean) {...@@ -44,7 +44,7 @@ function renderPendingActions(isRunning: boolean) {
44 );44 );
45}45}
4646
47function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) {47function renderRunningActions(hasSendableContent: boolean) {
48 return renderToStaticMarkup(48 return renderToStaticMarkup(
49 createElement(ComposerPrimaryActions, {49 createElement(ComposerPrimaryActions, {
50 compact: true,50 compact: true,
...@@ -58,7 +58,6 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent:...@@ -58,7 +58,6 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent:
58 isEnvironmentUnavailable: false,58 isEnvironmentUnavailable: false,
59 isPreparingWorktree: false,59 isPreparingWorktree: false,
60 hasSendableContent,60 hasSendableContent,
61 showSendWhileRunning,
62 onPreviousPendingQuestion: () => {},61 onPreviousPendingQuestion: () => {},
63 onInterrupt: () => {},62 onInterrupt: () => {},
64 onImplementPlanInNewThread: () => {},63 onImplementPlanInNewThread: () => {},
...@@ -125,25 +124,18 @@ describe("ComposerPrimaryActions", () => {...@@ -125,25 +124,18 @@ describe("ComposerPrimaryActions", () => {
125 expect(markup).not.toContain("stage-nightly");124 expect(markup).not.toContain("stage-nightly");
126 });125 });
127126
128 it("only renders stop while running when Enter-to-send is available", () => {127 it("renders a queue action alongside stop while running with a sendable draft", () => {
129 const markup = renderRunningActions(false, true);128 const markup = renderRunningActions(true);
130129
131 expect(markup).toContain('aria-label="Stop generation"');130 expect(markup).toContain('aria-label="Stop generation"');
132 expect(markup).not.toContain('aria-label="Send message"');131 expect(markup).toContain('aria-label="Queue 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"');
140 expect(markup).toContain('type="submit"');132 expect(markup).toContain('type="submit"');
141 });133 });
142134
143 it("keeps stop as the only action while running with an empty composer", () => {135 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
146 expect(markup).toContain('aria-label="Stop generation"');138 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"');
148 });140 });
149});141});
apps/web/src/components/chat/ComposerPrimaryActions.tsx+7-7
...@@ -29,9 +29,6 @@ interface ComposerPrimaryActionsProps {...@@ -29,9 +29,6 @@ interface ComposerPrimaryActionsProps {
29 isPreparingWorktree: boolean;29 isPreparingWorktree: boolean;
30 hasSendableContent: boolean;30 hasSendableContent: boolean;
31 preserveComposerFocusOnPointerDown?: boolean;31 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;
35 onPreviousPendingQuestion: () => void;32 onPreviousPendingQuestion: () => void;
36 onInterrupt: () => void;33 onInterrupt: () => void;
37 onImplementPlanInNewThread: () => void;34 onImplementPlanInNewThread: () => void;
...@@ -72,7 +69,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -72,7 +69,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
72 isPreparingWorktree,69 isPreparingWorktree,
73 hasSendableContent,70 hasSendableContent,
74 preserveComposerFocusOnPointerDown = false,71 preserveComposerFocusOnPointerDown = false,
75 showSendWhileRunning = false,
76 onPreviousPendingQuestion,72 onPreviousPendingQuestion,
77 onInterrupt,73 onInterrupt,
78 onImplementPlanInNewThread,74 onImplementPlanInNewThread,
...@@ -93,7 +89,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -93,7 +89,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
93 "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",89 "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",
94 insidePendingAction90 insidePendingAction
95 ? "size-8 sm:size-7"91 ? "size-8 sm:size-7"
96 : showSendWhileRunning && hasSendableContent92 : hasSendableContent
97 ? "size-9 sm:size-8"93 ? "size-9 sm:size-8"
98 : "size-8 sm:h-8 sm:w-8",94 : "size-8 sm:h-8 sm:w-8",
99 )}95 )}
...@@ -247,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -247,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
247 ? "Preparing worktree"243 ? "Preparing worktree"
248 : isSendBusy244 : isSendBusy
249 ? "Sending"245 ? "Sending"
250 : "Send message"246 : isRunning
247 ? "Queue message"
248 : "Send message"
251 }249 }
252 >250 >
253 {stageBackdropVariant ? (251 {stageBackdropVariant ? (
...@@ -275,10 +273,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -275,10 +273,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
275 return sendButton;273 return sendButton;
276 }274 }
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.
278 return (278 return (
279 <>279 <>
280 {renderStopGenerationButton(false)}280 {renderStopGenerationButton(false)}
281 {showSendWhileRunning && hasSendableContent ? sendButton : null}281 {hasSendableContent ? sendButton : null}
282 </>282 </>
283 );283 );
284});284});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+34
...@@ -1092,6 +1092,40 @@ describe("resolveAssistantMessageCopyState", () => {...@@ -1092,6 +1092,40 @@ describe("resolveAssistantMessageCopyState", () => {
1092});1092});
10931093
1094describe("deriveMessagesTimelineRows", () => {1094describe("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
1095 it("shows the worktree setup card instead of the working placeholder", () => {1129 it("shows the worktree setup card instead of the working placeholder", () => {
1096 const snapshot: WorktreeSetupSnapshot = {1130 const snapshot: WorktreeSetupSnapshot = {
1097 threadId: ThreadId.make("thread-setup"),1131 threadId: ThreadId.make("thread-setup"),
apps/web/src/components/chat/MessagesTimeline.logic.ts+27-2
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 type WorkLogEntry,28 type WorkLogEntry,
29} from "../../session-logic";29} from "../../session-logic";
30import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";30import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
31import type { QueuedComposerMessage } from "../../queuedMessageStore";
31import {32import {
32 type MessageId,33 type MessageId,
33 type OrchestrationLatestTurn,34 type OrchestrationLatestTurn,
...@@ -402,6 +403,14 @@ export type MessagesTimelineRow =...@@ -402,6 +403,14 @@ export type MessagesTimelineRow =
402 snapshot: WorktreeSetupSnapshot;403 snapshot: WorktreeSetupSnapshot;
403 /** The agent already started; render only the script row under the turn header. */404 /** The agent already started; render only the script row under the turn header. */
404 embedded: boolean;405 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;
405 };414 };
406415
407export interface StableMessagesTimelineRowsState {416export interface StableMessagesTimelineRowsState {
...@@ -872,6 +881,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -872,6 +881,8 @@ export function deriveMessagesTimelineRows(input: {
872 liveAgentTaskIds?: ReadonlySet<string> | undefined;881 liveAgentTaskIds?: ReadonlySet<string> | undefined;
873 /** Live bootstrap progress. Renders a stage card under the first user message. */882 /** Live bootstrap progress. Renders a stage card under the first user message. */
874 worktreeSetup?: WorktreeSetupSnapshot | null;883 worktreeSetup?: WorktreeSetupSnapshot | null;
884 /** Messages sent during the running turn, rendered after the live rows. */
885 queuedMessages?: ReadonlyArray<QueuedComposerMessage>;
875}): MessagesTimelineRow[] {886}): MessagesTimelineRow[] {
876 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();887 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
877 for (const summary of input.turnDiffSummaries) {888 for (const summary of input.turnDiffSummaries) {
...@@ -1329,8 +1340,17 @@ export function deriveMessagesTimelineRows(input: {...@@ -1329,8 +1340,17 @@ export function deriveMessagesTimelineRows(input: {
1329 createdAt: input.activeTurnStartedAt,1340 createdAt: input.activeTurnStartedAt,
1330 });1341 });
1331 }1342 }
13321343 const rows = attachTrailingToolGroupsToAssistant(nextRows);
1333 return 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;
1334}1354}
13351355
1336export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";1356export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
...@@ -1468,6 +1488,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1468,6 +1488,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1468 case "proposed-plan":1488 case "proposed-plan":
1469 return a.proposedPlan === (b as typeof a).proposedPlan;1489 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
1471 case "work": {1496 case "work": {
1472 const bw = b as typeof a;1497 const bw = b as typeof a;
1473 return (1498 return (
apps/web/src/components/chat/MessagesTimeline.tsx+117
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { ArrowUpIcon, ClockIcon } from "lucide-react";
1import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview";2import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview";
2import { useRightPanelStore } from "~/rightPanelStore";3import { useRightPanelStore } from "~/rightPanelStore";
3import {4import {
...@@ -45,6 +46,8 @@ import {...@@ -45,6 +46,8 @@ import {
4546
46const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();47const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
47const NOOP_OPEN_AGENTS = () => {};48const NOOP_OPEN_AGENTS = () => {};
49const EMPTY_QUEUED_MESSAGES: ReadonlyArray<QueuedComposerMessage> = [];
50const NOOP_QUEUED_MESSAGE_ACTION = (_id: string) => {};
48const NOOP_USE_ARTIFACT_TEMPLATE = () => {};51const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
49const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};52const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
50import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";53import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
...@@ -131,6 +134,7 @@ import type {...@@ -131,6 +134,7 @@ import type {
131 KnownComposerContextRecord,134 KnownComposerContextRecord,
132} from "@t3tools/contracts";135} from "@t3tools/contracts";
133import { Button } from "../ui/button";136import { Button } from "../ui/button";
137import type { QueuedComposerMessage } from "../../queuedMessageStore";
134import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls";138import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls";
135import { MediaVideoPlayer } from "../media/MediaVideoPlayer";139import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
136import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";140import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
...@@ -280,6 +284,8 @@ interface TimelineRowSharedState {...@@ -280,6 +284,8 @@ interface TimelineRowSharedState {
280 onCancelWorktreeSetup: (() => void) | null;284 onCancelWorktreeSetup: (() => void) | null;
281 onWorktreeSetupWorkLocally: (() => void) | null;285 onWorktreeSetupWorkLocally: (() => void) | null;
282 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;286 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;
287 onSteerQueuedMessage: (id: string) => void;
288 onRemoveQueuedMessage: (id: string) => void;
283}289}
284290
285interface TimelineRowActivityState {291interface TimelineRowActivityState {
...@@ -432,6 +438,10 @@ interface MessagesTimelineProps {...@@ -432,6 +438,10 @@ interface MessagesTimelineProps {
432 topFadeEnabled?: boolean;438 topFadeEnabled?: boolean;
433 /** Non-null when older turns exist beyond the loaded window. */439 /** Non-null when older turns exist beyond the loaded window. */
434 loadEarlier?: CitationHistoryPage | null;440 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;
435}445}
436446
437// ---------------------------------------------------------------------------447// ---------------------------------------------------------------------------
...@@ -484,6 +494,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -484,6 +494,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
484 hideEmptyPlaceholder = false,494 hideEmptyPlaceholder = false,
485 topFadeEnabled = false,495 topFadeEnabled = false,
486 loadEarlier = null,496 loadEarlier = null,
497 queuedMessages = EMPTY_QUEUED_MESSAGES,
498 onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
499 onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
487}: MessagesTimelineProps) {500}: MessagesTimelineProps) {
488 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());501 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
489 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());502 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
...@@ -707,6 +720,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -707,6 +720,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
707 supportsConversationRollback,720 supportsConversationRollback,
708 liveAgentTaskIds,721 liveAgentTaskIds,
709 worktreeSetup,722 worktreeSetup,
723 queuedMessages,
710 },724 },
711 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot725 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
712 ? previous.projection726 ? previous.projection
...@@ -729,6 +743,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -729,6 +743,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
729 supportsConversationRollback,743 supportsConversationRollback,
730 liveAgentTaskIds,744 liveAgentTaskIds,
731 worktreeSetup,745 worktreeSetup,
746 queuedMessages,
732 ]);747 ]);
733 const rows = useStableRows(rawRows, listIdentityKey);748 const rows = useStableRows(rawRows, listIdentityKey);
734 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);749 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
...@@ -924,6 +939,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -924,6 +939,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
924 onCancelWorktreeSetup: onCancelWorktreeSetup ?? null,939 onCancelWorktreeSetup: onCancelWorktreeSetup ?? null,
925 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,940 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
926 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,941 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
942 onSteerQueuedMessage,
943 onRemoveQueuedMessage,
927 }),944 }),
928 [945 [
929 readyCitationRequest,946 readyCitationRequest,
...@@ -954,6 +971,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -954,6 +971,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
954 onCancelWorktreeSetup,971 onCancelWorktreeSetup,
955 onWorktreeSetupWorkLocally,972 onWorktreeSetupWorkLocally,
956 onOpenWorktreeSetupTerminal,973 onOpenWorktreeSetupTerminal,
974 onSteerQueuedMessage,
975 onRemoveQueuedMessage,
957 ],976 ],
958 );977 );
959 const activityState = useMemo<TimelineRowActivityState>(978 const activityState = useMemo<TimelineRowActivityState>(
...@@ -1445,6 +1464,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1445,6 +1464,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1445 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}1464 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
1446 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}1465 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
1447 {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null}1466 {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null}
1467 {row.kind === "queued-message" ? <QueuedMessageTimelineRow row={row} /> : null}
1448 </div>1468 </div>
1449 );1469 );
1450});1470});
...@@ -1474,6 +1494,103 @@ function WorktreeSetupTimelineRow({...@@ -1474,6 +1494,103 @@ function WorktreeSetupTimelineRow({
1474 );1494 );
1475}1495}
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
1477function ContextCompactionTimelineRow({1594function ContextCompactionTimelineRow({
1478 row,1595 row,
1479}: {1596}: {
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....@@ -29,6 +29,14 @@ also send files to T3 Code through another app's system share sheet.
2929
30See [images and videos](#images-and-videos-in-messages) for previewing and saving media.30See [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
32## Queue messages offline on mobile40## Queue messages offline on mobile
3341
34Mobile keeps local copies of draft attachments, so you can preview them and queue42Mobile keeps local copies of draft attachments, so you can preview them and queue