authorgravatar for naveediqbal949@gmail.comNaveed Iqbal <naveediqbal949@gmail.com> 2026-08-15 17:07:07+05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:07:07+00:00
log7c55e86320aac9c68ae53a7bc15682b7e14f98bf
tree1e03e7265c1f60964f84028325b0f1ac7d12cf15
parentc0f9d917c1ab08d30f2b3715dd25d2175a6d2ecf
signature Signed by PGP key B5690EEEBB952194

fix(web): reject oversized prompts before provider turn start (#6602)


7 files changed, 358 insertions(+), 21 deletions(-)

apps/web/src/components/ChatView.tsx+37-20
......@@ -4994,6 +4994,16 @@ function ChatViewContent(props: ChatViewProps) {
49944994 draftText: trimmed,
49954995 planMarkdown: activeProposedPlan.planMarkdown,
49964996 });
4997 const outgoingFollowUpText = formatOutgoingPrompt({
4998 provider: ctxSelectedProvider,
4999 model: ctxSelectedModel,
5000 models: ctxSelectedProviderModels,
5001 effort: ctxSelectedPromptEffort,
5002 text: followUp.text.trim(),
5003 });
5004 if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) {
5005 return;
5006 }
49975007 promptRef.current = "";
49985008 clearComposerDraftContent(composerDraftTarget);
49995009 composerRef.current?.resetCursorState();
......@@ -5063,24 +5073,6 @@ function ChatViewContent(props: ChatViewProps) {
50635073 return;
50645074 }
50655075
5066 sendInFlightRef.current = true;
5067 if (isDraftHeroState && activeThreadKey) {
5068 let resolveDockStarted: (() => void) | undefined;
5069 const dockStarted = new Promise<void>((resolve) => {
5070 resolveDockStarted = resolve;
5071 });
5072 const dockTransition = runMobileComposerTransition(() => {
5073 flushSync(() => {
5074 captureDraftHeroComposerRect();
5075 setDockedDraftHeroThreadKey(activeThreadKey);
5076 });
5077 resolveDockStarted?.();
5078 });
5079 void dockTransition.catch(() => resolveDockStarted?.());
5080 await dockStarted;
5081 }
5082 beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });
5083
50845076 const composerImagesSnapshot = [...composerImages];
50855077 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
50865078 const composerElementContextsSnapshot = [...composerElementContexts];
......@@ -5098,8 +5090,6 @@ function ChatViewContent(props: ChatViewProps) {
50985090 messageTextWithPreviewAnnotations,
50995091 composerReviewCommentsSnapshot,
51005092 );
5101 const messageIdForSend = newMessageId();
5102 const messageCreatedAt = new Date().toISOString();
51035093 const outgoingMessageText = formatOutgoingPrompt({
51045094 provider: ctxSelectedProvider,
51055095 model: ctxSelectedModel,
......@@ -5107,6 +5097,30 @@ function ChatViewContent(props: ChatViewProps) {
51075097 effort: ctxSelectedPromptEffort,
51085098 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
51095099 });
5100 if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {
5101 return;
5102 }
5103
5104 sendInFlightRef.current = true;
5105 if (isDraftHeroState && activeThreadKey) {
5106 let resolveDockStarted: (() => void) | undefined;
5107 const dockStarted = new Promise<void>((resolve) => {
5108 resolveDockStarted = resolve;
5109 });
5110 const dockTransition = runMobileComposerTransition(() => {
5111 flushSync(() => {
5112 captureDraftHeroComposerRect();
5113 setDockedDraftHeroThreadKey(activeThreadKey);
5114 });
5115 resolveDockStarted?.();
5116 });
5117 void dockTransition.catch(() => resolveDockStarted?.());
5118 await dockStarted;
5119 }
5120 beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });
5121
5122 const messageIdForSend = newMessageId();
5123 const messageCreatedAt = new Date().toISOString();
51105124 const turnAttachmentsPromise = Promise.all(
51115125 composerImagesSnapshot.map(async (image) => ({
51125126 type: "image" as const,
......@@ -5723,6 +5737,9 @@ function ChatViewContent(props: ChatViewProps) {
57235737 effort: ctxSelectedPromptEffort,
57245738 text: implementationPrompt,
57255739 });
5740 if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) {
5741 return;
5742 }
57265743 const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));
57275744 const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection;
57285745
apps/web/src/components/chat/ChatComposer.tsx+66-1
......@@ -107,6 +107,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded
107107import { basenameOfPath } from "../../pierre-icons";
108108import { cn, randomUUID } from "~/lib/utils";
109109import { Separator } from "../ui/separator";
110import {
111 getComposerPromptLengthValidationMessage,
112 getComposerSubmissionValidationMessage,
113 submitComposerDraft,
114} from "./composerSubmission";
115import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
110116
111117type ComposerCommandMenuPosition = {
112118 bottom: number;
......@@ -488,6 +494,8 @@ export interface ChatComposerHandle {
488494 selectedModel: string;
489495 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;
490496 };
497 /** Validate the fully composed text immediately before a provider turn starts. */
498 validateProviderInput: (providerInput: string) => boolean;
491499}
492500
493501// --------------------------------------------------------------------------
......@@ -951,6 +959,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
951959 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
952960 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
953961 const [isComposerFocused, setIsComposerFocused] = useState(false);
962 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
963 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
964 null,
965 );
954966 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
955967 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
956968 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
......@@ -967,6 +979,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
967979 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
968980 const composerFormRef = useRef<HTMLFormElement>(null);
969981 const composerSurfaceRef = useRef<HTMLDivElement>(null);
982 const providerInputRejectedRef = useRef(false);
970983 const composerSelectLockRef = useRef(false);
971984 const composerMenuOpenRef = useRef(false);
972985 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
......@@ -1309,6 +1322,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13091322 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
13101323 }, [prompt, promptRef]);
13111324
1325 useEffect(() => {
1326 if (composerSubmissionError === null) return;
1327 const nextError = getComposerPromptLengthValidationMessage(prompt);
1328 if (nextError !== composerSubmissionError) {
1329 setComposerSubmissionError(nextError);
1330 }
1331 }, [composerSubmissionError, prompt]);
1332
1333 useEffect(() => {
1334 setProviderInputSubmissionError(null);
1335 }, [
1336 composerElementContexts,
1337 composerPreviewAnnotations,
1338 composerReviewComments,
1339 composerTerminalContexts,
1340 prompt,
1341 selectedModel,
1342 selectedPromptEffort,
1343 selectedProvider,
1344 ]);
1345
13121346 useEffect(() => {
13131347 composerImagesRef.current = composerImages;
13141348 }, [composerImages, composerImagesRef]);
......@@ -1400,6 +1434,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14001434 // ------------------------------------------------------------------
14011435 useEffect(() => {
14021436 setComposerHighlightedItemId(null);
1437 setComposerSubmissionError(null);
1438 setProviderInputSubmissionError(null);
14031439 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
14041440 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
14051441 setIsDragOverComposer(false);
......@@ -1826,17 +1862,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18261862 });
18271863 return;
18281864 }
1829 onSend(event);
1865 const submission = submitComposerDraft({
1866 prompt: promptRef.current,
1867 submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn",
1868 event,
1869 onSend: (sendEvent) => {
1870 // ChatView reports its final composed-input preflight through the
1871 // composer handle before its first asynchronous send step.
1872 providerInputRejectedRef.current = false;
1873 onSend(sendEvent);
1874 return !providerInputRejectedRef.current;
1875 },
1876 });
1877 setComposerSubmissionError(submission.validationMessage);
1878 if (!submission.didDispatch) return;
18301879 if (shouldBlurMobileComposerOnSubmit()) {
18311880 blurMobileComposerAfterSend();
18321881 }
18331882 },
18341883 [
18351884 activeThreadId,
1885 activePendingProgress,
18361886 blurMobileComposerAfterSend,
18371887 isSendDisabled,
18381888 noProviderAvailable,
18391889 onSend,
1890 promptRef,
18401891 shouldBlurMobileComposerOnSubmit,
18411892 ],
18421893 );
......@@ -2590,6 +2641,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25902641 selectedModel,
25912642 selectedProviderModels,
25922643 }),
2644 validateProviderInput: (providerInput: string) => {
2645 const validationMessage = getComposerSubmissionValidationMessage({
2646 prompt: promptRef.current,
2647 providerInput,
2648 submissionTarget: "provider-turn",
2649 });
2650 providerInputRejectedRef.current = validationMessage !== null;
2651 setProviderInputSubmissionError(validationMessage);
2652 return validationMessage === null;
2653 },
25932654 }),
25942655 [
25952656 activeThread,
......@@ -3058,6 +3119,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30583119 </div>
30593120 </div>
30603121
3122 <ComposerPromptLengthValidation
3123 message={providerInputSubmissionError ?? composerSubmissionError}
3124 />
3125
30613126 {/* Bottom toolbar */}
30623127 {isComposerCollapsedMobile ? null : activePendingApproval ? (
30633128 <div className="flex items-center justify-end gap-2 px-3 pb-3 sm:px-4 sm:pb-4">
apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx created+23
......@@ -0,0 +1,23 @@
1import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";
4
5import { getComposerPromptLengthValidationMessage } from "./composerSubmission";
6import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
7
8describe("ComposerPromptLengthValidation", () => {
9 it("renders oversized prompt feedback as an actionable composer alert", () => {
10 const message = getComposerPromptLengthValidationMessage(
11 "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1),
12 );
13
14 const markup = renderToStaticMarkup(<ComposerPromptLengthValidation message={message} />);
15
16 expect(markup).toContain('role="alert"');
17 expect(markup).toContain('data-chat-composer-validation="prompt-length"');
18 expect(markup).toContain(
19 "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.",
20 );
21 expect(markup).not.toContain("ProviderValidationError");
22 });
23});
apps/web/src/components/chat/ComposerPromptLengthValidation.tsx created+13
......@@ -0,0 +1,13 @@
1export function ComposerPromptLengthValidation({ message }: { message: string | null }) {
2 if (!message) return null;
3
4 return (
5 <p
6 role="alert"
7 className="px-3 pb-2 text-xs text-destructive sm:px-4"
8 data-chat-composer-validation="prompt-length"
9 >
10 {message}
11 </p>
12 );
13}
apps/web/src/components/chat/composerSubmission.test.ts created+170
......@@ -0,0 +1,170 @@
1import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import { submitComposerDraft } from "./composerSubmission";
5
6describe("submitComposerDraft", () => {
7 it("keeps an oversized draft editable and sends a corrected follow-up", () => {
8 let draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1);
9 let validationMessage: string | null = null;
10 const dispatchedDrafts: string[] = [];
11 const preventDefault = vi.fn();
12
13 const submit = () => {
14 const result = submitComposerDraft({
15 prompt: draft,
16 submissionTarget: "provider-turn",
17 event: { preventDefault },
18 onSend: () => {
19 dispatchedDrafts.push(draft);
20 },
21 });
22 validationMessage = result.validationMessage;
23 };
24
25 submit();
26
27 expect(dispatchedDrafts).toEqual([]);
28 expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1);
29 expect(validationMessage).toBe(
30 "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.",
31 );
32 expect(preventDefault).toHaveBeenCalledOnce();
33
34 draft = "Corrected prompt";
35 submit();
36
37 expect(dispatchedDrafts).toEqual(["Corrected prompt"]);
38 expect(validationMessage).toBeNull();
39 });
40
41 it("allows a draft at the shared character limit through the normal send path", () => {
42 const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
43 const onSend = vi.fn();
44 const preventDefault = vi.fn();
45
46 const result = submitComposerDraft({
47 prompt: draft,
48 submissionTarget: "provider-turn",
49 event: { preventDefault },
50 onSend,
51 });
52
53 expect(result).toEqual({ validationMessage: null, didDispatch: true });
54 expect(onSend).toHaveBeenCalledOnce();
55 expect(preventDefault).not.toHaveBeenCalled();
56 });
57
58 it("blocks when appended context pushes the provider input over the shared limit", () => {
59 const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
60 const onSend = vi.fn();
61
62 const result = submitComposerDraft({
63 prompt: draft,
64 providerInput: `${draft}\n\nTerminal context`,
65 submissionTarget: "provider-turn",
66 event: undefined,
67 onSend,
68 });
69
70 expect(result).toEqual({
71 validationMessage:
72 "Prompt is 18 characters over the 120,000-character limit. Shorten or split it before sending.",
73 didDispatch: false,
74 });
75 expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
76 expect(onSend).not.toHaveBeenCalled();
77
78 const correctedResult = submitComposerDraft({
79 prompt: "Corrected prompt",
80 providerInput: "Corrected prompt\n\nShort terminal context",
81 submissionTarget: "provider-turn",
82 event: undefined,
83 onSend,
84 });
85
86 expect(correctedResult).toEqual({ validationMessage: null, didDispatch: true });
87 expect(onSend).toHaveBeenCalledOnce();
88 });
89
90 it("does not finish submission when the send boundary rejects composed provider input", () => {
91 const preventDefault = vi.fn();
92
93 const result = submitComposerDraft({
94 prompt: "Sendable raw draft",
95 submissionTarget: "provider-turn",
96 event: { preventDefault },
97 onSend: () => false,
98 });
99
100 expect(result).toEqual({ validationMessage: null, didDispatch: false });
101 expect(preventDefault).toHaveBeenCalledOnce();
102 });
103
104 it("allows fully composed provider input at the shared character limit", () => {
105 const onSend = vi.fn();
106
107 const result = submitComposerDraft({
108 prompt: "Short draft",
109 providerInput: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS),
110 submissionTarget: "provider-turn",
111 event: undefined,
112 onSend,
113 });
114
115 expect(result).toEqual({ validationMessage: null, didDispatch: true });
116 expect(onSend).toHaveBeenCalledOnce();
117 });
118
119 it("blocks a generated plan follow-up that exceeds the shared limit", () => {
120 const onSend = vi.fn();
121
122 const result = submitComposerDraft({
123 prompt: "",
124 providerInput: `PLEASE IMPLEMENT THIS PLAN:\n${"x".repeat(
125 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
126 )}`,
127 submissionTarget: "provider-turn",
128 event: undefined,
129 onSend,
130 });
131
132 expect(result.didDispatch).toBe(false);
133 expect(result.validationMessage).toContain("over the 120,000-character limit");
134 expect(onSend).not.toHaveBeenCalled();
135 });
136
137 it("allows surrounding whitespace that the provider turn contract trims", () => {
138 const draft = ` ${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)} `;
139 const onSend = vi.fn();
140 const preventDefault = vi.fn();
141
142 const result = submitComposerDraft({
143 prompt: draft,
144 submissionTarget: "provider-turn",
145 event: { preventDefault },
146 onSend,
147 });
148
149 expect(result).toEqual({ validationMessage: null, didDispatch: true });
150 expect(onSend).toHaveBeenCalledOnce();
151 expect(preventDefault).not.toHaveBeenCalled();
152 });
153
154 it("dispatches pending user input answers on their separate response path", () => {
155 const answer = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1);
156 const onSend = vi.fn();
157 const preventDefault = vi.fn();
158
159 const result = submitComposerDraft({
160 prompt: answer,
161 submissionTarget: "pending-user-input",
162 event: { preventDefault },
163 onSend,
164 });
165
166 expect(result).toEqual({ validationMessage: null, didDispatch: true });
167 expect(onSend).toHaveBeenCalledOnce();
168 expect(preventDefault).not.toHaveBeenCalled();
169 });
170});
apps/web/src/components/chat/composerSubmission.ts created+44
......@@ -0,0 +1,44 @@
1import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
2
3type ComposerSubmitEvent = { preventDefault: () => void };
4
5type ComposerSubmissionInput = {
6 prompt: string;
7 providerInput?: string;
8 submissionTarget: "provider-turn" | "pending-user-input";
9};
10
11export function getComposerPromptLengthValidationMessage(prompt: string): string | null {
12 const excessCharacters = prompt.trim().length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS;
13 if (excessCharacters <= 0) return null;
14
15 const characterLabel = excessCharacters === 1 ? "character" : "characters";
16 return `Prompt is ${excessCharacters.toLocaleString("en-US")} ${characterLabel} over the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")}-character limit. Shorten or split it before sending.`;
17}
18
19export function getComposerSubmissionValidationMessage(
20 options: ComposerSubmissionInput,
21): string | null {
22 return options.submissionTarget === "provider-turn"
23 ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt)
24 : null;
25}
26
27export function submitComposerDraft(
28 options: ComposerSubmissionInput & {
29 event: ComposerSubmitEvent | undefined;
30 onSend: (event?: ComposerSubmitEvent) => boolean | void;
31 },
32): { validationMessage: string | null; didDispatch: boolean } {
33 const validationMessage = getComposerSubmissionValidationMessage(options);
34 if (validationMessage) {
35 options.event?.preventDefault();
36 return { validationMessage, didDispatch: false };
37 }
38
39 if (options.onSend(options.event) === false) {
40 options.event?.preventDefault();
41 return { validationMessage: null, didDispatch: false };
42 }
43 return { validationMessage: null, didDispatch: true };
44}
docs/user/composer.md created+5
......@@ -0,0 +1,5 @@
1# Message composer
2
3Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the
4composer and shows how many characters need to be removed. Shorten the draft or split it into
5multiple messages, then send again in the same thread.