| author | |
| committer | |
| log | cc839c42b1adfddd059142bb6d7151719080c4ea |
| tree | 9cc405cc1d5071ff8cf72517a16aa52d4a85dc63 |
| parent | 2c19283afed1e3b69a9af33360980527aeb37494 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 312 | import { | ||
| 313 | isQueuedMessageDue, | ||
| 314 | latestCompletedToolActivityId, | ||
| 315 | type QueuedComposerMessage, | ||
| 316 | useQueuedMessages, | ||
| 317 | useQueuedMessageStore, | ||
| 318 | } from "../queuedMessageStore"; | ||
| 312 | import { type ReviewCommentContext } from "../reviewCommentContext"; | 319 | import { type ReviewCommentContext } from "../reviewCommentContext"; |
| 313 | import { environmentCatalog } from "../connection/catalog"; | 320 | import { environmentCatalog } from "../connection/catalog"; |
| 314 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; | 321 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; |
| ... | @@ -512,6 +519,7 @@ import { | ... | @@ -512,6 +519,7 @@ import { |
| 512 | } from "./chat/composerPromptHistory"; | 519 | } from "./chat/composerPromptHistory"; |
| 513 | 520 | ||
| 514 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | 521 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 522 | const EMPTY_QUEUED_MESSAGES: QueuedComposerMessage[] = []; | ||
| 515 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 523 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 516 | const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; | 524 | const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; |
| 517 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | 525 | const 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) { |
| 3906 | 3914 | ||
| 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 | }; |
| 7075 | 7091 | ||
| 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 contexts | 7178 | // 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 reference | 7308 | // 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 = directAnnotation | 7310 | 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 === 0 | 7334 | 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 === 0 | 7457 | 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 | } |
| 7449 | 7591 | ||
| ... | @@ -7464,10 +7606,41 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7464,10 +7606,41 @@ export default function ChatView(props: ChatViewProps) { |
| 7464 | }; | 7606 | }; |
| 7465 | 7607 | ||
| 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 | } |
| 7498 | 7673 | ||
| 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 | } | ||
| 7634 | 7821 | ||
| 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 | } |
| 7855 | 8042 | ||
| 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 | }; |
| 7922 | 8126 | ||
| 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 | /> |
| 9194 | 9470 | ||
| 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 | } |
| 46 | 46 | ||
| 47 | function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { | 47 | function 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 | }); |
| 127 | 126 | ||
| 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); |
| 130 | 129 | ||
| 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 | }); |
| 142 | 134 | ||
| 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); |
| 145 | 137 | ||
| 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 | insidePendingAction | 90 | insidePendingAction |
| 95 | ? "size-8 sm:size-7" | 91 | ? "size-8 sm:size-7" |
| 96 | : showSendWhileRunning && hasSendableContent | 92 | : 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 | : isSendBusy | 244 | : 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 | } |
| 277 | 275 | ||
| 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 | }); |
| 1093 | 1093 | ||
| 1094 | describe("deriveMessagesTimelineRows", () => { | 1094 | describe("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"; |
| 30 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; | 30 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; |
| 31 | import type { QueuedComposerMessage } from "../../queuedMessageStore"; | ||
| 31 | import { | 32 | import { |
| 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 | }; |
| 406 | 415 | ||
| 407 | export interface StableMessagesTimelineRowsState { | 416 | export 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 | } |
| 1332 | 1343 | 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 | } |
| 1335 | 1355 | ||
| 1336 | export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; | 1356 | export 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; |
| 1470 | 1490 | ||
| 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 @@ |
| 1 | import { ArrowUpIcon, ClockIcon } from "lucide-react"; | ||
| 1 | import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; | 2 | import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; |
| 2 | import { useRightPanelStore } from "~/rightPanelStore"; | 3 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 3 | import { | 4 | import { |
| ... | @@ -45,6 +46,8 @@ import { | ... | @@ -45,6 +46,8 @@ import { |
| 45 | 46 | ||
| 46 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); | 47 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); |
| 47 | const NOOP_OPEN_AGENTS = () => {}; | 48 | const NOOP_OPEN_AGENTS = () => {}; |
| 49 | const EMPTY_QUEUED_MESSAGES: ReadonlyArray<QueuedComposerMessage> = []; | ||
| 50 | const NOOP_QUEUED_MESSAGE_ACTION = (_id: string) => {}; | ||
| 48 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; | 51 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; |
| 49 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | 52 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 50 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 53 | import { 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"; |
| 133 | import { Button } from "../ui/button"; | 136 | import { Button } from "../ui/button"; |
| 137 | import type { QueuedComposerMessage } from "../../queuedMessageStore"; | ||
| 134 | import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; | 138 | import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; |
| 135 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; | 139 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; |
| 136 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; | 140 | import { 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 | } |
| 284 | 290 | ||
| 285 | interface TimelineRowActivityState { | 291 | interface 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 | } |
| 436 | 446 | ||
| 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 === workspaceRoot | 725 | previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot |
| 712 | ? previous.projection | 726 | ? 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 | } |
| 1476 | 1496 | ||
| 1497 | /** A message waiting for the running turn: a dashed user bubble with icon actions inside it. */ | ||
| 1498 | function 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 | |||
| 1477 | function ContextCompactionTimelineRow({ | 1594 | function ContextCompactionTimelineRow({ |
| 1478 | row, | 1595 | row, |
| 1479 | }: { | 1596 | }: { |
apps/web/src/queuedMessageStore.test.ts created+143| ... | @@ -0,0 +1,143 @@ | ||
| 1 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | isQueuedMessageDue, | ||
| 5 | latestCompletedToolActivityId, | ||
| 6 | useQueuedMessageStore, | ||
| 7 | type QueuedComposerMessage, | ||
| 8 | } from "./queuedMessageStore"; | ||
| 9 | |||
| 10 | function 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 | |||
| 24 | describe("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 | |||
| 103 | describe("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 @@ | ||
| 1 | import type { PreviewAnnotationPayload } from "@t3tools/contracts"; | ||
| 2 | import { create } from "zustand"; | ||
| 3 | |||
| 4 | import type { ComposerSubmissionIntent } from "./composer-logic"; | ||
| 5 | import type { ComposerFileAttachment, ComposerImageAttachment } from "./composerDraftStore"; | ||
| 6 | import type { TerminalContextDraft } from "./lib/terminalContext"; | ||
| 7 | import { randomUUID } from "./lib/utils"; | ||
| 8 | import 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 | */ | ||
| 15 | export 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 | |||
| 38 | interface 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 | |||
| 68 | const EMPTY_QUEUE: QueuedComposerMessage[] = []; | ||
| 69 | |||
| 70 | /** In-memory only: a queued message is a live intent, not a draft worth persisting. */ | ||
| 71 | export 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 | */ | ||
| 160 | export 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 | */ | ||
| 188 | export 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 | |||
| 199 | export 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. |
| 29 | 29 | ||
| 30 | See [images and videos](#images-and-videos-in-messages) for previewing and saving media. | 30 | See [images and videos](#images-and-videos-in-messages) for previewing and saving media. |
| 31 | 31 | ||
| 32 | ## Send while the agent is working | ||
| 33 | |||
| 34 | A message sent during a running turn waits at the end of the conversation as a | ||
| 35 | dashed bubble. It goes out on its own when the agent finishes its next tool | ||
| 36 | call, or when the turn ends. Use the arrow under the bubble to send it right | ||
| 37 | away, or the X to move it back into the composer. Stop returns every queued | ||
| 38 | message to the composer. | ||
| 39 | |||
| 32 | ## Queue messages offline on mobile | 40 | ## Queue messages offline on mobile |
| 33 | 41 | ||
| 34 | Mobile keeps local copies of draft attachments, so you can preview them and queue | 42 | Mobile keeps local copies of draft attachments, so you can preview them and queue |