diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6f7b1425e892e..3f801e0f9fcb2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -227,11 +227,7 @@ import { type ExpandedImagePreview, } from "./ExpandedImagePreview"; import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; -import { - getComposerPromptLengthValidationMessage, - getComposerSubmissionValidationMessage, - submitComposerDraft, -} from "./composerSubmission"; +import { getComposerSubmissionValidationMessage, submitComposerDraft } from "./composerSubmission"; import { composerPromptLengthBannerItem } from "./ComposerPromptLengthValidation"; import { pendingDraftWork } from "./pendingDraftWork"; import { importComposerThreadAttachment } from "./composerThreadImport"; @@ -1212,8 +1208,6 @@ export function ChatComposer(props: ChatComposerProps) { }); const isComposerApprovalState = activePendingApproval !== null; - // A send declines the approvals shown here to redirect the agent, so it steers the waiting - // turn instead of queueing behind it. Approvals the typing guard holds stay unseen and pending. const followUpBehavior = pendingApprovals.some( (approval) => !heldRequestIds.has(approval.requestId), ) @@ -1431,7 +1425,11 @@ export function ChatComposer(props: ChatComposerProps) { } if (composerSubmissionError !== null) { - const nextError = getComposerPromptLengthValidationMessage(prompt); + const nextError = getComposerSubmissionValidationMessage({ + prompt, + isSendBusy, + submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", + }); if (nextError !== composerSubmissionError) { setComposerSubmissionError(nextError); } @@ -2205,6 +2203,7 @@ export function ChatComposer(props: ChatComposerProps) { return; } const submission = submitComposerDraft({ + isSendBusy, prompt: promptRef.current, submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", event, diff --git a/apps/web/src/components/chat/composerSend.logic.test.ts b/apps/web/src/components/chat/composerSend.logic.test.ts index 13e36ea5659c9..8e707568a1296 100644 --- a/apps/web/src/components/chat/composerSend.logic.test.ts +++ b/apps/web/src/components/chat/composerSend.logic.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { describe, expect, it } from "vite-plus/test"; import { formatInlineContextReference } from "../../lib/composerContextReferences"; @@ -6,6 +6,8 @@ import { threadContextReference } from "../../lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { approvalRedirectNotice, + approvalsForComposerRedirect, + composerDraftChangedSinceSend, loadBalancingSendWarning, multipleModelSendBlock, referencedThreadContexts, @@ -124,3 +126,62 @@ describe("approvalRedirectNotice", () => { expect(approvalRedirectNotice(settled)).toBeNull(); }); }); + +describe("approvalsForComposerRedirect", () => { + it("leaves approvals alone when queueing and redirects only shown approvals when steering", () => { + const shown = { + requestId: RuntimeRequestId.make("shown"), + requestKind: "command" as const, + responseCapability: "live" as const, + supportsFeedback: true, + createdAt: "2026-11-05T00:00:00.000Z", + }; + const held = { ...shown, requestId: RuntimeRequestId.make("held") }; + const input = { + approvals: [shown, held], + shownApprovalIds: [shown.requestId], + sameThread: true, + }; + expect(approvalsForComposerRedirect({ ...input, dispatchMode: "queue" })).toEqual([]); + expect(approvalsForComposerRedirect({ ...input, dispatchMode: "steer" })).toEqual([shown]); + expect( + approvalsForComposerRedirect({ ...input, dispatchMode: "steer", sameThread: false }), + ).toEqual([]); + }); +}); + +describe("composerDraftChangedSinceSend", () => { + const sent = { + pendingAttachments: false, + prompt: "Queue this", + sentPrompts: ["Queue this"], + attachmentIds: ["upload"], + sentAttachmentIds: ["upload"], + annotationImageId: undefined, + }; + + it("catches text, attachments and pastes added while the send uploads", () => { + expect(composerDraftChangedSinceSend(sent)).toBe(false); + expect(composerDraftChangedSinceSend({ ...sent, prompt: "Queue this too" })).toBe(true); + expect(composerDraftChangedSinceSend({ ...sent, attachmentIds: ["upload", "added"] })).toBe( + true, + ); + expect(composerDraftChangedSinceSend({ ...sent, pendingAttachments: true })).toBe(true); + }); + + it("ignores the annotation a direct annotation send writes into the draft", () => { + const annotated = { + ...sent, + sentPrompts: ["Queue this", "Queue this @annotation"], + sentAttachmentIds: ["upload", "annotation"], + annotationImageId: "annotation", + }; + expect(composerDraftChangedSinceSend(annotated)).toBe(false); + expect(composerDraftChangedSinceSend({ ...annotated, prompt: "Queue this @annotation" })).toBe( + false, + ); + expect( + composerDraftChangedSinceSend({ ...annotated, attachmentIds: ["upload", "annotation"] }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/composerSend.logic.ts b/apps/web/src/components/chat/composerSend.logic.ts index e19512e6c90d4..3fb4ff6e53459 100644 --- a/apps/web/src/components/chat/composerSend.logic.ts +++ b/apps/web/src/components/chat/composerSend.logic.ts @@ -1,4 +1,6 @@ -import type { EnvironmentId, ThreadContextRecord } from "@t3tools/contracts"; +import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; +import type { ThreadPendingApproval } from "@t3tools/client-runtime/state/thread-requests"; +import type { EnvironmentId, RuntimeRequestId, ThreadContextRecord } from "@t3tools/contracts"; import { collectInlineContextIds } from "../../lib/composerContextReferences"; /** Attached threads the prompt still links to; replacing linked text leaves stale records. */ @@ -79,3 +81,32 @@ export function approvalRedirectNotice(input: { } return null; } + +export function approvalsForComposerRedirect(input: { + readonly approvals: ReadonlyArray; + readonly shownApprovalIds: ReadonlyArray; + readonly dispatchMode: ComposerDispatchMode; + readonly sameThread: boolean; +}) { + if (!input.sameThread || input.dispatchMode === "queue") return []; + return input.approvals.filter((approval) => input.shownApprovalIds.includes(approval.requestId)); +} + +export function composerDraftChangedSinceSend(input: { + readonly pendingAttachments: boolean; + readonly prompt: string; + readonly sentPrompts: ReadonlyArray; + readonly attachmentIds: ReadonlyArray; + readonly sentAttachmentIds: ReadonlyArray; + readonly annotationImageId: string | undefined; +}): boolean { + if (input.pendingAttachments) return true; + const sameIds = (ids: ReadonlyArray) => + ids.length === input.attachmentIds.length && + ids.every((id, index) => id === input.attachmentIds[index]); + const attachmentsUnchanged = + sameIds(input.sentAttachmentIds) || + (input.annotationImageId !== undefined && + sameIds(input.sentAttachmentIds.filter((id) => id !== input.annotationImageId))); + return !input.sentPrompts.includes(input.prompt) || !attachmentsUnchanged; +} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index 6be1cd0733f2b..4297f143f7b2c 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -60,6 +60,29 @@ describe("submitComposerDraft", () => { expect(validationMessage).toBeNull(); }); + it("keeps the next message and explains that the previous send is still saving", () => { + const onSend = vi.fn(); + const preventDefault = vi.fn(); + const input = { + prompt: "Message B", + submissionTarget: "provider-turn" as const, + event: { preventDefault }, + onSend, + }; + expect(submitComposerDraft({ ...input, isSendBusy: true })).toEqual({ + validationMessage: + "Your previous message is still saving. Wait for it to finish, then send again.", + didDispatch: false, + }); + expect(onSend).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(submitComposerDraft({ ...input, isSendBusy: false })).toEqual({ + validationMessage: null, + didDispatch: true, + }); + expect(onSend).toHaveBeenCalledOnce(); + }); + it("blocks when appended context pushes the provider input over the shared limit", () => { const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); const onSend = vi.fn(); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts index b3297a8361e7c..2e9d55ebacc03 100644 --- a/apps/web/src/components/chat/composerSubmission.ts +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -6,6 +6,7 @@ type ComposerSubmitEvent = { preventDefault: () => void }; type ComposerSubmissionInput = { prompt: string; providerInput?: string; + isSendBusy?: boolean; submissionTarget: "provider-turn" | "pending-user-input"; }; @@ -18,7 +19,7 @@ export function getComposerPromptInputLength(prompt: string): number { ); } -export function getComposerPromptLengthValidationMessage(prompt: string): string | null { +function getComposerPromptLengthValidationMessage(prompt: string): string | null { const inputLength = getComposerPromptInputLength(prompt); const excessCharacters = inputLength - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; if (excessCharacters <= 0) return null; @@ -30,6 +31,9 @@ export function getComposerPromptLengthValidationMessage(prompt: string): string export function getComposerSubmissionValidationMessage( options: ComposerSubmissionInput, ): string | null { + if (options.isSendBusy) { + return "Your previous message is still saving. Wait for it to finish, then send again."; + } return options.submissionTarget === "provider-turn" ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt) : null; diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index d890b31861b63..23ac2906402e8 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -93,6 +93,8 @@ import { useClientSettingsHydrated } from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; import { approvalRedirectNotice, + approvalsForComposerRedirect, + composerDraftChangedSinceSend, loadBalancingSendWarning, multipleModelSendBlock, referencedThreadContexts, @@ -512,7 +514,12 @@ export function useComposerSend(input: ComposerSendInput) { return; } const multipleModelSelections = sendCtx.multipleModelSelections; - const approvalFeedbackTarget = pendingApprovalsRef.current.find( + const approvalFeedbackTarget = approvalsForComposerRedirect({ + approvals: pendingApprovalsRef.current, + shownApprovalIds: sendCtx.shownPendingApprovalIds, + dispatchMode, + sameThread: currentRouteThreadKeyRef.current === routeThreadKey, + }).find( (approval) => approval.supportsFeedback && approval.requestId === sendCtx.shownPendingApprovalIds[0], ); @@ -909,46 +916,34 @@ export function useComposerSend(input: ComposerSendInput) { ); return; } - // A message sent while approvals are pending redirects the agent. They are declined only - // here, after everything above has accepted the send, so local commands and rejected - // drafts leave them alone. After navigating away during an upload, the shown approvals - // are another thread's, so this send leaves them alone too. - const approvalsToDecline = - currentRouteThreadKeyRef.current === routeThreadKey - ? pendingApprovalsRef.current.filter((approval) => - sendCtx?.shownPendingApprovalIds.includes(approval.requestId), - ) - : []; - if (approvalsToDecline.length > 0 || approvalFeedbackTarget !== undefined) { - // A draft edited since send, or a paste still landing in it, is no longer the message - // the user sent. - const attachmentIdsAtSend = composerAttachmentsSnapshot.map((attachment) => attachment.id); - // A direct annotation send writes its image and reference into the draft as it sends, - // so the live draft may still lack them; anything else differing is a real edit. - const annotationImageId = directAnnotation?.image?.id; - const attachmentIdsBeforeAnnotation = attachmentIdsAtSend.filter( - (id) => id !== annotationImageId, - ); - const sameIds = (left: ReadonlyArray, right: ReadonlyArray) => - left.length === right.length && left.every((id, index) => id === right[index]); - const draftChangedSinceSend = () => { - if (composerRef.current?.hasPendingAttachments() === true) return true; - const sendContext = composerRef.current?.getSendContext(); - const attachmentIds = sendContext + const approvalsToDecline = approvalsForComposerRedirect({ + approvals: pendingApprovalsRef.current, + shownApprovalIds: sendCtx.shownPendingApprovalIds, + dispatchMode, + sameThread: currentRouteThreadKeyRef.current === routeThreadKey, + }); + const attachmentIdsAtSend = composerAttachmentsSnapshot.map((attachment) => attachment.id); + // A draft edited since send, or a paste still landing in it, is no longer the message + // the user sent. A direct annotation send writes its image and reference into the draft + // as it sends, so the live draft may still lack them. + const draftChangedSinceSend = () => { + const sendContext = composerRef.current?.getSendContext(); + return composerDraftChangedSinceSend({ + pendingAttachments: composerRef.current?.hasPendingAttachments() === true, + prompt: promptRef.current, + sentPrompts: [promptAtSend, promptForSend], + attachmentIds: sendContext ? [...sendContext.images, ...sendContext.files].map((attachment) => attachment.id) - : attachmentIdsAtSend; - const promptUnchanged = - promptRef.current === promptAtSend || promptRef.current === promptForSend; - const attachmentsUnchanged = - sameIds(attachmentIds, attachmentIdsAtSend) || - (annotationImageId !== undefined && - sameIds(attachmentIds, attachmentIdsBeforeAnnotation)); - return !promptUnchanged || !attachmentsUnchanged; - }; + : attachmentIdsAtSend, + sentAttachmentIds: attachmentIdsAtSend, + annotationImageId: directAnnotation?.image?.id, + }); + }; + const changedBeforeDispatch = draftChangedSinceSend(); + if (approvalsToDecline.length > 0 || approvalFeedbackTarget !== undefined) { const draftBeforeDecline = useComposerDraftStore .getState() .getComposerDraft(composerDraftTarget); - const changedBeforeDecline = draftChangedSinceSend(); const pendingIdsBeforeDecline = pendingApprovalsRef.current.map( (approval) => approval.requestId, ); @@ -965,7 +960,7 @@ export function useComposerSend(input: ComposerSendInput) { return; } if ( - changedBeforeDecline || + changedBeforeDispatch || approvalResponding || currentRouteThreadKeyRef.current !== routeThreadKey ) { @@ -992,13 +987,13 @@ export function useComposerSend(input: ComposerSendInput) { return; } const declined = - !changedBeforeDecline && + !changedBeforeDispatch && !approvalResponding && (await declinePendingApprovals(approvalsToDecline, onRespondToApprovalStable).catch( () => false, )); const draftChanged = - changedBeforeDecline || + changedBeforeDispatch || draftChangedSinceSend() || useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) !== draftBeforeDecline; @@ -1032,6 +1027,17 @@ export function useComposerSend(input: ComposerSendInput) { } return; } + } else if (changedBeforeDispatch) { + sendInFlightRef.current = false; + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); + toastManager.add({ + type: "info", + title: "Your message changed, so it wasn't sent.", + description: "Send again when it's ready.", + }); + return; } if (sendQueuesBehindSetup) { setSetupQueueSendThreadKey(activeThreadKey);