diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4182fa03ffa03c68c4600f027c0ee94e085f3586..e43bd23e130bbb3717f0ea3585cfb3f08e49f0ec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -309,6 +309,13 @@ import { reviewCommentContextLabel, terminalContextReference, } from "../lib/composerContextRecords"; +import { + isQueuedMessageDue, + latestCompletedToolActivityId, + type QueuedComposerMessage, + useQueuedMessages, + useQueuedMessageStore, +} from "../queuedMessageStore"; import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; @@ -512,6 +519,7 @@ import { } from "./chat/composerPromptHistory"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_QUEUED_MESSAGES: QueuedComposerMessage[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -3906,10 +3914,18 @@ export default function ChatView(props: ChatViewProps) { const interruptContextRef = useRef({ activeThread, phase, setThreadError }); interruptContextRef.current = { activeThread, phase, setThreadError }; + const restoreQueuedMessagesRef = useRef<(messages: ReadonlyArray) => void>( + () => {}, + ); const onInterrupt = useCallback(async () => { const { activeThread, phase, setThreadError } = interruptContextRef.current; const input = buildRunningThreadTurnInterruptInput(activeThread, phase); if (!input || !activeThread) return; + restoreQueuedMessagesRef.current( + useQueuedMessageStore + .getState() + .drain(scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id))), + ); const result = await interruptThreadTurn({ environmentId: activeThread.environmentId, input, @@ -7073,6 +7089,81 @@ export default function ChatView(props: ChatViewProps) { } }; + const queuedMessages = useQueuedMessages(activeThreadKey ?? ""); + // Puts queued messages back into the composer, e.g. after Stop or a failed + // send. Prompts join with blank lines; attachments and contexts are added. + const restoreQueuedMessagesToComposer = (messages: ReadonlyArray) => { + if (messages.length === 0) return; + const prompts = [promptRef.current, ...messages.map((message) => message.prompt)] + .map((prompt) => prompt.trim()) + .filter((prompt) => prompt.length > 0); + const nextPrompt = prompts.join("\n\n"); + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + // The draft store silently drops attachments over the per-turn cap. Split + // the overflow back into the queue so nothing is lost; the user can send + // the first batch and the rest follows as a queued message. + const attachmentRoom = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - + composerImagesRef.current.length - + composerFilesRef.current.length, + ); + const attachments = messages.flatMap((message) => [...message.images, ...message.files]); + const restored = attachments.slice(0, attachmentRoom); + const overflow = attachments.slice(attachmentRoom); + const restoredImages = restored.filter((attachment) => attachment.type === "image"); + const restoredFiles = restored.filter((attachment) => attachment.type === "file"); + // The composer syncs these refs from the draft in an effect; a send before + // that effect runs must already see the restored content. + composerImagesRef.current = [...composerImagesRef.current, ...restoredImages]; + composerFilesRef.current = [...composerFilesRef.current, ...restoredFiles]; + if (restoredImages.length > 0) addComposerDraftImages(composerDraftTarget, restoredImages); + if (restoredFiles.length > 0) addComposerDraftFiles(composerDraftTarget, restoredFiles); + if (overflow.length > 0 && activeThreadKey) { + useQueuedMessageStore.getState().enqueue(activeThreadKey, { + prompt: "", + images: overflow.filter((attachment) => attachment.type === "image"), + files: overflow.filter((attachment) => attachment.type === "file"), + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + submissionIntent: "foreground", + queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), + // Restoration is not a send. The user decides when the overflow goes. + holdUntilUserAction: true, + createdAt: new Date().toISOString(), + }); + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Some attachments stayed queued", + 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.`, + }), + ); + } + const restoredTerminalContexts = [ + ...composerTerminalContextsRef.current, + ...messages.flatMap((message) => message.terminalContexts), + ]; + composerTerminalContextsRef.current = restoredTerminalContexts; + setComposerDraftTerminalContexts(composerDraftTarget, restoredTerminalContexts); + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + setComposerDraftPreviewAnnotations(composerDraftTarget, [ + ...(draft?.previewAnnotations ?? []), + ...messages.flatMap((message) => message.previewAnnotations), + ]); + setComposerDraftReviewComments(composerDraftTarget, [ + ...(draft?.reviewComments ?? []), + ...messages.flatMap((message) => message.reviewComments), + ]); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(nextPrompt, nextPrompt.length), + prompt: nextPrompt, + detectTrigger: true, + }); + }; + const onSend = async ( e?: { preventDefault: () => void }, submissionIntent: ComposerSubmissionIntent = "foreground", @@ -7080,6 +7171,8 @@ export default function ChatView(props: ChatViewProps) { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; }, + /** A queued message being sent now instead of the live composer draft. */ + queuedMessage?: QueuedComposerMessage, ) => { e?.preventDefault(); // Typed out in full rather than picked from the menu. Attachments or contexts @@ -7088,6 +7181,7 @@ export default function ChatView(props: ChatViewProps) { usageLimitsOffered && usageLimitsKey !== null && !directAnnotation && + !queuedMessage && !composerHasNonPromptContent && isUsageLimitsCommand(promptRef.current) ) { @@ -7149,7 +7243,9 @@ export default function ChatView(props: ChatViewProps) { return; } if (activePendingProgress) { - if (directAnnotation) { + // A queued message waits until the question is answered; it must not + // be submitted as the answer. + if (directAnnotation || queuedMessage) { notifyDirectAnnotationAttached(); return; } @@ -7167,6 +7263,8 @@ export default function ChatView(props: ChatViewProps) { terminalContexts: composerTerminalContexts, previewAnnotations: sendContextPreviewAnnotations, reviewComments: composerReviewComments, + } = queuedMessage ?? sendCtx; + const { selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -7209,11 +7307,13 @@ export default function ChatView(props: ChatViewProps) { : sendContextPreviewAnnotations; // A direct "send annotation" writes the draft and sends in the same tick; the reference // must be in the text now, not after the next render. - const promptForSend = directAnnotation - ? ensureInlineContextReferences(promptRef.current, [ - previewAnnotationContextReference(directAnnotation.annotation), - ]) - : promptRef.current; + const promptForSend = queuedMessage + ? queuedMessage.prompt + : directAnnotation + ? ensureInlineContextReferences(promptRef.current, [ + previewAnnotationContextReference(directAnnotation.annotation), + ]) + : promptRef.current; const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, @@ -7234,7 +7334,7 @@ export default function ChatView(props: ChatViewProps) { composerReviewComments.length === 0 ? parseCodexFeedbackCommand(trimmed) : null; - if (feedbackCommand) { + if (feedbackCommand && !queuedMessage) { if (!isServerThread || activeThread.session === null) { toastManager.add( stackedThreadToast({ @@ -7285,6 +7385,7 @@ export default function ChatView(props: ChatViewProps) { } if ( !directAnnotation && + !queuedMessage && sendInteractionModeEnabled && showPlanFollowUpPrompt && activeProposedPlan && @@ -7356,7 +7457,7 @@ export default function ChatView(props: ChatViewProps) { composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; - if (standaloneSlashCommand) { + if (standaloneSlashCommand && !queuedMessage) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -7377,6 +7478,12 @@ export default function ChatView(props: ChatViewProps) { }), ); } + // A queued message whose only content expired would retry on every + // boundary and block the rest of the queue. Nothing sendable is left + // in it, so drop it and let the queue move on. + if (queuedMessage && activeThreadKey) { + useQueuedMessageStore.getState().remove(activeThreadKey, queuedMessage.id); + } return; } if (!activeProject) { @@ -7389,6 +7496,36 @@ export default function ChatView(props: ChatViewProps) { ); return; } + // A send during a running turn waits in the queue. It leaves on the next + // tool boundary, when the turn ends, or when the user clicks Steer. The + // provider treats a mid-turn send as a steer of the active turn, so the + // dispatch below is the same either way. + if (!queuedMessage && !directAnnotation && phase === "running" && activeThreadKey) { + if (composerRef.current?.validateProviderInput(promptForSend) === false) { + return; + } + useQueuedMessageStore.getState().enqueue(activeThreadKey, { + prompt: promptForSend, + images: [...composerImages], + files: [...composerFiles], + terminalContexts: [...composerTerminalContexts], + previewAnnotations: [...composerPreviewAnnotations], + reviewComments: [...composerReviewComments], + submissionIntent, + queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), + createdAt: new Date().toISOString(), + }); + promptRef.current = ""; + // Attachments move with the message; their uploads stay pending. The + // refs clear now too, so a Stop before the composer's sync effect runs + // does not restore the moved attachments twice. + composerImagesRef.current = []; + composerFilesRef.current = []; + composerTerminalContextsRef.current = []; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + return; + } const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = @@ -7444,6 +7581,11 @@ export default function ChatView(props: ChatViewProps) { text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { + // A queued message that no longer fits is held at the head for the + // user to edit via Cancel, instead of failing on every boundary. + if (queuedMessage && activeThreadKey) { + useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage); + } return; } @@ -7464,10 +7606,41 @@ export default function ChatView(props: ChatViewProps) { }; sendInFlightRef.current = true; + // Every early return above leaves a queued message in the queue for a + // later retry. From here on a failure hands it back to the composer. + if (queuedMessage) { + const taken = activeThreadKey + ? useQueuedMessageStore + .getState() + .take( + activeThreadKey, + queuedMessage.id, + latestCompletedToolActivityId(threadActivities), + ) + : null; + if (!taken) { + sendInFlightRef.current = false; + return; + } + } + // Stop drains the queue. A queued send whose upload was still running at + // that moment must not start a turn afterwards; it checks this before + // dispatch and hands the message back to the composer instead. + const drainGenerationAtTake = useQueuedMessageStore.getState().drainGeneration; + // A queued send that fails goes back to the head of the queue, held. The + // messages behind it keep their order and wait; the composer is not + // touched, which also keeps a failure after navigation off the new + // thread's draft. The user retries with Send now or edits with Cancel. + const abortQueuedReplay = () => { + if (queuedMessage && activeThreadKey) { + useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage); + } + }; const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason); + abortQueuedReplay(); return; } const turnUsesAttachmentUploads = @@ -7487,15 +7660,26 @@ export default function ChatView(props: ChatViewProps) { if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason); + abortQueuedReplay(); return; } if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, "Retry or remove failed uploads before sending."); + abortQueuedReplay(); return; } } + if ( + queuedMessage && + useQueuedMessageStore.getState().drainGeneration !== drainGenerationAtTake + ) { + sendInFlightRef.current = false; + restoreQueuedMessagesToComposer([queuedMessage]); + return; + } + const resolvedSubmissionIntent = submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; if ( @@ -7528,6 +7712,7 @@ export default function ChatView(props: ChatViewProps) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); + abortQueuedReplay(); return; } beginLocalDispatch({ @@ -7628,9 +7813,11 @@ export default function ChatView(props: ChatViewProps) { }), ); } - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + if (!queuedMessage) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { @@ -7854,7 +8041,24 @@ export default function ChatView(props: ChatViewProps) { } if (failure !== null) { - if ( + if (queuedMessage) { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + const next = existing.filter((message) => message.id !== messageIdForSend); + return next.length === existing.length ? existing : next; + }); + // The optimistic row's preview URLs were just revoked, so the images + // need fresh ones before the row can show them again. + if (activeThreadKey) { + useQueuedMessageStore.getState().holdAtFront(activeThreadKey, { + ...queuedMessage, + images: queuedMessage.images.map(cloneComposerImageForRetry), + }); + } + } else if ( promptRef.current.length === 0 && composerImagesRef.current.length === 0 && composerFilesRef.current.length === 0 && @@ -7920,6 +8124,75 @@ export default function ChatView(props: ChatViewProps) { } }; + // Sends the oldest queued message once it is due: a tool call finished + // after it was queued, or the turn ended. Only one leaves per boundary; the + // take inside onSend re-anchors the rest. + const sendQueuedMessage = useEffectEvent((message: QueuedComposerMessage) => { + void onSend(undefined, message.submissionIntent, undefined, message); + }); + const nextQueuedMessage = queuedMessages[0] ?? null; + const latestToolActivityId = useMemo( + () => (nextQueuedMessage ? latestCompletedToolActivityId(threadActivities) : null), + [nextQueuedMessage, threadActivities], + ); + // Approvals and questions block the agent; a steer landing on top of them + // would answer nothing and confuse the turn, so the queue holds until the + // user resolves them. + const queueBlockedByPendingRequest = + activePendingApproval !== null || pendingUserInputs.length > 0; + // onSend bails early on transient gates (environment offline, settings not + // hydrated, checkpoint rewinding, messages loading, machine not chosen) and + // leaves the message queued. Re-run when any of them clear so a due message + // does not wait for an unrelated phase change. + const queueSendGate = + activeEnvironmentUnavailable || + !clientSettingsHydrated || + isRevertingCheckpoint || + threadDetailLoading || + needsLoadBalancing || + activeProviderStatus === null; + useEffect(() => { + if (!nextQueuedMessage || isSendBusy || queueBlockedByPendingRequest || queueSendGate) return; + if (sendInFlightRef.current) return; + if (!isQueuedMessageDue({ message: nextQueuedMessage, phase, latestToolActivityId })) return; + sendQueuedMessage(nextQueuedMessage); + }, [ + isSendBusy, + latestToolActivityId, + nextQueuedMessage, + phase, + queueBlockedByPendingRequest, + queueSendGate, + ]); + + // The row handlers are read from refs at call-time so their identity stays + // stable and does not bust TimelineRowCtx on every ChatView render. + const queuedMessageActionsRef = useRef({ + steer: (_id: string) => {}, + remove: (_id: string) => {}, + }); + queuedMessageActionsRef.current = { + steer: (id) => { + const message = queuedMessages.find((entry) => entry.id === id); + if (!message || sendInFlightRef.current || queueBlockedByPendingRequest) return; + void onSend(undefined, message.submissionIntent, undefined, message); + }, + remove: (id) => { + if (!activeThreadKey) return; + const message = useQueuedMessageStore.getState().remove(activeThreadKey, id); + if (message) restoreQueuedMessagesToComposer([message]); + }, + }; + const onSteerQueuedMessage = useCallback((id: string) => { + queuedMessageActionsRef.current.steer(id); + }, []); + const onRemoveQueuedMessage = useCallback((id: string) => { + queuedMessageActionsRef.current.remove(id); + }, []); + // Stop also cancels the queue: the messages return to the composer instead + // of starting a new turn the moment the interrupted one settles. + restoreQueuedMessagesRef.current = restoreQueuedMessagesToComposer; + const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { if (!activeThreadId) return; @@ -9190,6 +9463,9 @@ export default function ChatView(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns} + queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages} + onSteerQueuedMessage={onSteerQueuedMessage} + onRemoveQueuedMessage={onRemoveQueuedMessage} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index efaa9d6ece2a1f796f55e11c594503a9ec575439..5bcdb05dbfdf90007065160c767ac1902de40d9d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1166,7 +1166,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; - showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -1200,7 +1199,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} - showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -6845,7 +6843,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting} - showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 45ef93568cf671637e74fd435feb973b5be083dd..b2f017bcf335f0073d37eaeac771a421faba506d 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -44,7 +44,7 @@ function renderPendingActions(isRunning: boolean) { ); } -function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { +function renderRunningActions(hasSendableContent: boolean) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { compact: true, @@ -58,7 +58,6 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: isEnvironmentUnavailable: false, isPreparingWorktree: false, hasSendableContent, - showSendWhileRunning, onPreviousPendingQuestion: () => {}, onInterrupt: () => {}, onImplementPlanInNewThread: () => {}, @@ -125,25 +124,18 @@ describe("ComposerPrimaryActions", () => { expect(markup).not.toContain("stage-nightly"); }); - it("only renders stop while running when Enter-to-send is available", () => { - const markup = renderRunningActions(false, true); + it("renders a queue action alongside stop while running with a sendable draft", () => { + const markup = renderRunningActions(true); expect(markup).toContain('aria-label="Stop generation"'); - expect(markup).not.toContain('aria-label="Send message"'); - }); - - it("renders send alongside stop while running when Enter-to-send is unavailable", () => { - const markup = renderRunningActions(true, true); - - expect(markup).toContain('aria-label="Stop generation"'); - expect(markup).toContain('aria-label="Send message"'); + expect(markup).toContain('aria-label="Queue message"'); expect(markup).toContain('type="submit"'); }); it("keeps stop as the only action while running with an empty composer", () => { - const markup = renderRunningActions(true, false); + const markup = renderRunningActions(false); expect(markup).toContain('aria-label="Stop generation"'); - expect(markup).not.toContain('aria-label="Send message"'); + expect(markup).not.toContain('aria-label="Queue message"'); }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 91c54b75ed032fa9a790cc22f1f501edf33b55f0..c71c8fd234a2c527d893b568f602840bbf7f4580 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -29,9 +29,6 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; - /** Enter-to-send is disabled on mobile viewports, where stop would otherwise - * be the only primary action and a running turn could not be steered. */ - showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -72,7 +69,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, - showSendWhileRunning = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -93,7 +89,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ "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", insidePendingAction ? "size-8 sm:size-7" - : showSendWhileRunning && hasSendableContent + : hasSendableContent ? "size-9 sm:size-8" : "size-8 sm:h-8 sm:w-8", )} @@ -247,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ? "Preparing worktree" : isSendBusy ? "Sending" - : "Send message" + : isRunning + ? "Queue message" + : "Send message" } > {stageBackdropVariant ? ( @@ -275,10 +273,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ return sendButton; } + // While a turn runs, a sendable draft queues for the next tool boundary, so + // the send button stays next to Stop on every viewport. return ( <> {renderStopGenerationButton(false)} - {showSendWhileRunning && hasSendableContent ? sendButton : null} + {hasSendableContent ? sendButton : null} ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 39cd8f8318a818c15e1319c3a86cdde73d8f41b0..24078b5f0af02ddf66acd934c2f5654d14f40f20 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1092,6 +1092,40 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + it("appends queued messages after the live rows, marking the oldest as next", () => { + const queuedMessage = (id: string, prompt: string) => ({ + id, + prompt, + images: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + submissionIntent: "foreground" as const, + queuedAfterToolActivityId: null, + createdAt: "2026-01-01T00:00:01Z", + }); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + queuedMessages: [queuedMessage("q1", "first"), queuedMessage("q2", "second")], + }); + + expect(rows.map((row) => row.kind)).toEqual([ + "working", + "thinking", + "queued-message", + "queued-message", + ]); + expect(rows.slice(2)).toMatchObject([ + { id: "queued-message:q1", isNext: true, queuedMessage: { prompt: "first" } }, + { id: "queued-message:q2", isNext: false, queuedMessage: { prompt: "second" } }, + ]); + }); + it("shows the worktree setup card instead of the working placeholder", () => { const snapshot: WorktreeSetupSnapshot = { threadId: ThreadId.make("thread-setup"), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3ce0a5fd64497142b9b22b1b9d5a78e4270d73d8..983e49dfaa87def8b8366344c4870359d7d2de08 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -28,6 +28,7 @@ import { type WorkLogEntry, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; +import type { QueuedComposerMessage } from "../../queuedMessageStore"; import { type MessageId, type OrchestrationLatestTurn, @@ -402,6 +403,14 @@ export type MessagesTimelineRow = snapshot: WorktreeSetupSnapshot; /** The agent already started; render only the script row under the turn header. */ embedded: boolean; + } + | { + kind: "queued-message"; + id: string; + createdAt: string; + queuedMessage: QueuedComposerMessage; + /** Oldest queued message, the one the next boundary sends. */ + isNext: boolean; }; export interface StableMessagesTimelineRowsState { @@ -872,6 +881,8 @@ export function deriveMessagesTimelineRows(input: { liveAgentTaskIds?: ReadonlySet | undefined; /** Live bootstrap progress. Renders a stage card under the first user message. */ worktreeSetup?: WorktreeSetupSnapshot | null; + /** Messages sent during the running turn, rendered after the live rows. */ + queuedMessages?: ReadonlyArray; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -1329,8 +1340,17 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); } - - return attachTrailingToolGroupsToAssistant(nextRows); + const rows = attachTrailingToolGroupsToAssistant(nextRows); + input.queuedMessages?.forEach((queuedMessage, index) => { + rows.push({ + kind: "queued-message", + id: `queued-message:${queuedMessage.id}`, + createdAt: queuedMessage.createdAt, + queuedMessage, + isNext: index === 0, + }); + }); + return rows; } export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; @@ -1468,6 +1488,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "proposed-plan": return a.proposedPlan === (b as typeof a).proposedPlan; + case "queued-message": { + const bq = b as typeof a; + return a.queuedMessage === bq.queuedMessage && a.isNext === bq.isNext; + } + case "work": { const bw = b as typeof a; return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b6383724e036ac1b1cfeee84f1a941a196d1b62f..61381ddb71f7cf14049679491a1e04ed1c78389f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,4 @@ +import { ArrowUpIcon, ClockIcon } from "lucide-react"; import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { @@ -45,6 +46,8 @@ import { const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); const NOOP_OPEN_AGENTS = () => {}; +const EMPTY_QUEUED_MESSAGES: ReadonlyArray = []; +const NOOP_QUEUED_MESSAGE_ACTION = (_id: string) => {}; const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; @@ -131,6 +134,7 @@ import type { KnownComposerContextRecord, } from "@t3tools/contracts"; import { Button } from "../ui/button"; +import type { QueuedComposerMessage } from "../../queuedMessageStore"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; @@ -280,6 +284,8 @@ interface TimelineRowSharedState { onCancelWorktreeSetup: (() => void) | null; onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; + onSteerQueuedMessage: (id: string) => void; + onRemoveQueuedMessage: (id: string) => void; } interface TimelineRowActivityState { @@ -432,6 +438,10 @@ interface MessagesTimelineProps { topFadeEnabled?: boolean; /** Non-null when older turns exist beyond the loaded window. */ loadEarlier?: CitationHistoryPage | null; + /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */ + queuedMessages?: ReadonlyArray; + onSteerQueuedMessage?: (id: string) => void; + onRemoveQueuedMessage?: (id: string) => void; } // --------------------------------------------------------------------------- @@ -484,6 +494,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, + queuedMessages = EMPTY_QUEUED_MESSAGES, + onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, + onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -707,6 +720,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ supportsConversationRollback, liveAgentTaskIds, worktreeSetup, + queuedMessages, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -729,6 +743,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ supportsConversationRollback, liveAgentTaskIds, worktreeSetup, + queuedMessages, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -924,6 +939,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, + onSteerQueuedMessage, + onRemoveQueuedMessage, }), [ readyCitationRequest, @@ -954,6 +971,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onCancelWorktreeSetup, onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, + onSteerQueuedMessage, + onRemoveQueuedMessage, ], ); const activityState = useMemo( @@ -1445,6 +1464,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "working" ? : null} {row.kind === "thinking" ? : null} {row.kind === "worktree-setup" ? : null} + {row.kind === "queued-message" ? : null} ); }); @@ -1474,6 +1494,103 @@ function WorktreeSetupTimelineRow({ ); } +/** A message waiting for the running turn: a dashed user bubble with icon actions inside it. */ +function QueuedMessageTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const { queuedMessage } = row; + const attachmentCount = queuedMessage.images.length + queuedMessage.files.length; + const contextCount = + queuedMessage.terminalContexts.length + + queuedMessage.previewAnnotations.length + + queuedMessage.reviewComments.length; + const text = queuedMessage.prompt.trim(); + const statusLabel = queuedMessage.holdUntilUserAction + ? "Waits for Send now" + : row.isNext + ? "Sends after the next tool call or when the turn ends" + : "Sends after the messages above it"; + return ( +
+
+ {text.length > 0 ? ( +
{text}
+ ) : null} + {attachmentCount > 0 || contextCount > 0 ? ( +
0 && "mt-1.5")}> + {[ + attachmentCount > 0 + ? `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}` + : null, + contextCount > 0 + ? `${contextCount} context item${contextCount === 1 ? "" : "s"}` + : null, + ] + .filter(Boolean) + .join(", ")} +
+ ) : null} +
+ + } + aria-label={`Queued. ${statusLabel}.`} + > + + Queued + + {statusLabel} + +
+ + event.preventDefault()} + onClick={() => ctx.onSteerQueuedMessage(queuedMessage.id)} + aria-label="Send now" + /> + } + > + + + Send now + + + event.preventDefault()} + onClick={() => ctx.onRemoveQueuedMessage(queuedMessage.id)} + aria-label="Cancel and return to the composer" + /> + } + > + + + Cancel and return to the composer + +
+
+
+
+ ); +} + function ContextCompactionTimelineRow({ row, }: { diff --git a/apps/web/src/queuedMessageStore.test.ts b/apps/web/src/queuedMessageStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..33869d89da6223766066b6cf3a2936bbe6edd287 --- /dev/null +++ b/apps/web/src/queuedMessageStore.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + isQueuedMessageDue, + latestCompletedToolActivityId, + useQueuedMessageStore, + type QueuedComposerMessage, +} from "./queuedMessageStore"; + +function makeMessage(prompt: string): Omit { + return { + prompt, + images: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + submissionIntent: "foreground", + queuedAfterToolActivityId: null, + createdAt: "2026-09-11T00:00:00.000Z", + }; +} + +describe("queuedMessageStore", () => { + beforeEach(() => { + useQueuedMessageStore.setState({ queuesByThreadKey: {}, drainGeneration: 0 }); + }); + + it("keeps messages in submission order per thread", () => { + const { enqueue } = useQueuedMessageStore.getState(); + enqueue("thread-a", makeMessage("first")); + enqueue("thread-a", makeMessage("second")); + enqueue("thread-b", makeMessage("other")); + + const queues = useQueuedMessageStore.getState().queuesByThreadKey; + expect(queues["thread-a"]?.map((message) => message.prompt)).toEqual(["first", "second"]); + expect(queues["thread-b"]?.map((message) => message.prompt)).toEqual(["other"]); + }); + + it("take hands the message to exactly one caller", () => { + const { enqueue, take } = useQueuedMessageStore.getState(); + const entry = enqueue("thread-a", makeMessage("first")); + + expect(take("thread-a", entry.id, null)?.prompt).toBe("first"); + expect(take("thread-a", entry.id, null)).toBeNull(); + expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toBeUndefined(); + }); + + it("take re-anchors the remaining messages to the current tool boundary", () => { + const { enqueue, take } = useQueuedMessageStore.getState(); + const first = enqueue("thread-a", makeMessage("first")); + enqueue("thread-a", makeMessage("second")); + + take("thread-a", first.id, "tool-2"); + + const [second] = useQueuedMessageStore.getState().queuesByThreadKey["thread-a"] ?? []; + expect(second?.queuedAfterToolActivityId).toBe("tool-2"); + expect( + isQueuedMessageDue({ message: second!, phase: "running", latestToolActivityId: "tool-2" }), + ).toBe(false); + }); + + it("remove keeps the other messages' anchors", () => { + const { enqueue, remove } = useQueuedMessageStore.getState(); + const first = enqueue("thread-a", { ...makeMessage("first"), queuedAfterToolActivityId: "t1" }); + const second = enqueue("thread-a", makeMessage("second")); + + expect(remove("thread-a", second.id)?.prompt).toBe("second"); + expect(remove("thread-a", second.id)).toBeNull(); + expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toEqual([first]); + }); + + it("holdAtFront returns a failed message to the head, held", () => { + const { enqueue, take, holdAtFront } = useQueuedMessageStore.getState(); + const first = enqueue("thread-a", makeMessage("first")); + enqueue("thread-a", makeMessage("second")); + const taken = take("thread-a", first.id, "t1")!; + + holdAtFront("thread-a", taken); + + const queue = useQueuedMessageStore.getState().queuesByThreadKey["thread-a"] ?? []; + expect(queue.map((message) => message.prompt)).toEqual(["first", "second"]); + expect(queue[0]?.holdUntilUserAction).toBe(true); + expect( + isQueuedMessageDue({ message: queue[0]!, phase: "ready", latestToolActivityId: null }), + ).toBe(false); + }); + + it("drain empties one thread's queue in order", () => { + const { enqueue, drain } = useQueuedMessageStore.getState(); + enqueue("thread-a", makeMessage("first")); + enqueue("thread-a", makeMessage("second")); + enqueue("thread-b", makeMessage("other")); + + expect(drain("thread-a").map((message) => message.prompt)).toEqual(["first", "second"]); + expect(useQueuedMessageStore.getState().drainGeneration).toBe(1); + expect(drain("thread-a")).toEqual([]); + expect(useQueuedMessageStore.getState().drainGeneration).toBe(1); + expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-b"]).toHaveLength(1); + }); +}); + +describe("queued message dispatch timing", () => { + const activities = [ + { id: "a1", kind: "tool.started", sequence: 1, createdAt: "2026-01-01T00:00:01Z" }, + { id: "a2", kind: "tool.completed", sequence: 2, createdAt: "2026-01-01T00:00:02Z" }, + { id: "a3", kind: "tool.updated", sequence: 3, createdAt: "2026-01-01T00:00:03Z" }, + ]; + + it("finds the newest completed tool call by sequence, not position", () => { + expect(latestCompletedToolActivityId(activities)).toBe("a2"); + expect(latestCompletedToolActivityId([])).toBeNull(); + expect( + latestCompletedToolActivityId([ + { id: "late", kind: "tool.completed", sequence: 9, createdAt: "2026-01-01T00:00:09Z" }, + { id: "early", kind: "tool.completed", sequence: 4, createdAt: "2026-01-01T00:00:04Z" }, + ]), + ).toBe("late"); + }); + + it("waits mid-turn until a tool call finishes after the message was queued", () => { + const message = { queuedAfterToolActivityId: "a2" }; + expect(isQueuedMessageDue({ message, phase: "running", latestToolActivityId: "a2" })).toBe( + false, + ); + expect(isQueuedMessageDue({ message, phase: "running", latestToolActivityId: "a4" })).toBe( + true, + ); + }); + + it("never auto-sends a message held for user action", () => { + const message = { queuedAfterToolActivityId: null, holdUntilUserAction: true }; + expect(isQueuedMessageDue({ message, phase: "ready", latestToolActivityId: "a4" })).toBe(false); + }); + + it("is due as soon as the turn is over, but not while a send is connecting", () => { + const message = { queuedAfterToolActivityId: "a2" }; + expect(isQueuedMessageDue({ message, phase: "ready", latestToolActivityId: "a2" })).toBe(true); + expect(isQueuedMessageDue({ message, phase: "connecting", latestToolActivityId: "a4" })).toBe( + false, + ); + }); +}); diff --git a/apps/web/src/queuedMessageStore.ts b/apps/web/src/queuedMessageStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..b342a1380f7a8bdf58cd3c1628fcf8597688d1f7 --- /dev/null +++ b/apps/web/src/queuedMessageStore.ts @@ -0,0 +1,201 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { create } from "zustand"; + +import type { ComposerSubmissionIntent } from "./composer-logic"; +import type { ComposerFileAttachment, ComposerImageAttachment } from "./composerDraftStore"; +import type { TerminalContextDraft } from "./lib/terminalContext"; +import { randomUUID } from "./lib/utils"; +import type { ReviewCommentContext } from "./reviewCommentContext"; + +/** + * A composer submission held back while the thread's turn is running. It + * carries the full draft snapshot so the send path can dispatch it later with + * the same text, attachments, and contexts the user pressed Enter on. + */ +export interface QueuedComposerMessage { + id: string; + prompt: string; + images: ComposerImageAttachment[]; + files: ComposerFileAttachment[]; + terminalContexts: TerminalContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; + reviewComments: ReviewCommentContext[]; + submissionIntent: ComposerSubmissionIntent; + /** + * The newest completed tool activity at queue time. A different id later + * means a tool call finished after the user queued, which is the boundary + * the message goes out on. + */ + queuedAfterToolActivityId: string | null; + /** + * Set when the message was created by Stop or a failed restore, not by the + * user pressing send. It waits for Send now instead of leaving on its own. + */ + holdUntilUserAction?: boolean; + createdAt: string; +} + +interface QueuedMessageStoreState { + queuesByThreadKey: Record; + /** + * Bumped by `drain`. A send that took a message before a drain and finishes + * its upload after it compares this to the value it captured and gives up, + * so Stop cannot be followed by a queued message starting a new turn. + */ + drainGeneration: number; + enqueue: (threadKey: string, message: Omit) => QueuedComposerMessage; + /** + * Removes one message and returns it, or null when another caller already + * took it. The remaining messages are re-anchored to `toolActivityId` so + * only one queued message leaves per tool boundary. + */ + take: ( + threadKey: string, + id: string, + toolActivityId: string | null, + ) => QueuedComposerMessage | null; + /** Removes one message without touching the others' anchors. Null when already gone. */ + remove: (threadKey: string, id: string) => QueuedComposerMessage | null; + /** + * Puts a message back at the head, held for user action. Used when its + * send failed: the queue keeps its order and nothing behind it overtakes. + */ + holdAtFront: (threadKey: string, message: QueuedComposerMessage) => void; + /** Removes and returns every queued message for the thread, oldest first. */ + drain: (threadKey: string) => QueuedComposerMessage[]; +} + +const EMPTY_QUEUE: QueuedComposerMessage[] = []; + +/** In-memory only: a queued message is a live intent, not a draft worth persisting. */ +export const useQueuedMessageStore = create()((set, get) => ({ + queuesByThreadKey: {}, + drainGeneration: 0, + enqueue: (threadKey, message) => { + const entry: QueuedComposerMessage = { ...message, id: randomUUID() }; + set((state) => ({ + queuesByThreadKey: { + ...state.queuesByThreadKey, + [threadKey]: [...(state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE), entry], + }, + })); + return entry; + }, + take: (threadKey, id, toolActivityId) => { + const queue = get().queuesByThreadKey[threadKey]; + const entry = queue?.find((message) => message.id === id); + if (!queue || !entry) { + return null; + } + set((state) => { + const remaining = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE) + .filter((message) => message.id !== id) + .map((message) => + message.queuedAfterToolActivityId === toolActivityId + ? message + : { ...message, queuedAfterToolActivityId: toolActivityId }, + ); + const queuesByThreadKey = { ...state.queuesByThreadKey }; + if (remaining.length === 0) { + delete queuesByThreadKey[threadKey]; + } else { + queuesByThreadKey[threadKey] = remaining; + } + return { queuesByThreadKey }; + }); + return entry; + }, + remove: (threadKey, id) => { + const queue = get().queuesByThreadKey[threadKey]; + const entry = queue?.find((message) => message.id === id); + if (!queue || !entry) { + return null; + } + set((state) => { + const remaining = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE).filter( + (message) => message.id !== id, + ); + const queuesByThreadKey = { ...state.queuesByThreadKey }; + if (remaining.length === 0) { + delete queuesByThreadKey[threadKey]; + } else { + queuesByThreadKey[threadKey] = remaining; + } + return { queuesByThreadKey }; + }); + return entry; + }, + holdAtFront: (threadKey, message) => { + set((state) => { + const rest = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE).filter( + (entry) => entry.id !== message.id, + ); + return { + queuesByThreadKey: { + ...state.queuesByThreadKey, + [threadKey]: [{ ...message, holdUntilUserAction: true }, ...rest], + }, + }; + }); + }, + drain: (threadKey) => { + const queue = get().queuesByThreadKey[threadKey]; + if (!queue || queue.length === 0) { + return EMPTY_QUEUE; + } + set((state) => { + const queuesByThreadKey = { ...state.queuesByThreadKey }; + delete queuesByThreadKey[threadKey]; + return { queuesByThreadKey, drainGeneration: state.drainGeneration + 1 }; + }); + return queue; + }, +})); + +/** + * The newest finished tool call. Its id changing is the boundary a queued + * message goes out on. Live arrays are sorted, but a snapshot loaded from the + * database is not, so pick by sequence rather than position. + */ +export function latestCompletedToolActivityId( + activities: ReadonlyArray<{ + readonly id: string; + readonly kind: string; + readonly sequence?: number | undefined; + readonly createdAt: string; + }>, +): string | null { + let latest: (typeof activities)[number] | null = null; + for (const activity of activities) { + if (activity.kind !== "tool.completed") continue; + if ( + latest === null || + (activity.sequence ?? -1) > (latest.sequence ?? -1) || + ((activity.sequence ?? -1) === (latest.sequence ?? -1) && + activity.createdAt > latest.createdAt) + ) { + latest = activity; + } + } + return latest?.id ?? null; +} + +/** + * A queued message is due mid-turn once a tool call finished after it was + * queued, and as soon as the turn is over otherwise. "connecting" is the gap + * between a send and the provider picking it up, so nothing is due there. + */ +export function isQueuedMessageDue(input: { + message: Pick; + phase: "connecting" | "running" | "ready" | "disconnected"; + latestToolActivityId: string | null; +}): boolean { + if (input.message.holdUntilUserAction) return false; + if (input.phase === "connecting") return false; + if (input.phase !== "running") return true; + return input.latestToolActivityId !== input.message.queuedAfterToolActivityId; +} + +export function useQueuedMessages(threadKey: string): QueuedComposerMessage[] { + return useQueuedMessageStore((state) => state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE); +} diff --git a/docs/user/composer.md b/docs/user/composer.md index 4da45221589344f86948626500c8fe2338136d6e..8ed45837388d6534ed6f1213a1743ed9c4b933c8 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -29,6 +29,14 @@ also send files to T3 Code through another app's system share sheet. See [images and videos](#images-and-videos-in-messages) for previewing and saving media. +## Send while the agent is working + +A message sent during a running turn waits at the end of the conversation as a +dashed bubble. It goes out on its own when the agent finishes its next tool +call, or when the turn ends. Use the arrow under the bubble to send it right +away, or the X to move it back into the composer. Stop returns every queued +message to the composer. + ## Queue messages offline on mobile Mobile keeps local copies of draft attachments, so you can preview them and queue