Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
)
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 62 additions & 1 deletion apps/web/src/components/chat/composerSend.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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";
import { threadContextReference } from "../../lib/composerContextRecords";
import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
import {
approvalRedirectNotice,
approvalsForComposerRedirect,
composerDraftChangedSinceSend,
loadBalancingSendWarning,
multipleModelSendBlock,
referencedThreadContexts,
Expand Down Expand Up @@ -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);
});
});
33 changes: 32 additions & 1 deletion apps/web/src/components/chat/composerSend.logic.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -79,3 +81,32 @@ export function approvalRedirectNotice(input: {
}
return null;
}

export function approvalsForComposerRedirect(input: {
readonly approvals: ReadonlyArray<ThreadPendingApproval>;
readonly shownApprovalIds: ReadonlyArray<RuntimeRequestId>;
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<string>;
readonly attachmentIds: ReadonlyArray<string>;
readonly sentAttachmentIds: ReadonlyArray<string>;
readonly annotationImageId: string | undefined;
}): boolean {
if (input.pendingAttachments) return true;
const sameIds = (ids: ReadonlyArray<string>) =>
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;
}
23 changes: 23 additions & 0 deletions apps/web/src/components/chat/composerSubmission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/chat/composerSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type ComposerSubmitEvent = { preventDefault: () => void };
type ComposerSubmissionInput = {
prompt: string;
providerInput?: string;
isSendBusy?: boolean;
submissionTarget: "provider-turn" | "pending-user-input";
};

Expand All @@ -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;
Expand All @@ -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;
Expand Down
86 changes: 46 additions & 40 deletions apps/web/src/components/chat/useComposerSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ import { useClientSettingsHydrated } from "../../hooks/useSettings";
import { environmentPresentations } from "../../state/presentation";
import {
approvalRedirectNotice,
approvalsForComposerRedirect,
composerDraftChangedSinceSend,
loadBalancingSendWarning,
multipleModelSendBlock,
referencedThreadContexts,
Expand Down Expand Up @@ -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],
);
Expand Down Expand Up @@ -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<string>, right: ReadonlyArray<string>) =>
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,
);
Expand All @@ -965,7 +960,7 @@ export function useComposerSend(input: ComposerSendInput) {
return;
}
if (
changedBeforeDecline ||
changedBeforeDispatch ||
approvalResponding ||
currentRouteThreadKeyRef.current !== routeThreadKey
) {
Expand All @@ -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;
Expand Down Expand Up @@ -1032,6 +1027,17 @@ export function useComposerSend(input: ComposerSendInput) {
}
return;
}
} else if (changedBeforeDispatch) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High chat/useComposerSend.ts:1030

If the user switches from thread A to thread B while A's attachment upload is pending, this branch cancels A's send because the shared composer refs now describe B's draft. Restrict this cancellation to the original route, or compare against A's stored draft, so navigation alone doesn't discard the accepted send.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/useComposerSend.ts around line 1030:

If the user switches from thread A to thread B while A's attachment upload is pending, this branch cancels A's send because the shared composer refs now describe B's draft. Restrict this cancellation to the original route, or compare against A's stored draft, so navigation alone doesn't discard the accepted send.

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);
Expand Down
Loading