| author | |
| committer | |
| log | 295d7cba09bb4b4084482a74b5a42a0cac72a0b1 |
| tree | 05144bc2d2589b46c128ea589da2cb9a2ba0384b |
| parent | 81e0491d2bdec153bf1ee12d4e1deab3909b7428 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>11 files changed, 892 insertions(+), 302 deletions(-)
apps/web/src/components/ChatView.tsx+61-147| ... | ... | @@ -324,12 +324,13 @@ import { |
| 324 | 324 | terminalContextReference, |
| 325 | 325 | } from "../lib/composerContextRecords"; |
| 326 | 326 | import { |
| 327 | isQueuedMessageDue, | |
| 328 | 327 | latestCompletedToolActivityId, |
| 329 | 328 | type QueuedComposerMessage, |
| 329 | type QueuedMessageSendSettings, | |
| 330 | 330 | useQueuedMessages, |
| 331 | 331 | useQueuedMessageStore, |
| 332 | 332 | } from "../queuedMessageStore"; |
| 333 | import { sendQueuedMessage } from "./chat/sendQueuedMessage"; | |
| 333 | 334 | import { type ReviewCommentContext } from "../reviewCommentContext"; |
| 334 | 335 | import { environmentCatalog } from "../connection/catalog"; |
| 335 | 336 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; |
| ... | ... | @@ -7248,10 +7249,27 @@ export default function ChatView(props: ChatViewProps) { |
| 7248 | 7249 | }; |
| 7249 | 7250 | |
| 7250 | 7251 | const queuedMessages = useQueuedMessages(activeThreadKey ?? ""); |
| 7251 | // Puts queued messages back into the composer, e.g. after Stop or a failed | |
| 7252 | // send. Prompts join with blank lines; attachments and contexts are added. | |
| 7252 | // The composer's model and modes, as a queued message keeps them for its send. | |
| 7253 | const readComposerSendSettings = ( | |
| 7254 | sendCtx: ReturnType<ChatComposerHandle["getSendContext"]>, | |
| 7255 | ): QueuedMessageSendSettings => ({ | |
| 7256 | modelSelection: sendCtx.selectedModelSelection, | |
| 7257 | runtimeMode, | |
| 7258 | interactionMode: sendCtx.interactionMode, | |
| 7259 | promptEffort: resolvePromptInjectedEffort( | |
| 7260 | getProviderModelCapabilities( | |
| 7261 | sendCtx.selectedProviderModels, | |
| 7262 | sendCtx.selectedModel, | |
| 7263 | sendCtx.selectedProvider, | |
| 7264 | ), | |
| 7265 | sendCtx.selectedPromptEffort, | |
| 7266 | ), | |
| 7267 | }); | |
| 7268 | // Puts queued messages back into the composer after Stop or Cancel. Prompts | |
| 7269 | // join with blank lines; attachments and contexts are added. | |
| 7253 | 7270 | const restoreQueuedMessagesToComposer = (messages: ReadonlyArray<QueuedComposerMessage>) => { |
| 7254 | if (messages.length === 0) return; | |
| 7271 | const [firstMessage] = messages; | |
| 7272 | if (!firstMessage) return; | |
| 7255 | 7273 | const prompts = [promptRef.current, ...messages.map((message) => message.prompt)] |
| 7256 | 7274 | .map((prompt) => prompt.trim()) |
| 7257 | 7275 | .filter((prompt) => prompt.length > 0); |
| ... | ... | @@ -7279,6 +7297,8 @@ export default function ChatView(props: ChatViewProps) { |
| 7279 | 7297 | if (restoredImages.length > 0) addComposerDraftImages(composerDraftTarget, restoredImages); |
| 7280 | 7298 | if (restoredFiles.length > 0) addComposerDraftFiles(composerDraftTarget, restoredFiles); |
| 7281 | 7299 | if (overflow.length > 0 && activeThreadKey) { |
| 7300 | // The overflow is the rest of the restored draft, so it follows the composer. | |
| 7301 | const sendCtx = composerRef.current?.getSendContext(); | |
| 7282 | 7302 | useQueuedMessageStore.getState().enqueue(activeThreadKey, { |
| 7283 | 7303 | prompt: "", |
| 7284 | 7304 | images: overflow.filter((attachment) => attachment.type === "image"), |
| ... | ... | @@ -7286,7 +7306,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7286 | 7306 | terminalContexts: [], |
| 7287 | 7307 | previewAnnotations: [], |
| 7288 | 7308 | reviewComments: [], |
| 7289 | submissionIntent: "foreground", | |
| 7309 | sendSettings: sendCtx ? readComposerSendSettings(sendCtx) : firstMessage.sendSettings, | |
| 7290 | 7310 | queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), |
| 7291 | 7311 | // Restoration is not a send. The user decides when the overflow goes. |
| 7292 | 7312 | holdUntilUserAction: true, |
| ... | ... | @@ -7329,8 +7349,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7329 | 7349 | annotation: PreviewAnnotationPayload; |
| 7330 | 7350 | image: ComposerImageAttachment | null; |
| 7331 | 7351 | }, |
| 7332 | /** A queued message being sent now instead of the live composer draft. */ | |
| 7333 | queuedMessage?: QueuedComposerMessage, | |
| 7334 | 7352 | ) => { |
| 7335 | 7353 | e?.preventDefault(); |
| 7336 | 7354 | // Typed out in full rather than picked from the menu. Attachments or contexts |
| ... | ... | @@ -7339,7 +7357,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7339 | 7357 | usageLimitsOffered && |
| 7340 | 7358 | usageLimitsKey !== null && |
| 7341 | 7359 | !directAnnotation && |
| 7342 | !queuedMessage && | |
| 7343 | 7360 | !composerHasNonPromptContent && |
| 7344 | 7361 | isUsageLimitsCommand(promptRef.current) |
| 7345 | 7362 | ) { |
| ... | ... | @@ -7401,9 +7418,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7401 | 7418 | return; |
| 7402 | 7419 | } |
| 7403 | 7420 | if (activePendingProgress) { |
| 7404 | // A queued message waits until the question is answered; it must not | |
| 7405 | // be submitted as the answer. | |
| 7406 | if (directAnnotation || queuedMessage) { | |
| 7421 | if (directAnnotation) { | |
| 7407 | 7422 | notifyDirectAnnotationAttached(); |
| 7408 | 7423 | return; |
| 7409 | 7424 | } |
| ... | ... | @@ -7415,7 +7430,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7415 | 7430 | notifyDirectAnnotationAttached(); |
| 7416 | 7431 | return; |
| 7417 | 7432 | } |
| 7418 | const multipleModelSelections = queuedMessage ? null : sendCtx.multipleModelSelections; | |
| 7433 | const multipleModelSelections = sendCtx.multipleModelSelections; | |
| 7419 | 7434 | if ( |
| 7420 | 7435 | multipleModelSelections !== null && |
| 7421 | 7436 | serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true |
| ... | ... | @@ -7446,8 +7461,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7446 | 7461 | terminalContexts: composerTerminalContexts, |
| 7447 | 7462 | previewAnnotations: sendContextPreviewAnnotations, |
| 7448 | 7463 | reviewComments: composerReviewComments, |
| 7449 | } = queuedMessage ?? sendCtx; | |
| 7450 | const { | |
| 7451 | 7464 | selectedProvider: ctxSelectedProvider, |
| 7452 | 7465 | selectedModel: ctxSelectedModel, |
| 7453 | 7466 | selectedProviderModels: ctxSelectedProviderModels, |
| ... | ... | @@ -7490,13 +7503,11 @@ export default function ChatView(props: ChatViewProps) { |
| 7490 | 7503 | : sendContextPreviewAnnotations; |
| 7491 | 7504 | // A direct "send annotation" writes the draft and sends in the same tick; the reference |
| 7492 | 7505 | // must be in the text now, not after the next render. |
| 7493 | const promptForSend = queuedMessage | |
| 7494 | ? queuedMessage.prompt | |
| 7495 | : directAnnotation | |
| 7496 | ? ensureInlineContextReferences(promptRef.current, [ | |
| 7497 | previewAnnotationContextReference(directAnnotation.annotation), | |
| 7498 | ]) | |
| 7499 | : promptRef.current; | |
| 7506 | const promptForSend = directAnnotation | |
| 7507 | ? ensureInlineContextReferences(promptRef.current, [ | |
| 7508 | previewAnnotationContextReference(directAnnotation.annotation), | |
| 7509 | ]) | |
| 7510 | : promptRef.current; | |
| 7500 | 7511 | const { |
| 7501 | 7512 | trimmedPrompt: trimmed, |
| 7502 | 7513 | sendableTerminalContexts: sendableComposerTerminalContexts, |
| ... | ... | @@ -7517,7 +7528,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7517 | 7528 | composerReviewComments.length === 0 |
| 7518 | 7529 | ? parseCodexFeedbackCommand(trimmed) |
| 7519 | 7530 | : null; |
| 7520 | if (feedbackCommand && !queuedMessage && multipleModelSelections === null) { | |
| 7531 | if (feedbackCommand && multipleModelSelections === null) { | |
| 7521 | 7532 | if (!isServerThread || activeThread.session === null) { |
| 7522 | 7533 | toastManager.add( |
| 7523 | 7534 | stackedThreadToast({ |
| ... | ... | @@ -7568,7 +7579,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7568 | 7579 | } |
| 7569 | 7580 | if ( |
| 7570 | 7581 | !directAnnotation && |
| 7571 | !queuedMessage && | |
| 7572 | 7582 | sendInteractionModeEnabled && |
| 7573 | 7583 | showPlanFollowUpPrompt && |
| 7574 | 7584 | activeProposedPlan && |
| ... | ... | @@ -7640,7 +7650,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7640 | 7650 | composerReviewComments.length === 0 |
| 7641 | 7651 | ? parseStandaloneComposerSlashCommand(trimmed) |
| 7642 | 7652 | : null; |
| 7643 | if (standaloneSlashCommand && !queuedMessage && multipleModelSelections === null) { | |
| 7653 | if (standaloneSlashCommand && multipleModelSelections === null) { | |
| 7644 | 7654 | handleInteractionModeChange(standaloneSlashCommand); |
| 7645 | 7655 | promptRef.current = ""; |
| 7646 | 7656 | clearComposerDraftContent(composerDraftTarget); |
| ... | ... | @@ -7661,12 +7671,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7661 | 7671 | }), |
| 7662 | 7672 | ); |
| 7663 | 7673 | } |
| 7664 | // A queued message whose only content expired would retry on every | |
| 7665 | // boundary and block the rest of the queue. Nothing sendable is left | |
| 7666 | // in it, so drop it and let the queue move on. | |
| 7667 | if (queuedMessage && activeThreadKey) { | |
| 7668 | useQueuedMessageStore.getState().remove(activeThreadKey, queuedMessage.id); | |
| 7669 | } | |
| 7670 | 7674 | return; |
| 7671 | 7675 | } |
| 7672 | 7676 | if (!activeProject) { |
| ... | ... | @@ -7679,14 +7683,26 @@ export default function ChatView(props: ChatViewProps) { |
| 7679 | 7683 | ); |
| 7680 | 7684 | return; |
| 7681 | 7685 | } |
| 7686 | // A queued message that will still leave on its own goes first, so a new | |
| 7687 | // send lines up behind it instead of overtaking it. | |
| 7688 | const queueStillSending = | |
| 7689 | activeThreadKey !== null && | |
| 7690 | (useQueuedMessageStore.getState().queuesByThreadKey[activeThreadKey] ?? []).some( | |
| 7691 | (message) => message.sending !== undefined || !message.holdUntilUserAction, | |
| 7692 | ); | |
| 7682 | 7693 | if ( |
| 7683 | !queuedMessage && | |
| 7684 | 7694 | !directAnnotation && |
| 7685 | phase === "running" && | |
| 7686 | 7695 | activeThreadKey && |
| 7687 | (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate") | |
| 7696 | (queueStillSending || | |
| 7697 | (phase === "running" && | |
| 7698 | (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate"))) | |
| 7688 | 7699 | ) { |
| 7689 | if (composerRef.current?.validateProviderInput(promptForSend) === false) { | |
| 7700 | const sendSettings = readComposerSendSettings(sendCtx); | |
| 7701 | if ( | |
| 7702 | composerRef.current?.validateProviderInput( | |
| 7703 | applyClaudePromptEffortPrefix(promptForSend, sendSettings.promptEffort), | |
| 7704 | ) === false | |
| 7705 | ) { | |
| 7690 | 7706 | return; |
| 7691 | 7707 | } |
| 7692 | 7708 | useQueuedMessageStore.getState().enqueue(activeThreadKey, { |
| ... | ... | @@ -7696,7 +7712,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7696 | 7712 | terminalContexts: [...composerTerminalContexts], |
| 7697 | 7713 | previewAnnotations: [...composerPreviewAnnotations], |
| 7698 | 7714 | reviewComments: [...composerReviewComments], |
| 7699 | submissionIntent, | |
| 7715 | sendSettings, | |
| 7700 | 7716 | queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), |
| 7701 | 7717 | createdAt: new Date().toISOString(), |
| 7702 | 7718 | }); |
| ... | ... | @@ -7766,11 +7782,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7766 | 7782 | text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, |
| 7767 | 7783 | }); |
| 7768 | 7784 | if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { |
| 7769 | // A queued message that no longer fits is held at the head for the | |
| 7770 | // user to edit via Cancel, instead of failing on every boundary. | |
| 7771 | if (queuedMessage && activeThreadKey) { | |
| 7772 | useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage); | |
| 7773 | } | |
| 7774 | 7785 | return; |
| 7775 | 7786 | } |
| 7776 | 7787 | |
| ... | ... | @@ -7837,41 +7848,10 @@ export default function ChatView(props: ChatViewProps) { |
| 7837 | 7848 | |
| 7838 | 7849 | sendInFlightRef.current = true; |
| 7839 | 7850 | const sendGeneration = ++composerSendGenerationRef.current; |
| 7840 | // Every early return above leaves a queued message in the queue for a | |
| 7841 | // later retry. From here on a failure hands it back to the composer. | |
| 7842 | if (queuedMessage) { | |
| 7843 | const taken = activeThreadKey | |
| 7844 | ? useQueuedMessageStore | |
| 7845 | .getState() | |
| 7846 | .take( | |
| 7847 | activeThreadKey, | |
| 7848 | queuedMessage.id, | |
| 7849 | latestCompletedToolActivityId(threadActivities), | |
| 7850 | ) | |
| 7851 | : null; | |
| 7852 | if (!taken) { | |
| 7853 | sendInFlightRef.current = false; | |
| 7854 | return; | |
| 7855 | } | |
| 7856 | } | |
| 7857 | // Stop drains the queue. A queued send whose upload was still running at | |
| 7858 | // that moment must not start a turn afterwards; it checks this before | |
| 7859 | // dispatch and hands the message back to the composer instead. | |
| 7860 | const drainGenerationAtTake = useQueuedMessageStore.getState().drainGeneration; | |
| 7861 | // A queued send that fails goes back to the head of the queue, held. The | |
| 7862 | // messages behind it keep their order and wait; the composer is not | |
| 7863 | // touched, which also keeps a failure after navigation off the new | |
| 7864 | // thread's draft. The user retries with Send now or edits with Cancel. | |
| 7865 | const abortQueuedReplay = () => { | |
| 7866 | if (queuedMessage && activeThreadKey) { | |
| 7867 | useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage); | |
| 7868 | } | |
| 7869 | }; | |
| 7870 | 7851 | const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); |
| 7871 | 7852 | if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { |
| 7872 | 7853 | sendInFlightRef.current = false; |
| 7873 | 7854 | setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason); |
| 7874 | abortQueuedReplay(); | |
| 7875 | 7855 | return; |
| 7876 | 7856 | } |
| 7877 | 7857 | const turnUsesAttachmentUploads = |
| ... | ... | @@ -7891,26 +7871,15 @@ export default function ChatView(props: ChatViewProps) { |
| 7891 | 7871 | if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) { |
| 7892 | 7872 | sendInFlightRef.current = false; |
| 7893 | 7873 | setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason); |
| 7894 | abortQueuedReplay(); | |
| 7895 | 7874 | return; |
| 7896 | 7875 | } |
| 7897 | 7876 | if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) { |
| 7898 | 7877 | sendInFlightRef.current = false; |
| 7899 | 7878 | setThreadError(threadIdForSend, "Retry or remove failed uploads before sending."); |
| 7900 | abortQueuedReplay(); | |
| 7901 | 7879 | return; |
| 7902 | 7880 | } |
| 7903 | 7881 | } |
| 7904 | 7882 | |
| 7905 | if ( | |
| 7906 | queuedMessage && | |
| 7907 | useQueuedMessageStore.getState().drainGeneration !== drainGenerationAtTake | |
| 7908 | ) { | |
| 7909 | sendInFlightRef.current = false; | |
| 7910 | restoreQueuedMessagesToComposer([queuedMessage]); | |
| 7911 | return; | |
| 7912 | } | |
| 7913 | ||
| 7914 | 7883 | const resolvedSubmissionIntent = |
| 7915 | 7884 | (multipleModelSelections !== null || submissionIntent === "background") && isLocalDraftThread |
| 7916 | 7885 | ? "background" |
| ... | ... | @@ -7951,7 +7920,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7951 | 7920 | setDockedDraftHeroThreadKey((currentThreadKey) => |
| 7952 | 7921 | currentThreadKey === activeThreadKey ? null : currentThreadKey, |
| 7953 | 7922 | ); |
| 7954 | abortQueuedReplay(); | |
| 7955 | 7923 | return; |
| 7956 | 7924 | } |
| 7957 | 7925 | beginLocalDispatch({ |
| ... | ... | @@ -8313,11 +8281,9 @@ export default function ChatView(props: ChatViewProps) { |
| 8313 | 8281 | }), |
| 8314 | 8282 | ); |
| 8315 | 8283 | } |
| 8316 | if (!queuedMessage) { | |
| 8317 | promptRef.current = ""; | |
| 8318 | clearComposerDraftContent(composerDraftTarget); | |
| 8319 | composerRef.current?.resetCursorState(); | |
| 8320 | } | |
| 8284 | promptRef.current = ""; | |
| 8285 | clearComposerDraftContent(composerDraftTarget); | |
| 8286 | composerRef.current?.resetCursorState(); | |
| 8321 | 8287 | |
| 8322 | 8288 | let firstComposerImageName: string | null = null; |
| 8323 | 8289 | if (composerImagesSnapshot.length > 0) { |
| ... | ... | @@ -8552,24 +8518,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8552 | 8518 | ); |
| 8553 | 8519 | clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); |
| 8554 | 8520 | } |
| 8555 | if (queuedMessage) { | |
| 8556 | setOptimisticUserMessages((existing) => { | |
| 8557 | const removed = existing.filter((message) => message.id === messageIdForSend); | |
| 8558 | for (const message of removed) { | |
| 8559 | revokeUserMessagePreviewUrls(message); | |
| 8560 | } | |
| 8561 | const next = existing.filter((message) => message.id !== messageIdForSend); | |
| 8562 | return next.length === existing.length ? existing : next; | |
| 8563 | }); | |
| 8564 | // The optimistic row's preview URLs were just revoked, so the images | |
| 8565 | // need fresh ones before the row can show them again. | |
| 8566 | if (activeThreadKey) { | |
| 8567 | useQueuedMessageStore.getState().holdAtFront(activeThreadKey, { | |
| 8568 | ...queuedMessage, | |
| 8569 | images: queuedMessage.images.map(cloneComposerImageForRetry), | |
| 8570 | }); | |
| 8571 | } | |
| 8572 | } else if ( | |
| 8521 | if ( | |
| 8573 | 8522 | backgroundDraftOpened |
| 8574 | 8523 | ? !composerDraftHasUserContent( |
| 8575 | 8524 | useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), |
| ... | ... | @@ -8659,46 +8608,12 @@ export default function ChatView(props: ChatViewProps) { |
| 8659 | 8608 | } |
| 8660 | 8609 | }; |
| 8661 | 8610 | |
| 8662 | // Sends the oldest queued message once it is due: a tool call finished | |
| 8663 | // after it was queued, or the turn ended. Only one leaves per boundary; the | |
| 8664 | // take inside onSend re-anchors the rest. | |
| 8665 | const sendQueuedMessage = useEffectEvent((message: QueuedComposerMessage) => { | |
| 8666 | void onSend(undefined, message.submissionIntent, undefined, message); | |
| 8667 | }); | |
| 8668 | const nextQueuedMessage = queuedMessages[0] ?? null; | |
| 8669 | const latestToolActivityId = useMemo( | |
| 8670 | () => (nextQueuedMessage ? latestCompletedToolActivityId(threadActivities) : null), | |
| 8671 | [nextQueuedMessage, threadActivities], | |
| 8672 | ); | |
| 8673 | // Approvals and questions block the agent; a steer landing on top of them | |
| 8674 | // would answer nothing and confuse the turn, so the queue holds until the | |
| 8675 | // user resolves them. | |
| 8611 | // Queued messages go out from QueuedMessageSender, which also covers | |
| 8612 | // threads that are not on screen. Send now uses the same path but skips the | |
| 8613 | // wait for a boundary. Approvals and questions still hold it: a steer on | |
| 8614 | // top of them would answer nothing and confuse the turn. | |
| 8676 | 8615 | const queueBlockedByPendingRequest = |
| 8677 | 8616 | activePendingApproval !== null || pendingUserInputs.length > 0; |
| 8678 | // onSend bails early on transient gates (environment offline, settings not | |
| 8679 | // hydrated, checkpoint rewinding, messages loading, machine not chosen) and | |
| 8680 | // leaves the message queued. Re-run when any of them clear so a due message | |
| 8681 | // does not wait for an unrelated phase change. | |
| 8682 | const queueSendGate = | |
| 8683 | activeEnvironmentUnavailable || | |
| 8684 | !clientSettingsHydrated || | |
| 8685 | isRevertingCheckpoint || | |
| 8686 | threadDetailLoading || | |
| 8687 | needsLoadBalancing || | |
| 8688 | activeProviderStatus === null; | |
| 8689 | useEffect(() => { | |
| 8690 | if (!nextQueuedMessage || isSendBusy || queueBlockedByPendingRequest || queueSendGate) return; | |
| 8691 | if (sendInFlightRef.current) return; | |
| 8692 | if (!isQueuedMessageDue({ message: nextQueuedMessage, phase, latestToolActivityId })) return; | |
| 8693 | sendQueuedMessage(nextQueuedMessage); | |
| 8694 | }, [ | |
| 8695 | isSendBusy, | |
| 8696 | latestToolActivityId, | |
| 8697 | nextQueuedMessage, | |
| 8698 | phase, | |
| 8699 | queueBlockedByPendingRequest, | |
| 8700 | queueSendGate, | |
| 8701 | ]); | |
| 8702 | 8617 | |
| 8703 | 8618 | // The row handlers are read from refs at call-time so their identity stays |
| 8704 | 8619 | // stable and does not bust TimelineRowCtx on every ChatView render. |
| ... | ... | @@ -8708,9 +8623,8 @@ export default function ChatView(props: ChatViewProps) { |
| 8708 | 8623 | }); |
| 8709 | 8624 | queuedMessageActionsRef.current = { |
| 8710 | 8625 | steer: (id) => { |
| 8711 | const message = queuedMessages.find((entry) => entry.id === id); | |
| 8712 | if (!message || sendInFlightRef.current || queueBlockedByPendingRequest) return; | |
| 8713 | void onSend(undefined, message.submissionIntent, undefined, message); | |
| 8626 | if (!activeThreadRef || queueBlockedByPendingRequest) return; | |
| 8627 | void sendQueuedMessage(activeThreadRef, id); | |
| 8714 | 8628 | }, |
| 8715 | 8629 | remove: (id) => { |
| 8716 | 8630 | if (!activeThreadKey) return; |
apps/web/src/components/QueuedMessageSender.test.tsx created+231| ... | ... | @@ -0,0 +1,231 @@ |
| 1 | import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import { act, createElement } from "react"; | |
| 5 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | import { useQueuedMessageStore, type QueuedComposerMessage } from "../queuedMessageStore"; | |
| 9 | import { sendQueuedMessage } from "./chat/sendQueuedMessage"; | |
| 10 | import { QueuedMessageSender } from "./QueuedMessageSender"; | |
| 11 | ||
| 12 | const io = vi.hoisted(() => ({ | |
| 13 | run: vi.fn(), | |
| 14 | upload: vi.fn(), | |
| 15 | toast: vi.fn(), | |
| 16 | thread: null as unknown, | |
| 17 | shell: { runtimeMode: "full-access", interactionMode: "default" } as Record<string, unknown>, | |
| 18 | })); | |
| 19 | const config = { | |
| 20 | environment: { capabilities: { attachmentUploads: true, inlineMessageContext: true } }, | |
| 21 | }; | |
| 22 | vi.mock("@t3tools/client-runtime/state/runtime", async (load) => ({ | |
| 23 | ...(await load<typeof import("@t3tools/client-runtime/state/runtime")>()), | |
| 24 | runAtomCommand: (...args: unknown[]) => io.run(...args), | |
| 25 | })); | |
| 26 | vi.mock("../rpc/atomRegistry", () => ({ | |
| 27 | appAtomRegistry: { get: () => new Map([["env-a", config]]) }, | |
| 28 | })); | |
| 29 | vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); | |
| 30 | vi.mock("../state/threads", () => ({ | |
| 31 | threadEnvironment: { | |
| 32 | updateMetadata: "metadata", | |
| 33 | setRuntimeMode: "runtime", | |
| 34 | setInteractionMode: "interaction", | |
| 35 | startTurn: "start", | |
| 36 | }, | |
| 37 | })); | |
| 38 | vi.mock("../state/environments", () => ({ | |
| 39 | useEnvironment: () => ({ connection: { phase: "connected" } }), | |
| 40 | })); | |
| 41 | vi.mock("../state/entities", () => ({ | |
| 42 | useThread: () => io.thread, | |
| 43 | useThreadStatus: () => "live", | |
| 44 | useServerConfigs: () => new Map([["env-a", config]]), | |
| 45 | readThreadShell: () => io.shell, | |
| 46 | readThread: () => io.thread, | |
| 47 | })); | |
| 48 | vi.mock("./ui/toast", () => ({ toastManager: { add: (...args: unknown[]) => io.toast(...args) } })); | |
| 49 | vi.mock("../lib/attachmentUploadQueue", () => ({ | |
| 50 | startAttachmentUpload: vi.fn(), | |
| 51 | awaitAttachmentUploads: (...args: unknown[]) => io.upload(...args), | |
| 52 | getUploadedAttachments: () => [ | |
| 53 | { type: "image", id: "uploaded", name: "a.png", mimeType: "image/png", sizeBytes: 4 }, | |
| 54 | ], | |
| 55 | releaseDraftAttachments: vi.fn(), | |
| 56 | })); | |
| 57 | ||
| 58 | const threadRef = scopeThreadRef(EnvironmentId.make("env-a"), ThreadId.make("thread-a")); | |
| 59 | const threadKey = scopedThreadKey(threadRef); | |
| 60 | const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }; | |
| 61 | ||
| 62 | function enqueue(overrides: Partial<QueuedComposerMessage> = {}) { | |
| 63 | return useQueuedMessageStore.getState().enqueue(threadKey, { | |
| 64 | prompt: "follow up", | |
| 65 | images: [], | |
| 66 | files: [], | |
| 67 | terminalContexts: [], | |
| 68 | previewAnnotations: [], | |
| 69 | reviewComments: [], | |
| 70 | sendSettings: { | |
| 71 | modelSelection, | |
| 72 | runtimeMode: "full-access", | |
| 73 | interactionMode: "default", | |
| 74 | promptEffort: null, | |
| 75 | }, | |
| 76 | queuedAfterToolActivityId: null, | |
| 77 | createdAt: "2026-09-25T00:00:00Z", | |
| 78 | ...overrides, | |
| 79 | }); | |
| 80 | } | |
| 81 | ||
| 82 | const commandsRun = () => io.run.mock.calls.map((call) => call[1]); | |
| 83 | const queue = () => useQueuedMessageStore.getState().queuesByThreadKey[threadKey]; | |
| 84 | ||
| 85 | beforeEach(() => { | |
| 86 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 87 | useQueuedMessageStore.setState({ queuesByThreadKey: {}, lastDispatchByThreadKey: {} }); | |
| 88 | io.thread = null; | |
| 89 | io.run.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); | |
| 90 | io.upload.mockReset().mockResolvedValue(undefined); | |
| 91 | io.toast.mockReset(); | |
| 92 | io.shell = { | |
| 93 | modelSelection, | |
| 94 | branch: null, | |
| 95 | runtimeMode: "full-access", | |
| 96 | interactionMode: "default", | |
| 97 | }; | |
| 98 | }); | |
| 99 | ||
| 100 | afterEach(() => { | |
| 101 | vi.unstubAllGlobals(); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("QueuedMessageSender", () => { | |
| 105 | const thread = ( | |
| 106 | status: string, | |
| 107 | { toolActivityIds = [] as string[], userMessageIds = [] as string[] } = {}, | |
| 108 | ) => ({ | |
| 109 | session: { status, activeTurnId: null, updatedAt: status }, | |
| 110 | activities: toolActivityIds.map((id, index) => ({ | |
| 111 | id, | |
| 112 | kind: "tool.completed", | |
| 113 | sequence: index, | |
| 114 | createdAt: "2026-09-25T00:00:01Z", | |
| 115 | })), | |
| 116 | messages: userMessageIds.map((id) => ({ id, role: "user" })), | |
| 117 | latestTurn: null, | |
| 118 | }); | |
| 119 | let root: ReactTestRenderer | null = null; | |
| 120 | const render = () => | |
| 121 | act(() => { | |
| 122 | if (root) root.update(createElement(QueuedMessageSender)); | |
| 123 | else root = create(createElement(QueuedMessageSender)); | |
| 124 | }); | |
| 125 | afterEach(async () => { | |
| 126 | await act(() => root?.unmount()); | |
| 127 | root = null; | |
| 128 | }); | |
| 129 | ||
| 130 | it("sends a queued message when the turn ends, with no chat view open", async () => { | |
| 131 | enqueue(); | |
| 132 | io.thread = thread("running"); | |
| 133 | await render(); | |
| 134 | expect(commandsRun()).toEqual([]); | |
| 135 | ||
| 136 | io.thread = thread("ready"); | |
| 137 | await render(); | |
| 138 | ||
| 139 | expect(commandsRun()).toEqual(["start"]); | |
| 140 | expect(io.run.mock.calls[0]?.[2]).toMatchObject({ | |
| 141 | environmentId: "env-a", | |
| 142 | input: { threadId: "thread-a", message: { text: "follow up" }, modelSelection }, | |
| 143 | }); | |
| 144 | expect(queue()).toBeUndefined(); | |
| 145 | }); | |
| 146 | ||
| 147 | it("holds the next message until the server picks up the one before it", async () => { | |
| 148 | enqueue({ prompt: "first" }); | |
| 149 | enqueue({ prompt: "second" }); | |
| 150 | io.thread = thread("ready"); | |
| 151 | await render(); | |
| 152 | await render(); | |
| 153 | expect(commandsRun()).toEqual(["start"]); | |
| 154 | ||
| 155 | // The first message started a turn; the second waits for its next tool call. | |
| 156 | io.thread = thread("running", { userMessageIds: ["first"] }); | |
| 157 | await render(); | |
| 158 | expect(commandsRun()).toEqual(["start"]); | |
| 159 | io.thread = thread("running", { userMessageIds: ["first"], toolActivityIds: ["tool-1"] }); | |
| 160 | await render(); | |
| 161 | expect(commandsRun()).toEqual(["start", "start"]); | |
| 162 | }); | |
| 163 | ||
| 164 | it("moves on to the next message after a failed one is cancelled", async () => { | |
| 165 | io.run.mockResolvedValueOnce({ _tag: "Failure", cause: Cause.fail(new Error("offline")) }); | |
| 166 | const first = enqueue({ prompt: "first" }); | |
| 167 | enqueue({ prompt: "second" }); | |
| 168 | io.thread = thread("ready"); | |
| 169 | await render(); | |
| 170 | expect(queue()?.[0]).toMatchObject({ prompt: "first", holdUntilUserAction: true }); | |
| 171 | ||
| 172 | await act(() => { | |
| 173 | useQueuedMessageStore.getState().remove(threadKey, first.id); | |
| 174 | }); | |
| 175 | await render(); | |
| 176 | ||
| 177 | expect(commandsRun()).toEqual(["start", "start"]); | |
| 178 | expect(io.run.mock.calls[1]?.[2]).toMatchObject({ input: { message: { text: "second" } } }); | |
| 179 | }); | |
| 180 | }); | |
| 181 | ||
| 182 | describe("sendQueuedMessage", () => { | |
| 183 | it("saves a mode changed before queueing, then starts the turn", async () => { | |
| 184 | io.shell = { ...io.shell, runtimeMode: "approval-required" }; | |
| 185 | const message = enqueue(); | |
| 186 | ||
| 187 | await sendQueuedMessage(threadRef, message.id); | |
| 188 | ||
| 189 | expect(commandsRun()).toEqual(["runtime", "start"]); | |
| 190 | expect(io.run.mock.calls[1]?.[2]).toMatchObject({ input: { runtimeMode: "full-access" } }); | |
| 191 | expect(queue()).toBeUndefined(); | |
| 192 | }); | |
| 193 | ||
| 194 | it("gives a message back to Stop while its upload runs, without starting a turn", async () => { | |
| 195 | let finishUpload!: () => void; | |
| 196 | io.upload.mockReturnValue(new Promise<void>((resolve) => (finishUpload = resolve))); | |
| 197 | const image = { | |
| 198 | type: "image" as const, | |
| 199 | id: "image-1", | |
| 200 | name: "a.png", | |
| 201 | mimeType: "image/png", | |
| 202 | sizeBytes: 4, | |
| 203 | previewUrl: "data:image/png;base64,AAAA", | |
| 204 | file: new File(["AAAA"], "a.png", { type: "image/png" }), | |
| 205 | }; | |
| 206 | const message = enqueue({ images: [image] }); | |
| 207 | ||
| 208 | const sending = sendQueuedMessage(threadRef, message.id); | |
| 209 | expect(useQueuedMessageStore.getState().drain(threadKey)).toHaveLength(1); | |
| 210 | finishUpload(); | |
| 211 | await sending; | |
| 212 | ||
| 213 | expect(commandsRun()).toEqual([]); | |
| 214 | expect(io.toast).not.toHaveBeenCalled(); | |
| 215 | expect(queue()).toBeUndefined(); | |
| 216 | }); | |
| 217 | ||
| 218 | it("holds a message at the head when the turn start fails", async () => { | |
| 219 | io.run.mockResolvedValue({ _tag: "Failure", cause: Cause.fail(new Error("offline")) }); | |
| 220 | enqueue({ prompt: "first" }); | |
| 221 | const second = enqueue({ prompt: "second" }); | |
| 222 | ||
| 223 | await sendQueuedMessage(threadRef, second.id); | |
| 224 | ||
| 225 | expect(queue()?.map((entry) => [entry.prompt, entry.holdUntilUserAction])).toEqual([ | |
| 226 | ["second", true], | |
| 227 | ["first", undefined], | |
| 228 | ]); | |
| 229 | expect(io.toast).toHaveBeenCalledWith(expect.objectContaining({ description: "offline" })); | |
| 230 | }); | |
| 231 | }); |
apps/web/src/components/QueuedMessageSender.tsx created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; | |
| 3 | import { useEffect, useMemo } from "react"; | |
| 4 | import { useShallow } from "zustand/react/shallow"; | |
| 5 | ||
| 6 | import { useComposerDraftStore } from "../composerDraftStore"; | |
| 7 | import { | |
| 8 | isQueuedMessageDue, | |
| 9 | latestCompletedToolActivityId, | |
| 10 | useQueuedMessageStore, | |
| 11 | useQueuedMessages, | |
| 12 | } from "../queuedMessageStore"; | |
| 13 | import { derivePhase } from "../session-logic"; | |
| 14 | import { useServerConfigs, useThread, useThreadStatus } from "../state/entities"; | |
| 15 | import { useEnvironment } from "../state/environments"; | |
| 16 | import { hasServerAcknowledgedLocalDispatch, latestTurnStartFailureId } from "./ChatView.logic"; | |
| 17 | import { sendQueuedMessage } from "./chat/sendQueuedMessage"; | |
| 18 | ||
| 19 | /** | |
| 20 | * Sends queued messages when they are due, for every thread with a queue, | |
| 21 | * whether or not the thread is on screen. Mounted once at the root. | |
| 22 | */ | |
| 23 | export function QueuedMessageSender() { | |
| 24 | const threadKeys = useQueuedMessageStore( | |
| 25 | useShallow((state) => Object.keys(state.queuesByThreadKey)), | |
| 26 | ); | |
| 27 | return threadKeys.map((threadKey) => <ThreadQueueSender key={threadKey} threadKey={threadKey} />); | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * Watches one thread while it has queued messages. Reading the thread keeps | |
| 32 | * its detail subscribed, so tool boundaries and the end of the turn are | |
| 33 | * visible while the user is elsewhere. | |
| 34 | */ | |
| 35 | function ThreadQueueSender({ threadKey }: { threadKey: string }) { | |
| 36 | const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); | |
| 37 | const thread = useThread(threadRef); | |
| 38 | const threadStatus = useThreadStatus(threadRef); | |
| 39 | const environmentId = threadRef?.environmentId ?? null; | |
| 40 | const environment = useEnvironment(environmentId); | |
| 41 | const serverConfigs = useServerConfigs(); | |
| 42 | const serverConfigLoaded = environmentId !== null && serverConfigs.has(environmentId); | |
| 43 | const rewinding = useComposerDraftStore((store) => store.rewindingThreadKeys.has(threadKey)); | |
| 44 | const queue = useQueuedMessages(threadKey); | |
| 45 | const next = queue[0]; | |
| 46 | const sending = queue.some((message) => message.sending); | |
| 47 | const activities = thread?.activities; | |
| 48 | const latestToolActivityId = useMemo( | |
| 49 | () => latestCompletedToolActivityId(activities ?? []), | |
| 50 | [activities], | |
| 51 | ); | |
| 52 | const pendingRequests = useMemo(() => derivePendingRequests(activities ?? []), [activities]); | |
| 53 | const phase = derivePhase(thread?.session ?? null); | |
| 54 | ||
| 55 | // A send that starts a new turn leaves the thread idle until the server | |
| 56 | // picks it up. Hold the next message until then, as the composer does for | |
| 57 | // its own sends. | |
| 58 | const lastDispatch = useQueuedMessageStore( | |
| 59 | (state) => state.lastDispatchByThreadKey[threadKey]?.thread ?? null, | |
| 60 | ); | |
| 61 | const latestUserMessageId = thread?.messages.findLast((m) => m.role === "user")?.id ?? null; | |
| 62 | const waitingForServer = | |
| 63 | lastDispatch !== null && | |
| 64 | !hasServerAcknowledgedLocalDispatch({ | |
| 65 | localDispatch: lastDispatch, | |
| 66 | phase, | |
| 67 | latestTurn: thread?.latestTurn ?? null, | |
| 68 | latestUserMessageId, | |
| 69 | session: thread?.session ?? null, | |
| 70 | hasPendingApproval: pendingRequests.approvals.length > 0, | |
| 71 | hasPendingUserInput: pendingRequests.userInputs.length > 0, | |
| 72 | latestTurnStartFailureId: latestTurnStartFailureId(thread ?? undefined, latestUserMessageId), | |
| 73 | threadError: null, | |
| 74 | }); | |
| 75 | ||
| 76 | // Approvals and questions block the agent; a steer landing on top of them | |
| 77 | // would answer nothing and confuse the turn, so the queue holds until the | |
| 78 | // user resolves them. | |
| 79 | const blocked = | |
| 80 | threadRef === null || | |
| 81 | thread === null || | |
| 82 | threadStatus !== "live" || | |
| 83 | (environment !== null && environment.connection.phase !== "connected") || | |
| 84 | !serverConfigLoaded || | |
| 85 | rewinding || | |
| 86 | sending || | |
| 87 | waitingForServer || | |
| 88 | pendingRequests.approvals.length > 0 || | |
| 89 | pendingRequests.userInputs.length > 0; | |
| 90 | const due = | |
| 91 | next !== undefined && | |
| 92 | !blocked && | |
| 93 | isQueuedMessageDue({ message: next, phase, latestToolActivityId }); | |
| 94 | const nextId = next?.id; | |
| 95 | useEffect(() => { | |
| 96 | if (!due || !threadRef || nextId === undefined) return; | |
| 97 | void sendQueuedMessage(threadRef, nextId); | |
| 98 | }, [due, nextId, threadRef]); | |
| 99 | return null; | |
| 100 | } |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+6-1| ... | ... | @@ -1100,7 +1100,12 @@ describe("deriveMessagesTimelineRows", () => { |
| 1100 | 1100 | terminalContexts: [], |
| 1101 | 1101 | previewAnnotations: [], |
| 1102 | 1102 | reviewComments: [], |
| 1103 | submissionIntent: "foreground" as const, | |
| 1103 | sendSettings: { | |
| 1104 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 1105 | runtimeMode: "full-access" as const, | |
| 1106 | interactionMode: "default" as const, | |
| 1107 | promptEffort: null, | |
| 1108 | }, | |
| 1104 | 1109 | queuedAfterToolActivityId: null, |
| 1105 | 1110 | createdAt: "2026-01-01T00:00:01Z", |
| 1106 | 1111 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+11-8| ... | ... | @@ -1788,11 +1788,14 @@ function QueuedMessageTimelineRow({ |
| 1788 | 1788 | queuedMessage.previewAnnotations.length + |
| 1789 | 1789 | queuedMessage.reviewComments.length; |
| 1790 | 1790 | const text = queuedMessage.prompt.trim(); |
| 1791 | const statusLabel = queuedMessage.holdUntilUserAction | |
| 1792 | ? "Waits for Send now" | |
| 1793 | : row.isNext | |
| 1794 | ? "Sends after the next tool call or when the turn ends" | |
| 1795 | : "Sends after the messages above it"; | |
| 1791 | const sending = queuedMessage.sending !== undefined; | |
| 1792 | const statusLabel = sending | |
| 1793 | ? "Sending to the agent" | |
| 1794 | : queuedMessage.holdUntilUserAction | |
| 1795 | ? "Waits for Send now" | |
| 1796 | : row.isNext | |
| 1797 | ? "Sends after the next tool call or when the turn ends" | |
| 1798 | : "Sends after the messages above it"; | |
| 1796 | 1799 | return ( |
| 1797 | 1800 | <div className="flex flex-col items-end" data-queued-message-id={queuedMessage.id}> |
| 1798 | 1801 | <div className="max-w-[80%] rounded-2xl border border-dashed border-border p-3 text-message-foreground/80"> |
| ... | ... | @@ -1820,14 +1823,14 @@ function QueuedMessageTimelineRow({ |
| 1820 | 1823 | <Tooltip> |
| 1821 | 1824 | <TooltipTrigger |
| 1822 | 1825 | render={<span className="inline-flex h-6 items-center gap-1" />} |
| 1823 | aria-label={`Queued. ${statusLabel}.`} | |
| 1826 | aria-label={`${sending ? "Sending" : "Queued"}. ${statusLabel}.`} | |
| 1824 | 1827 | > |
| 1825 | 1828 | <ClockIcon className="size-3.5" aria-hidden /> |
| 1826 | Queued | |
| 1829 | {sending ? "Sending" : "Queued"} | |
| 1827 | 1830 | </TooltipTrigger> |
| 1828 | 1831 | <TooltipPopup side="bottom">{statusLabel}</TooltipPopup> |
| 1829 | 1832 | </Tooltip> |
| 1830 | <div className="ml-auto flex items-center gap-0.5"> | |
| 1833 | <div className={cn("ml-auto flex items-center gap-0.5", sending && "invisible")}> | |
| 1831 | 1834 | <Tooltip> |
| 1832 | 1835 | <TooltipTrigger |
| 1833 | 1836 | render={ |
apps/web/src/components/chat/sendQueuedMessage.ts created+211| ... | ... | @@ -0,0 +1,211 @@ |
| 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import { | |
| 3 | runAtomCommand, | |
| 4 | squashAtomCommandFailure, | |
| 5 | type AtomCommand, | |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | |
| 7 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 8 | import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; | |
| 9 | import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; | |
| 10 | ||
| 11 | import { buildMessageContext, terminalContextReference } from "../../lib/composerContextRecords"; | |
| 12 | import { removeInlineContextReference } from "../../lib/composerContextReferences"; | |
| 13 | import { | |
| 14 | awaitAttachmentUploads, | |
| 15 | getUploadedAttachments, | |
| 16 | releaseDraftAttachments, | |
| 17 | startAttachmentUpload, | |
| 18 | } from "../../lib/attachmentUploadQueue"; | |
| 19 | import { newMessageId } from "../../lib/utils"; | |
| 20 | import { latestCompletedToolActivityId, useQueuedMessageStore } from "../../queuedMessageStore"; | |
| 21 | import { appAtomRegistry } from "../../rpc/atomRegistry"; | |
| 22 | import { readThread, readThreadShell } from "../../state/entities"; | |
| 23 | import { environmentServerConfigsAtom } from "../../state/server"; | |
| 24 | import { threadEnvironment } from "../../state/threads"; | |
| 25 | import { | |
| 26 | createLocalDispatchSnapshot, | |
| 27 | deriveComposerSendState, | |
| 28 | readFileAsDataUrl, | |
| 29 | resolveThreadMetadataUpdateForNextTurn, | |
| 30 | revokeBlobPreviewUrl, | |
| 31 | } from "../ChatView.logic"; | |
| 32 | import { toastManager } from "../ui/toast"; | |
| 33 | import { fileAttachmentCapabilityBlockReason } from "./composerAttachmentFiles"; | |
| 34 | import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./composerPromptHistory"; | |
| 35 | ||
| 36 | async function run<W, A, E>(command: AtomCommand<W, A, E>, input: W): Promise<A> { | |
| 37 | const result = await runAtomCommand(appAtomRegistry, command, input, { reportFailure: false }); | |
| 38 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | |
| 39 | return result.value; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Sends one queued message as a turn on its thread. `QueuedMessageSender` | |
| 44 | * calls it when the head message is due, and Send now calls it directly. It | |
| 45 | * reads nothing from the composer, so it works for threads that are not on | |
| 46 | * screen. A failed send stays at the head of the queue, held for Send now. | |
| 47 | */ | |
| 48 | export async function sendQueuedMessage( | |
| 49 | threadRef: ScopedThreadRef, | |
| 50 | messageId: string, | |
| 51 | ): Promise<void> { | |
| 52 | const { environmentId, threadId } = threadRef; | |
| 53 | const threadKey = scopedThreadKey(threadRef); | |
| 54 | const queue = useQueuedMessageStore.getState(); | |
| 55 | const message = queue.beginSend( | |
| 56 | threadKey, | |
| 57 | messageId, | |
| 58 | latestCompletedToolActivityId(readThread(threadRef)?.activities ?? []), | |
| 59 | ); | |
| 60 | if (!message) return; | |
| 61 | const { sendSettings } = message; | |
| 62 | const attachments = [...message.images, ...message.files]; | |
| 63 | const readConfig = () => appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId); | |
| 64 | const assertFilesAllowed = () => { | |
| 65 | const config = readConfig(); | |
| 66 | const reason = fileAttachmentCapabilityBlockReason({ | |
| 67 | files: message.files, | |
| 68 | attachmentUploadsCapabilityKnown: config !== undefined, | |
| 69 | supportsAttachmentUploads: config?.environment.capabilities.attachmentUploads === true, | |
| 70 | maxFileAttachmentBytes: | |
| 71 | config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null, | |
| 72 | }); | |
| 73 | if (reason !== null) throw new Error(reason); | |
| 74 | }; | |
| 75 | try { | |
| 76 | const { sendableTerminalContexts, hasSendableContent } = deriveComposerSendState({ | |
| 77 | prompt: message.prompt, | |
| 78 | imageCount: attachments.length, | |
| 79 | terminalContexts: message.terminalContexts, | |
| 80 | elementContextCount: message.previewAnnotations.length + message.reviewComments.length, | |
| 81 | }); | |
| 82 | // Only expired terminal context was left. Retrying would block the queue | |
| 83 | // on every boundary, so drop it and let the queue move on. | |
| 84 | if (!hasSendableContent) { | |
| 85 | queue.finishSend(threadKey, message.id); | |
| 86 | return; | |
| 87 | } | |
| 88 | // Expired terminal excerpts are not sent; their chips leave the text with them. | |
| 89 | const prompt = message.terminalContexts | |
| 90 | .filter((context) => !sendableTerminalContexts.includes(context)) | |
| 91 | .reduce( | |
| 92 | (text, context) => | |
| 93 | removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, | |
| 94 | message.prompt, | |
| 95 | ) | |
| 96 | .trim(); | |
| 97 | const text = applyClaudePromptEffortPrefix( | |
| 98 | prompt || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, | |
| 99 | sendSettings.promptEffort, | |
| 100 | ); | |
| 101 | ||
| 102 | assertFilesAllowed(); | |
| 103 | const useUploads = readConfig()?.environment.capabilities.attachmentUploads === true; | |
| 104 | if (useUploads && attachments.length > 0) { | |
| 105 | for (const attachment of attachments) { | |
| 106 | startAttachmentUpload({ environmentId, image: attachment, draftTarget: threadRef }); | |
| 107 | } | |
| 108 | await awaitAttachmentUploads(attachments.map((attachment) => attachment.id)); | |
| 109 | } | |
| 110 | const wireAttachments = await Promise.all( | |
| 111 | attachments.map(async (attachment) => { | |
| 112 | if (useUploads) { | |
| 113 | const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; | |
| 114 | if (!uploaded) throw new Error(`Attachment '${attachment.name}' did not upload.`); | |
| 115 | return uploaded; | |
| 116 | } | |
| 117 | if (attachment.type !== "image") { | |
| 118 | throw new Error("This server does not support file attachments."); | |
| 119 | } | |
| 120 | return { | |
| 121 | type: "image" as const, | |
| 122 | id: attachment.id, | |
| 123 | name: attachment.name, | |
| 124 | mimeType: attachment.mimeType, | |
| 125 | sizeBytes: attachment.sizeBytes, | |
| 126 | dataUrl: await readFileAsDataUrl(attachment.file), | |
| 127 | ...(attachment.source ? { source: attachment.source } : {}), | |
| 128 | }; | |
| 129 | }), | |
| 130 | ); | |
| 131 | assertFilesAllowed(); | |
| 132 | ||
| 133 | // The server starts the turn with the thread's stored modes, so a change | |
| 134 | // made in the composer before queueing is saved first. | |
| 135 | const createdAt = new Date().toISOString(); | |
| 136 | const shell = readThreadShell(threadRef); | |
| 137 | const metadataUpdate = shell | |
| 138 | ? resolveThreadMetadataUpdateForNextTurn({ | |
| 139 | currentModelSelection: shell.modelSelection, | |
| 140 | nextModelSelection: sendSettings.modelSelection, | |
| 141 | currentBranch: shell.branch, | |
| 142 | }) | |
| 143 | : null; | |
| 144 | if (metadataUpdate) { | |
| 145 | await run(threadEnvironment.updateMetadata, { | |
| 146 | environmentId, | |
| 147 | input: { threadId, ...metadataUpdate }, | |
| 148 | }); | |
| 149 | } | |
| 150 | if (shell && shell.runtimeMode !== sendSettings.runtimeMode) { | |
| 151 | await run(threadEnvironment.setRuntimeMode, { | |
| 152 | environmentId, | |
| 153 | input: { threadId, runtimeMode: sendSettings.runtimeMode, createdAt }, | |
| 154 | }); | |
| 155 | } | |
| 156 | if (shell && shell.interactionMode !== sendSettings.interactionMode) { | |
| 157 | await run(threadEnvironment.setInteractionMode, { | |
| 158 | environmentId, | |
| 159 | input: { threadId, interactionMode: sendSettings.interactionMode, createdAt }, | |
| 160 | }); | |
| 161 | } | |
| 162 | ||
| 163 | // Stop hands a preparing message back to the composer. Past this point | |
| 164 | // the send can no longer be taken back. | |
| 165 | const thread = readThread(threadRef) ?? undefined; | |
| 166 | if (!queue.markDispatching(threadKey, message.id, createLocalDispatchSnapshot(thread))) return; | |
| 167 | const context = buildMessageContext({ | |
| 168 | terminalContexts: sendableTerminalContexts, | |
| 169 | reviewComments: message.reviewComments, | |
| 170 | previewAnnotations: message.previewAnnotations, | |
| 171 | attachments: attachments.map((attachment, index) => ({ | |
| 172 | attachment, | |
| 173 | attachmentId: wireAttachments[index]?.id ?? attachment.id, | |
| 174 | })), | |
| 175 | }); | |
| 176 | // Servers from before inline context drop the records, so their turns | |
| 177 | // carry the payload in the text instead. | |
| 178 | const inlineContext = readConfig()?.environment.capabilities.inlineMessageContext === true; | |
| 179 | await run(threadEnvironment.startTurn, { | |
| 180 | environmentId, | |
| 181 | input: { | |
| 182 | threadId, | |
| 183 | message: { | |
| 184 | messageId: newMessageId(), | |
| 185 | role: "user", | |
| 186 | text: | |
| 187 | context !== undefined && !inlineContext | |
| 188 | ? serializeLegacyContextMessage({ text, records: context.records }) | |
| 189 | : text, | |
| 190 | attachments: wireAttachments, | |
| 191 | ...(context !== undefined && inlineContext ? { context } : {}), | |
| 192 | }, | |
| 193 | modelSelection: sendSettings.modelSelection, | |
| 194 | runtimeMode: sendSettings.runtimeMode, | |
| 195 | interactionMode: sendSettings.interactionMode, | |
| 196 | createdAt, | |
| 197 | }, | |
| 198 | }); | |
| 199 | queue.finishSend(threadKey, message.id); | |
| 200 | if (useUploads) releaseDraftAttachments(attachments); | |
| 201 | for (const image of message.images) revokeBlobPreviewUrl(image.previewUrl); | |
| 202 | } catch (error) { | |
| 203 | if (!queue.failSend(threadKey, message.id)) return; | |
| 204 | const title = readThreadShell(threadRef)?.title; | |
| 205 | toastManager.add({ | |
| 206 | type: "error", | |
| 207 | title: title ? `Queued message not sent in "${title}"` : "Queued message not sent", | |
| 208 | description: error instanceof Error ? error.message : "Use Send now to try again.", | |
| 209 | }); | |
| 210 | } | |
| 211 | } |
apps/web/src/queuedMessageStore.test.ts+96-45| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 1 | 2 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | |
| 4 | import { createLocalDispatchSnapshot } from "./components/ChatView.logic"; | |
| 3 | 5 | import { |
| 4 | 6 | isQueuedMessageDue, |
| 5 | 7 | latestCompletedToolActivityId, |
| ... | ... | @@ -15,15 +17,23 @@ function makeMessage(prompt: string): Omit<QueuedComposerMessage, "id"> { |
| 15 | 17 | terminalContexts: [], |
| 16 | 18 | previewAnnotations: [], |
| 17 | 19 | reviewComments: [], |
| 18 | submissionIntent: "foreground", | |
| 20 | sendSettings: { | |
| 21 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 22 | runtimeMode: "full-access", | |
| 23 | interactionMode: "default", | |
| 24 | promptEffort: null, | |
| 25 | }, | |
| 19 | 26 | queuedAfterToolActivityId: null, |
| 20 | 27 | createdAt: "2026-09-11T00:00:00.000Z", |
| 21 | 28 | }; |
| 22 | 29 | } |
| 23 | 30 | |
| 31 | const queue = (threadKey: string) => | |
| 32 | useQueuedMessageStore.getState().queuesByThreadKey[threadKey] ?? []; | |
| 33 | ||
| 24 | 34 | describe("queuedMessageStore", () => { |
| 25 | 35 | beforeEach(() => { |
| 26 | useQueuedMessageStore.setState({ queuesByThreadKey: {}, drainGeneration: 0 }); | |
| 36 | useQueuedMessageStore.setState({ queuesByThreadKey: {}, lastDispatchByThreadKey: {} }); | |
| 27 | 37 | }); |
| 28 | 38 | |
| 29 | 39 | it("keeps messages in submission order per thread", () => { |
| ... | ... | @@ -32,71 +42,112 @@ describe("queuedMessageStore", () => { |
| 32 | 42 | enqueue("thread-a", makeMessage("second")); |
| 33 | 43 | enqueue("thread-b", makeMessage("other")); |
| 34 | 44 | |
| 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"]); | |
| 45 | expect(queue("thread-a").map((message) => message.prompt)).toEqual(["first", "second"]); | |
| 46 | expect(queue("thread-b").map((message) => message.prompt)).toEqual(["other"]); | |
| 38 | 47 | }); |
| 39 | 48 | |
| 40 | it("take hands the message to exactly one caller", () => { | |
| 41 | const { enqueue, take } = useQueuedMessageStore.getState(); | |
| 42 | const entry = enqueue("thread-a", makeMessage("first")); | |
| 49 | it("allows one send per thread and re-anchors the rest to the current tool boundary", () => { | |
| 50 | const { enqueue, beginSend } = useQueuedMessageStore.getState(); | |
| 51 | const first = enqueue("thread-a", makeMessage("first")); | |
| 52 | const second = enqueue("thread-a", makeMessage("second")); | |
| 43 | 53 | |
| 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(); | |
| 54 | expect(beginSend("thread-a", first.id, "tool-2")?.prompt).toBe("first"); | |
| 55 | expect(beginSend("thread-a", first.id, "tool-2")).toBeNull(); | |
| 56 | expect(beginSend("thread-a", second.id, "tool-2")).toBeNull(); | |
| 57 | ||
| 58 | const [sending, waiting] = queue("thread-a"); | |
| 59 | expect(sending?.sending).toBe("preparing"); | |
| 60 | expect(waiting?.queuedAfterToolActivityId).toBe("tool-2"); | |
| 61 | expect( | |
| 62 | isQueuedMessageDue({ message: waiting!, phase: "running", latestToolActivityId: "tool-2" }), | |
| 63 | ).toBe(false); | |
| 47 | 64 | }); |
| 48 | 65 | |
| 49 | it("take re-anchors the remaining messages to the current tool boundary", () => { | |
| 50 | const { enqueue, take } = useQueuedMessageStore.getState(); | |
| 66 | it("finishSend drops the sent message", () => { | |
| 67 | const { enqueue, beginSend, finishSend } = useQueuedMessageStore.getState(); | |
| 51 | 68 | const first = enqueue("thread-a", makeMessage("first")); |
| 52 | enqueue("thread-a", makeMessage("second")); | |
| 69 | beginSend("thread-a", first.id, null); | |
| 53 | 70 | |
| 54 | take("thread-a", first.id, "tool-2"); | |
| 71 | finishSend("thread-a", first.id); | |
| 55 | 72 | |
| 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); | |
| 73 | expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toBeUndefined(); | |
| 74 | }); | |
| 75 | ||
| 76 | it("failSend returns the message to the head, held", () => { | |
| 77 | const { enqueue, beginSend, failSend } = useQueuedMessageStore.getState(); | |
| 78 | enqueue("thread-a", makeMessage("first")); | |
| 79 | const second = enqueue("thread-a", makeMessage("second")); | |
| 80 | beginSend("thread-a", second.id, null); | |
| 81 | ||
| 82 | expect(failSend("thread-a", second.id)).toBe(true); | |
| 83 | ||
| 84 | const [head] = queue("thread-a"); | |
| 85 | expect(queue("thread-a").map((message) => message.prompt)).toEqual(["second", "first"]); | |
| 86 | expect(head?.sending).toBeUndefined(); | |
| 87 | expect(isQueuedMessageDue({ message: head!, phase: "ready", latestToolActivityId: null })).toBe( | |
| 88 | false, | |
| 89 | ); | |
| 61 | 90 | }); |
| 62 | 91 | |
| 63 | it("remove keeps the other messages' anchors", () => { | |
| 64 | const { enqueue, remove } = useQueuedMessageStore.getState(); | |
| 92 | it("remove keeps the other messages' anchors and refuses a message being sent", () => { | |
| 93 | const { enqueue, remove, beginSend } = useQueuedMessageStore.getState(); | |
| 65 | 94 | const first = enqueue("thread-a", { ...makeMessage("first"), queuedAfterToolActivityId: "t1" }); |
| 66 | 95 | const second = enqueue("thread-a", makeMessage("second")); |
| 67 | 96 | |
| 68 | 97 | expect(remove("thread-a", second.id)?.prompt).toBe("second"); |
| 69 | 98 | expect(remove("thread-a", second.id)).toBeNull(); |
| 70 | expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toEqual([first]); | |
| 99 | expect(queue("thread-a")).toEqual([first]); | |
| 100 | ||
| 101 | beginSend("thread-a", first.id, "t1"); | |
| 102 | expect(remove("thread-a", first.id)).toBeNull(); | |
| 71 | 103 | }); |
| 72 | 104 | |
| 73 | it("holdAtFront returns a failed message to the head, held", () => { | |
| 74 | const { enqueue, take, holdAtFront } = useQueuedMessageStore.getState(); | |
| 105 | it("a failed send keeps waiting on the dispatch before it", () => { | |
| 106 | const { enqueue, beginSend, markDispatching, finishSend, failSend } = | |
| 107 | useQueuedMessageStore.getState(); | |
| 108 | const earlier = createLocalDispatchSnapshot(undefined); | |
| 75 | 109 | const first = enqueue("thread-a", makeMessage("first")); |
| 76 | enqueue("thread-a", makeMessage("second")); | |
| 77 | const taken = take("thread-a", first.id, "t1")!; | |
| 110 | const second = enqueue("thread-a", makeMessage("second")); | |
| 111 | const third = enqueue("thread-a", makeMessage("third")); | |
| 112 | const lastDispatch = () => useQueuedMessageStore.getState().lastDispatchByThreadKey["thread-a"]; | |
| 113 | beginSend("thread-a", first.id, null); | |
| 114 | markDispatching("thread-a", first.id, earlier); | |
| 115 | finishSend("thread-a", first.id); | |
| 116 | ||
| 117 | // Fails before its turn start went out: the first send is still the one to wait on. | |
| 118 | beginSend("thread-a", second.id, null); | |
| 119 | failSend("thread-a", second.id); | |
| 120 | expect(lastDispatch()?.thread).toBe(earlier); | |
| 121 | ||
| 122 | // Fails after going out: it never reached the server, so the first still counts. | |
| 123 | beginSend("thread-a", third.id, null); | |
| 124 | markDispatching("thread-a", third.id, { ...earlier, startedAt: "later" }); | |
| 125 | failSend("thread-a", third.id); | |
| 126 | expect(lastDispatch()?.thread).toBe(earlier); | |
| 127 | }); | |
| 78 | 128 | |
| 79 | holdAtFront("thread-a", taken); | |
| 129 | it("Stop takes back a preparing send but not one already dispatching", () => { | |
| 130 | const { enqueue, beginSend, markDispatching, drain, failSend } = | |
| 131 | useQueuedMessageStore.getState(); | |
| 132 | const preparing = enqueue("thread-a", makeMessage("preparing")); | |
| 133 | enqueue("thread-a", makeMessage("waiting")); | |
| 134 | beginSend("thread-a", preparing.id, null); | |
| 80 | 135 | |
| 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 | }); | |
| 136 | expect(drain("thread-a").map((message) => message.prompt)).toEqual(["preparing", "waiting"]); | |
| 137 | expect(markDispatching("thread-a", preparing.id, createLocalDispatchSnapshot(undefined))).toBe( | |
| 138 | false, | |
| 139 | ); | |
| 140 | expect(failSend("thread-a", preparing.id)).toBe(false); | |
| 88 | 141 | |
| 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")); | |
| 142 | const dispatching = enqueue("thread-b", makeMessage("dispatching")); | |
| 143 | enqueue("thread-b", makeMessage("waiting")); | |
| 144 | beginSend("thread-b", dispatching.id, null); | |
| 145 | expect( | |
| 146 | markDispatching("thread-b", dispatching.id, createLocalDispatchSnapshot(undefined)), | |
| 147 | ).toBe(true); | |
| 94 | 148 | |
| 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); | |
| 149 | expect(drain("thread-b").map((message) => message.prompt)).toEqual(["waiting"]); | |
| 150 | expect(queue("thread-b").map((message) => message.prompt)).toEqual(["dispatching"]); | |
| 100 | 151 | }); |
| 101 | 152 | }); |
| 102 | 153 |
apps/web/src/queuedMessageStore.ts+165-100| ... | ... | @@ -1,12 +1,30 @@ |
| 1 | import type { PreviewAnnotationPayload } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | ModelSelection, | |
| 3 | PreviewAnnotationPayload, | |
| 4 | ProviderInteractionMode, | |
| 5 | RuntimeMode, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { create } from "zustand"; |
| 3 | 8 | |
| 4 | import type { ComposerSubmissionIntent } from "./composer-logic"; | |
| 9 | import type { LocalDispatchSnapshot } from "./components/ChatView.logic"; | |
| 5 | 10 | import type { ComposerFileAttachment, ComposerImageAttachment } from "./composerDraftStore"; |
| 6 | 11 | import type { TerminalContextDraft } from "./lib/terminalContext"; |
| 7 | 12 | import { randomUUID } from "./lib/utils"; |
| 8 | 13 | import type { ReviewCommentContext } from "./reviewCommentContext"; |
| 9 | 14 | |
| 15 | /** | |
| 16 | * The composer's model and modes when the message was queued. The send uses | |
| 17 | * these instead of the live composer, so it can go out while the user is on | |
| 18 | * another thread. | |
| 19 | */ | |
| 20 | export interface QueuedMessageSendSettings { | |
| 21 | modelSelection: ModelSelection; | |
| 22 | runtimeMode: RuntimeMode; | |
| 23 | interactionMode: ProviderInteractionMode; | |
| 24 | /** Effort written into the prompt text, for providers that read it there. */ | |
| 25 | promptEffort: string | null; | |
| 26 | } | |
| 27 | ||
| 10 | 28 | /** |
| 11 | 29 | * A composer submission held back while the thread's turn is running. It |
| 12 | 30 | * carries the full draft snapshot so the send path can dispatch it later with |
| ... | ... | @@ -20,7 +38,7 @@ export interface QueuedComposerMessage { |
| 20 | 38 | terminalContexts: TerminalContextDraft[]; |
| 21 | 39 | previewAnnotations: PreviewAnnotationPayload[]; |
| 22 | 40 | reviewComments: ReviewCommentContext[]; |
| 23 | submissionIntent: ComposerSubmissionIntent; | |
| 41 | sendSettings: QueuedMessageSendSettings; | |
| 24 | 42 | /** |
| 25 | 43 | * The newest completed tool activity at queue time. A different id later |
| 26 | 44 | * means a tool call finished after the user queued, which is the boundary |
| ... | ... | @@ -32,125 +50,172 @@ export interface QueuedComposerMessage { |
| 32 | 50 | * user pressing send. It waits for Send now instead of leaving on its own. |
| 33 | 51 | */ |
| 34 | 52 | holdUntilUserAction?: boolean; |
| 53 | /** | |
| 54 | * Set while a send is under way; the row stays until it settles. Stop can | |
| 55 | * still take a "preparing" message back (uploads, thread settings), but not | |
| 56 | * a "dispatching" one, whose turn start is already on the wire. | |
| 57 | */ | |
| 58 | sending?: "preparing" | "dispatching"; | |
| 35 | 59 | createdAt: string; |
| 36 | 60 | } |
| 37 | 61 | |
| 62 | /** | |
| 63 | * The thread as it was when its last queued turn start went out. The next | |
| 64 | * message waits until the server has moved past it, so a send that starts a | |
| 65 | * new turn and the message after it do not leave on one boundary. | |
| 66 | */ | |
| 67 | interface QueuedDispatch { | |
| 68 | /** The message that went out, or null for a dispatch restored after a failure. */ | |
| 69 | messageId: string | null; | |
| 70 | thread: LocalDispatchSnapshot; | |
| 71 | /** The dispatch this one replaced. If this send fails, that one still counts. */ | |
| 72 | previous: LocalDispatchSnapshot | null; | |
| 73 | } | |
| 74 | ||
| 38 | 75 | interface QueuedMessageStoreState { |
| 39 | 76 | 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; | |
| 77 | lastDispatchByThreadKey: Record<string, QueuedDispatch>; | |
| 46 | 78 | enqueue: (threadKey: string, message: Omit<QueuedComposerMessage, "id">) => QueuedComposerMessage; |
| 47 | 79 | /** |
| 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. | |
| 80 | * Marks one message as sending and returns it, or null when it is gone or | |
| 81 | * the thread already has a send under way. The other messages are | |
| 82 | * re-anchored to `toolActivityId` so only one leaves per tool boundary. | |
| 51 | 83 | */ |
| 52 | take: ( | |
| 84 | beginSend: ( | |
| 53 | 85 | threadKey: string, |
| 54 | 86 | id: string, |
| 55 | 87 | toolActivityId: string | null, |
| 56 | 88 | ) => QueuedComposerMessage | null; |
| 57 | /** Removes one message without touching the others' anchors. Null when already gone. */ | |
| 58 | remove: (threadKey: string, id: string) => QueuedComposerMessage | null; | |
| 89 | /** The turn start is going out. False when Stop took the message back first. */ | |
| 90 | markDispatching: (threadKey: string, id: string, thread: LocalDispatchSnapshot) => boolean; | |
| 91 | /** Drops a message whose send went out, or that had nothing left to send. */ | |
| 92 | finishSend: (threadKey: string, id: string) => void; | |
| 59 | 93 | /** |
| 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. | |
| 94 | * Moves a message whose send failed back to the head, held for user action. | |
| 95 | * The queue keeps its order and nothing behind it overtakes. False when | |
| 96 | * Stop already took the message back. | |
| 62 | 97 | */ |
| 63 | holdAtFront: (threadKey: string, message: QueuedComposerMessage) => void; | |
| 64 | /** Removes and returns every queued message for the thread, oldest first. */ | |
| 98 | failSend: (threadKey: string, id: string) => boolean; | |
| 99 | /** Removes one message without touching the others' anchors. Null when gone or sending. */ | |
| 100 | remove: (threadKey: string, id: string) => QueuedComposerMessage | null; | |
| 101 | /** Removes and returns every message for the thread that is not already on the wire. */ | |
| 65 | 102 | drain: (threadKey: string) => QueuedComposerMessage[]; |
| 66 | 103 | } |
| 67 | 104 | |
| 68 | 105 | const EMPTY_QUEUE: QueuedComposerMessage[] = []; |
| 69 | 106 | |
| 107 | type QueueState = Pick<QueuedMessageStoreState, "queuesByThreadKey" | "lastDispatchByThreadKey">; | |
| 108 | ||
| 109 | /** Replaces one thread's queue. `lastDispatch` null forgets it; an empty queue always does. */ | |
| 110 | function withQueue( | |
| 111 | state: QueueState, | |
| 112 | threadKey: string, | |
| 113 | queue: QueuedComposerMessage[], | |
| 114 | lastDispatch?: QueuedDispatch | null, | |
| 115 | ): QueueState { | |
| 116 | const queuesByThreadKey = { ...state.queuesByThreadKey, [threadKey]: queue }; | |
| 117 | const lastDispatchByThreadKey = { ...state.lastDispatchByThreadKey }; | |
| 118 | if (lastDispatch) lastDispatchByThreadKey[threadKey] = lastDispatch; | |
| 119 | if (queue.length === 0) delete queuesByThreadKey[threadKey]; | |
| 120 | if (queue.length === 0 || lastDispatch === null) delete lastDispatchByThreadKey[threadKey]; | |
| 121 | return { queuesByThreadKey, lastDispatchByThreadKey }; | |
| 122 | } | |
| 123 | ||
| 70 | 124 | /** 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, | |
| 125 | export const useQueuedMessageStore = create<QueuedMessageStoreState>()((set, get) => { | |
| 126 | const queueOf = (threadKey: string) => get().queuesByThreadKey[threadKey] ?? EMPTY_QUEUE; | |
| 127 | const update = ( | |
| 128 | threadKey: string, | |
| 129 | queue: QueuedComposerMessage[], | |
| 130 | lastDispatch?: QueuedDispatch | null, | |
| 131 | ) => set((state) => withQueue(state, threadKey, queue, lastDispatch)); | |
| 132 | return { | |
| 133 | queuesByThreadKey: {}, | |
| 134 | lastDispatchByThreadKey: {}, | |
| 135 | enqueue: (threadKey, message) => { | |
| 136 | const entry: QueuedComposerMessage = { ...message, id: randomUUID() }; | |
| 137 | update(threadKey, [...queueOf(threadKey), entry]); | |
| 138 | return entry; | |
| 139 | }, | |
| 140 | beginSend: (threadKey, id, toolActivityId) => { | |
| 141 | const queue = queueOf(threadKey); | |
| 142 | const entry = queue.find((message) => message.id === id); | |
| 143 | if (!entry || queue.some((message) => message.sending)) return null; | |
| 144 | update( | |
| 145 | threadKey, | |
| 146 | queue.map((message) => | |
| 147 | message.id === id | |
| 148 | ? { ...message, sending: "preparing" } | |
| 149 | : message.queuedAfterToolActivityId === toolActivityId | |
| 150 | ? message | |
| 151 | : { ...message, queuedAfterToolActivityId: toolActivityId }, | |
| 152 | ), | |
| 132 | 153 | ); |
| 133 | return { | |
| 134 | queuesByThreadKey: { | |
| 135 | ...state.queuesByThreadKey, | |
| 136 | [threadKey]: [{ ...message, holdUntilUserAction: true }, ...rest], | |
| 154 | return entry; | |
| 155 | }, | |
| 156 | markDispatching: (threadKey, id, thread) => { | |
| 157 | const queue = queueOf(threadKey); | |
| 158 | if (!queue.some((message) => message.id === id && message.sending)) return false; | |
| 159 | update( | |
| 160 | threadKey, | |
| 161 | queue.map((message) => | |
| 162 | message.id === id ? { ...message, sending: "dispatching" } : message, | |
| 163 | ), | |
| 164 | { | |
| 165 | messageId: id, | |
| 166 | thread, | |
| 167 | previous: get().lastDispatchByThreadKey[threadKey]?.thread ?? null, | |
| 137 | 168 | }, |
| 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 | })); | |
| 169 | ); | |
| 170 | return true; | |
| 171 | }, | |
| 172 | finishSend: (threadKey, id) => { | |
| 173 | const queue = queueOf(threadKey); | |
| 174 | if (!queue.some((message) => message.id === id)) return; | |
| 175 | update( | |
| 176 | threadKey, | |
| 177 | queue.filter((message) => message.id !== id), | |
| 178 | ); | |
| 179 | }, | |
| 180 | failSend: (threadKey, id) => { | |
| 181 | const queue = queueOf(threadKey); | |
| 182 | const entry = queue.find((message) => message.id === id); | |
| 183 | if (!entry) return false; | |
| 184 | const { sending: _sending, ...rest } = entry; | |
| 185 | // This send never reached the server, so only an earlier one is worth | |
| 186 | // waiting for. | |
| 187 | const dispatch = get().lastDispatchByThreadKey[threadKey]; | |
| 188 | update( | |
| 189 | threadKey, | |
| 190 | [{ ...rest, holdUntilUserAction: true }, ...queue.filter((message) => message.id !== id)], | |
| 191 | dispatch?.messageId !== id | |
| 192 | ? undefined | |
| 193 | : dispatch.previous && { messageId: null, thread: dispatch.previous, previous: null }, | |
| 194 | ); | |
| 195 | return true; | |
| 196 | }, | |
| 197 | remove: (threadKey, id) => { | |
| 198 | const queue = queueOf(threadKey); | |
| 199 | const entry = queue.find((message) => message.id === id); | |
| 200 | if (!entry || entry.sending) return null; | |
| 201 | update( | |
| 202 | threadKey, | |
| 203 | queue.filter((message) => message.id !== id), | |
| 204 | ); | |
| 205 | return entry; | |
| 206 | }, | |
| 207 | drain: (threadKey) => { | |
| 208 | const queue = queueOf(threadKey); | |
| 209 | const drained = queue.filter((message) => message.sending !== "dispatching"); | |
| 210 | if (drained.length === 0) return EMPTY_QUEUE; | |
| 211 | update( | |
| 212 | threadKey, | |
| 213 | queue.filter((message) => message.sending === "dispatching"), | |
| 214 | ); | |
| 215 | return drained; | |
| 216 | }, | |
| 217 | }; | |
| 218 | }); | |
| 154 | 219 | |
| 155 | 220 | /** |
| 156 | 221 | * The newest finished tool call. Its id changing is the boundary a queued |
apps/web/src/routes/__root.tsx+2| ... | ... | @@ -29,6 +29,7 @@ import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAp |
| 29 | 29 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; |
| 30 | 30 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 31 | 31 | import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; |
| 32 | import { QueuedMessageSender } from "../components/QueuedMessageSender"; | |
| 32 | 33 | import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; |
| 33 | 34 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 34 | 35 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; |
| ... | ... | @@ -227,6 +228,7 @@ function RootRouteView() { |
| 227 | 228 | <SshPasswordPromptDialog /> |
| 228 | 229 | <SnapShotCoordinator /> |
| 229 | 230 | <ThreadNotificationCoordinator /> |
| 231 | <QueuedMessageSender /> | |
| 230 | 232 | <ConfirmDialogHost /> |
| 231 | 233 | <CustomSnoozeDialogHost /> |
| 232 | 234 | <SlowRpcRequestToastCoordinator /> |
apps/web/src/state/entities.ts+8| ... | ... | @@ -183,6 +183,14 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | |
| 183 | 183 | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); |
| 184 | 184 | } |
| 185 | 185 | |
| 186 | /** The thread as `useThread` returns it, read outside React. */ | |
| 187 | export function readThread(ref: ScopedThreadRef): EnvironmentThread | null { | |
| 188 | return mergeEnvironmentThread( | |
| 189 | appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)), | |
| 190 | readThreadShell(ref), | |
| 191 | ); | |
| 192 | } | |
| 193 | ||
| 186 | 194 | /** Whether the environment's server understands thread.settle/unsettle. |
| 187 | 195 | False for pre-settlement servers (capability defaults false on decode), |
| 188 | 196 | so clients under version skew fall back instead of erroring. */ |
docs/user/composer.md+1-1| ... | ... | @@ -36,7 +36,7 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin |
| 36 | 36 | |
| 37 | 37 | On web and desktop, a message sent during a running turn waits at the end of the conversation as a |
| 38 | 38 | dashed bubble. It goes out on its own when the agent finishes its next tool |
| 39 | call, or when the turn ends. Use the arrow under the bubble to send it right | |
| 39 | call, or when the turn ends, even while you have another thread open. Use the arrow under the bubble to send it right | |
| 40 | 40 | away, or the X to move it back into the composer. Stop returns every queued |
| 41 | 41 | message to the composer. |
| 42 | 42 |