From 9fa4786563d3aefe81dd7c800209e2eb58b4fe80 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Fri, 14 Aug 2026 16:37:36 +0500 Subject: [PATCH 1/4] fix(web): reject oversized composer prompts --- apps/web/src/components/chat/ChatComposer.tsx | 26 ++++++++- .../ComposerPromptLengthValidation.test.tsx | 23 ++++++++ .../chat/ComposerPromptLengthValidation.tsx | 13 +++++ .../chat/composerSubmission.test.ts | 53 +++++++++++++++++++ .../src/components/chat/composerSubmission.ts | 26 +++++++++ docs/user/composer.md | 5 ++ 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.tsx create mode 100644 apps/web/src/components/chat/composerSubmission.test.ts create mode 100644 apps/web/src/components/chat/composerSubmission.ts create mode 100644 docs/user/composer.md diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e918e7758688..4dd2b7a4756f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -106,6 +106,11 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; +import { + getComposerPromptLengthValidationMessage, + submitComposerDraft, +} from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; type ComposerCommandMenuPosition = { bottom: number; @@ -947,6 +952,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ @@ -1306,6 +1312,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt, promptRef]); + useEffect(() => { + if (composerSubmissionError === null) return; + const nextError = getComposerPromptLengthValidationMessage(prompt); + if (nextError !== composerSubmissionError) { + setComposerSubmissionError(nextError); + } + }, [composerSubmissionError, prompt]); + useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); @@ -1397,6 +1411,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ useEffect(() => { setComposerHighlightedItemId(null); + setComposerSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; @@ -1824,7 +1839,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } - onSend(event); + const submission = submitComposerDraft({ + prompt: promptRef.current, + event, + onSend, + }); + setComposerSubmissionError(submission.validationMessage); + if (!submission.didDispatch) return; if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } @@ -1835,6 +1856,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendDisabled, noProviderAvailable, onSend, + promptRef, shouldBlurMobileComposerOnSubmit, ], ); @@ -3086,6 +3108,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) + + {/* Bottom toolbar */} {isComposerCollapsedMobile ? null : activePendingApproval ? (
diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx new file mode 100644 index 000000000000..3ffb4fa9c20a --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx @@ -0,0 +1,23 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { getComposerPromptLengthValidationMessage } from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; + +describe("ComposerPromptLengthValidation", () => { + it("renders oversized prompt feedback as an actionable composer alert", () => { + const message = getComposerPromptLengthValidationMessage( + "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + ); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain('data-chat-composer-validation="prompt-length"'); + expect(markup).toContain( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(markup).not.toContain("ProviderValidationError"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx new file mode 100644 index 000000000000..88e4c3b813eb --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx @@ -0,0 +1,13 @@ +export function ComposerPromptLengthValidation({ message }: { message: string | null }) { + if (!message) return null; + + return ( +

+ {message} +

+ ); +} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts new file mode 100644 index 000000000000..eb4941e5b5d7 --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -0,0 +1,53 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { submitComposerDraft } from "./composerSubmission"; + +describe("submitComposerDraft", () => { + it("keeps an oversized draft editable and sends a corrected follow-up", () => { + let draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + let validationMessage: string | null = null; + const dispatchedDrafts: string[] = []; + const preventDefault = vi.fn(); + + const submit = () => { + const result = submitComposerDraft({ + prompt: draft, + event: { preventDefault }, + onSend: () => dispatchedDrafts.push(draft), + }); + validationMessage = result.validationMessage; + }; + + submit(); + + expect(dispatchedDrafts).toEqual([]); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + expect(validationMessage).toBe( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(preventDefault).toHaveBeenCalledOnce(); + + draft = "Corrected prompt"; + submit(); + + expect(dispatchedDrafts).toEqual(["Corrected prompt"]); + expect(validationMessage).toBeNull(); + }); + + it("allows a draft at the shared character limit through the normal send path", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts new file mode 100644 index 000000000000..0e311ad3e53f --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -0,0 +1,26 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; + +type ComposerSubmitEvent = { preventDefault: () => void }; + +export function getComposerPromptLengthValidationMessage(prompt: string): string | null { + const excessCharacters = prompt.length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + if (excessCharacters <= 0) return null; + + const characterLabel = excessCharacters === 1 ? "character" : "characters"; + 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.`; +} + +export function submitComposerDraft(options: { + prompt: string; + event: ComposerSubmitEvent | undefined; + onSend: (event?: ComposerSubmitEvent) => void; +}): { validationMessage: string | null; didDispatch: boolean } { + const validationMessage = getComposerPromptLengthValidationMessage(options.prompt); + if (validationMessage) { + options.event?.preventDefault(); + return { validationMessage, didDispatch: false }; + } + + options.onSend(options.event); + return { validationMessage: null, didDispatch: true }; +} diff --git a/docs/user/composer.md b/docs/user/composer.md new file mode 100644 index 000000000000..d2e49db247b0 --- /dev/null +++ b/docs/user/composer.md @@ -0,0 +1,5 @@ +# Message composer + +Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the +composer and shows how many characters need to be removed. Shorten the draft or split it into +multiple messages, then send again in the same thread. From c3609db70cbb266bc5e72d1bec3f31be0a80e959 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Fri, 14 Aug 2026 17:21:19 +0500 Subject: [PATCH 2/4] fix(web): align composer validation paths --- apps/web/src/components/chat/ChatComposer.tsx | 2 ++ .../chat/composerSubmission.test.ts | 36 +++++++++++++++++++ .../src/components/chat/composerSubmission.ts | 8 +++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4dd2b7a4756f..85c53cabcbb9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1841,6 +1841,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const submission = submitComposerDraft({ prompt: promptRef.current, + submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", event, onSend, }); @@ -1852,6 +1853,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [ activeThreadId, + activePendingProgress, blurMobileComposerAfterSend, isSendDisabled, noProviderAvailable, diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index eb4941e5b5d7..31fd7ea8962d 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -13,6 +13,7 @@ describe("submitComposerDraft", () => { const submit = () => { const result = submitComposerDraft({ prompt: draft, + submissionTarget: "provider-turn", event: { preventDefault }, onSend: () => dispatchedDrafts.push(draft), }); @@ -42,6 +43,41 @@ describe("submitComposerDraft", () => { const result = submitComposerDraft({ prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("allows surrounding whitespace that the provider turn contract trims", () => { + const draft = ` ${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)} `; + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("dispatches pending user input answers on their separate response path", () => { + const answer = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: answer, + submissionTarget: "pending-user-input", event: { preventDefault }, onSend, }); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts index 0e311ad3e53f..f7e7254834e6 100644 --- a/apps/web/src/components/chat/composerSubmission.ts +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -3,7 +3,7 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; type ComposerSubmitEvent = { preventDefault: () => void }; export function getComposerPromptLengthValidationMessage(prompt: string): string | null { - const excessCharacters = prompt.length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const excessCharacters = prompt.trim().length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; if (excessCharacters <= 0) return null; const characterLabel = excessCharacters === 1 ? "character" : "characters"; @@ -12,10 +12,14 @@ export function getComposerPromptLengthValidationMessage(prompt: string): string export function submitComposerDraft(options: { prompt: string; + submissionTarget: "provider-turn" | "pending-user-input"; event: ComposerSubmitEvent | undefined; onSend: (event?: ComposerSubmitEvent) => void; }): { validationMessage: string | null; didDispatch: boolean } { - const validationMessage = getComposerPromptLengthValidationMessage(options.prompt); + const validationMessage = + options.submissionTarget === "provider-turn" + ? getComposerPromptLengthValidationMessage(options.prompt) + : null; if (validationMessage) { options.event?.preventDefault(); return { validationMessage, didDispatch: false }; From 16f7778bc3ab0f301817a8a636e3afae4b4b31c0 Mon Sep 17 00:00:00 2001 From: naveed949 Date: Fri, 14 Aug 2026 18:06:02 +0500 Subject: [PATCH 3/4] fix(web): validate composed provider prompts --- apps/web/src/components/ChatView.tsx | 57 ++++++++++------ apps/web/src/components/chat/ChatComposer.tsx | 33 +++++++++- .../chat/composerSubmission.test.ts | 65 +++++++++++++++++++ .../src/components/chat/composerSubmission.ts | 31 ++++++--- 4 files changed, 155 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3da816618a18..992a9023f829 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4980,6 +4980,16 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + const outgoingFollowUpText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: followUp.text.trim(), + }); + if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { + return; + } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5049,24 +5059,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { - let resolveDockStarted: (() => void) | undefined; - const dockStarted = new Promise((resolve) => { - resolveDockStarted = resolve; - }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); - void dockTransition.catch(() => resolveDockStarted?.()); - await dockStarted; - } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -5084,8 +5076,6 @@ function ChatViewContent(props: ChatViewProps) { messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -5093,6 +5083,30 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { + return; + } + + sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -5709,6 +5723,9 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: implementationPrompt, }); + if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) { + return; + } const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 85c53cabcbb9..f65afec90b5b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -108,6 +108,7 @@ import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; import { getComposerPromptLengthValidationMessage, + getComposerSubmissionValidationMessage, submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; @@ -489,6 +490,8 @@ export interface ChatComposerHandle { selectedModel: string; selectedProviderModels: ReadonlyArray; }; + /** Validate the fully composed text immediately before a provider turn starts. */ + validateProviderInput: (providerInput: string) => boolean; } // -------------------------------------------------------------------------- @@ -953,6 +956,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerSubmissionError, setComposerSubmissionError] = useState(null); + const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( + null, + ); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ @@ -1320,6 +1326,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }, [composerSubmissionError, prompt]); + useEffect(() => { + setProviderInputSubmissionError(null); + }, [ + composerElementContexts, + composerPreviewAnnotations, + composerReviewComments, + composerTerminalContexts, + prompt, + selectedModel, + selectedPromptEffort, + selectedProvider, + ]); + useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); @@ -1412,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { setComposerHighlightedItemId(null); setComposerSubmissionError(null); + setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; @@ -2640,6 +2660,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProviderModels, }), + validateProviderInput: (providerInput: string) => { + const validationMessage = getComposerSubmissionValidationMessage({ + prompt: promptRef.current, + providerInput, + submissionTarget: "provider-turn", + }); + setProviderInputSubmissionError(validationMessage); + return validationMessage === null; + }, }), [ activeThread, @@ -3110,7 +3139,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
- + {/* Bottom toolbar */} {isComposerCollapsedMobile ? null : activePendingApproval ? ( diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index 31fd7ea8962d..e7f1ad0309bf 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -53,6 +53,71 @@ describe("submitComposerDraft", () => { expect(preventDefault).not.toHaveBeenCalled(); }); + 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(); + + const result = submitComposerDraft({ + prompt: draft, + providerInput: `${draft}\n\nTerminal context`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ + validationMessage: + "Prompt is 18 characters over the 120,000-character limit. Shorten or split it before sending.", + didDispatch: false, + }); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(onSend).not.toHaveBeenCalled(); + + const correctedResult = submitComposerDraft({ + prompt: "Corrected prompt", + providerInput: "Corrected prompt\n\nShort terminal context", + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(correctedResult).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("allows fully composed provider input at the shared character limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Short draft", + providerInput: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("blocks a generated plan follow-up that exceeds the shared limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "", + providerInput: `PLEASE IMPLEMENT THIS PLAN:\n${"x".repeat( + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + )}`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result.didDispatch).toBe(false); + expect(result.validationMessage).toContain("over the 120,000-character limit"); + expect(onSend).not.toHaveBeenCalled(); + }); + it("allows surrounding whitespace that the provider turn contract trims", () => { 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 f7e7254834e6..a9830755bdab 100644 --- a/apps/web/src/components/chat/composerSubmission.ts +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -2,6 +2,12 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; type ComposerSubmitEvent = { preventDefault: () => void }; +type ComposerSubmissionInput = { + prompt: string; + providerInput?: string; + submissionTarget: "provider-turn" | "pending-user-input"; +}; + export function getComposerPromptLengthValidationMessage(prompt: string): string | null { const excessCharacters = prompt.trim().length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; if (excessCharacters <= 0) return null; @@ -10,16 +16,21 @@ export function getComposerPromptLengthValidationMessage(prompt: string): string 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.`; } -export function submitComposerDraft(options: { - prompt: string; - submissionTarget: "provider-turn" | "pending-user-input"; - event: ComposerSubmitEvent | undefined; - onSend: (event?: ComposerSubmitEvent) => void; -}): { validationMessage: string | null; didDispatch: boolean } { - const validationMessage = - options.submissionTarget === "provider-turn" - ? getComposerPromptLengthValidationMessage(options.prompt) - : null; +export function getComposerSubmissionValidationMessage( + options: ComposerSubmissionInput, +): string | null { + return options.submissionTarget === "provider-turn" + ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt) + : null; +} + +export function submitComposerDraft( + options: ComposerSubmissionInput & { + event: ComposerSubmitEvent | undefined; + onSend: (event?: ComposerSubmitEvent) => void; + }, +): { validationMessage: string | null; didDispatch: boolean } { + const validationMessage = getComposerSubmissionValidationMessage(options); if (validationMessage) { options.event?.preventDefault(); return { validationMessage, didDispatch: false }; From 8cc9ca33be6bf3775201a4d93a907b6fe2abf3ab Mon Sep 17 00:00:00 2001 From: naveed949 Date: Fri, 14 Aug 2026 18:34:42 +0500 Subject: [PATCH 4/4] fix(web): keep rejected composer sends focused --- apps/web/src/components/chat/ChatComposer.tsx | 10 +++++++++- .../components/chat/composerSubmission.test.ts | 18 +++++++++++++++++- .../src/components/chat/composerSubmission.ts | 7 +++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f65afec90b5b..625ff460b2dc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -975,6 +975,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); + const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -1863,7 +1864,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt: promptRef.current, submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", event, - onSend, + onSend: (sendEvent) => { + // ChatView reports its final composed-input preflight through the + // composer handle before its first asynchronous send step. + providerInputRejectedRef.current = false; + onSend(sendEvent); + return !providerInputRejectedRef.current; + }, }); setComposerSubmissionError(submission.validationMessage); if (!submission.didDispatch) return; @@ -2666,6 +2673,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerInput, submissionTarget: "provider-turn", }); + providerInputRejectedRef.current = validationMessage !== null; setProviderInputSubmissionError(validationMessage); return validationMessage === null; }, diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index e7f1ad0309bf..239db28a6002 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -15,7 +15,9 @@ describe("submitComposerDraft", () => { prompt: draft, submissionTarget: "provider-turn", event: { preventDefault }, - onSend: () => dispatchedDrafts.push(draft), + onSend: () => { + dispatchedDrafts.push(draft); + }, }); validationMessage = result.validationMessage; }; @@ -85,6 +87,20 @@ describe("submitComposerDraft", () => { expect(onSend).toHaveBeenCalledOnce(); }); + it("does not finish submission when the send boundary rejects composed provider input", () => { + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Sendable raw draft", + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => false, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: false }); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + it("allows fully composed provider input at the shared character limit", () => { const onSend = vi.fn(); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts index a9830755bdab..528ac75bcabe 100644 --- a/apps/web/src/components/chat/composerSubmission.ts +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -27,7 +27,7 @@ export function getComposerSubmissionValidationMessage( export function submitComposerDraft( options: ComposerSubmissionInput & { event: ComposerSubmitEvent | undefined; - onSend: (event?: ComposerSubmitEvent) => void; + onSend: (event?: ComposerSubmitEvent) => boolean | void; }, ): { validationMessage: string | null; didDispatch: boolean } { const validationMessage = getComposerSubmissionValidationMessage(options); @@ -36,6 +36,9 @@ export function submitComposerDraft( return { validationMessage, didDispatch: false }; } - options.onSend(options.event); + if (options.onSend(options.event) === false) { + options.event?.preventDefault(); + return { validationMessage: null, didDispatch: false }; + } return { validationMessage: null, didDispatch: true }; }