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) {...@@ -4994,6 +4994,16 @@ function ChatViewContent(props: ChatViewProps) {
4994 draftText: trimmed,4994 draftText: trimmed,
4995 planMarkdown: activeProposedPlan.planMarkdown,4995 planMarkdown: activeProposedPlan.planMarkdown,
4996 });4996 });
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 }
4997 promptRef.current = "";5007 promptRef.current = "";
4998 clearComposerDraftContent(composerDraftTarget);5008 clearComposerDraftContent(composerDraftTarget);
4999 composerRef.current?.resetCursorState();5009 composerRef.current?.resetCursorState();
...@@ -5063,24 +5073,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5063,24 +5073,6 @@ function ChatViewContent(props: ChatViewProps) {
5063 return;5073 return;
5064 }5074 }
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
5084 const composerImagesSnapshot = [...composerImages];5076 const composerImagesSnapshot = [...composerImages];
5085 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];5077 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
5086 const composerElementContextsSnapshot = [...composerElementContexts];5078 const composerElementContextsSnapshot = [...composerElementContexts];
...@@ -5098,8 +5090,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5098,8 +5090,6 @@ function ChatViewContent(props: ChatViewProps) {
5098 messageTextWithPreviewAnnotations,5090 messageTextWithPreviewAnnotations,
5099 composerReviewCommentsSnapshot,5091 composerReviewCommentsSnapshot,
5100 );5092 );
5101 const messageIdForSend = newMessageId();
5102 const messageCreatedAt = new Date().toISOString();
5103 const outgoingMessageText = formatOutgoingPrompt({5093 const outgoingMessageText = formatOutgoingPrompt({
5104 provider: ctxSelectedProvider,5094 provider: ctxSelectedProvider,
5105 model: ctxSelectedModel,5095 model: ctxSelectedModel,
...@@ -5107,6 +5097,30 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5107,6 +5097,30 @@ function ChatViewContent(props: ChatViewProps) {
5107 effort: ctxSelectedPromptEffort,5097 effort: ctxSelectedPromptEffort,
5108 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,5098 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
5109 });5099 });
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();
5110 const turnAttachmentsPromise = Promise.all(5124 const turnAttachmentsPromise = Promise.all(
5111 composerImagesSnapshot.map(async (image) => ({5125 composerImagesSnapshot.map(async (image) => ({
5112 type: "image" as const,5126 type: "image" as const,
...@@ -5723,6 +5737,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5723,6 +5737,9 @@ function ChatViewContent(props: ChatViewProps) {
5723 effort: ctxSelectedPromptEffort,5737 effort: ctxSelectedPromptEffort,
5724 text: implementationPrompt,5738 text: implementationPrompt,
5725 });5739 });
5740 if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) {
5741 return;
5742 }
5726 const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));5743 const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));
5727 const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection;5744 const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection;
57285745
apps/web/src/components/chat/ChatComposer.tsx+66-1
...@@ -107,6 +107,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded...@@ -107,6 +107,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded
107import { basenameOfPath } from "../../pierre-icons";107import { basenameOfPath } from "../../pierre-icons";
108import { cn, randomUUID } from "~/lib/utils";108import { cn, randomUUID } from "~/lib/utils";
109import { Separator } from "../ui/separator";109import { Separator } from "../ui/separator";
110import {
111 getComposerPromptLengthValidationMessage,
112 getComposerSubmissionValidationMessage,
113 submitComposerDraft,
114} from "./composerSubmission";
115import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
110116
111type ComposerCommandMenuPosition = {117type ComposerCommandMenuPosition = {
112 bottom: number;118 bottom: number;
...@@ -488,6 +494,8 @@ export interface ChatComposerHandle {...@@ -488,6 +494,8 @@ export interface ChatComposerHandle {
488 selectedModel: string;494 selectedModel: string;
489 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;495 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;
490 };496 };
497 /** Validate the fully composed text immediately before a provider turn starts. */
498 validateProviderInput: (providerInput: string) => boolean;
491}499}
492500
493// --------------------------------------------------------------------------501// --------------------------------------------------------------------------
...@@ -951,6 +959,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -951,6 +959,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
951 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);959 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
952 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);960 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
953 const [isComposerFocused, setIsComposerFocused] = useState(false);961 const [isComposerFocused, setIsComposerFocused] = useState(false);
962 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
963 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
964 null,
965 );
954 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);966 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
955 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);967 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
956 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({968 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
...@@ -967,6 +979,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -967,6 +979,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
967 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);979 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
968 const composerFormRef = useRef<HTMLFormElement>(null);980 const composerFormRef = useRef<HTMLFormElement>(null);
969 const composerSurfaceRef = useRef<HTMLDivElement>(null);981 const composerSurfaceRef = useRef<HTMLDivElement>(null);
982 const providerInputRejectedRef = useRef(false);
970 const composerSelectLockRef = useRef(false);983 const composerSelectLockRef = useRef(false);
971 const composerMenuOpenRef = useRef(false);984 const composerMenuOpenRef = useRef(false);
972 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);985 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
...@@ -1309,6 +1322,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1309,6 +1322,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1309 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));1322 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
1310 }, [prompt, promptRef]);1323 }, [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
1312 useEffect(() => {1346 useEffect(() => {
1313 composerImagesRef.current = composerImages;1347 composerImagesRef.current = composerImages;
1314 }, [composerImages, composerImagesRef]);1348 }, [composerImages, composerImagesRef]);
...@@ -1400,6 +1434,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1400,6 +1434,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1400 // ------------------------------------------------------------------1434 // ------------------------------------------------------------------
1401 useEffect(() => {1435 useEffect(() => {
1402 setComposerHighlightedItemId(null);1436 setComposerHighlightedItemId(null);
1437 setComposerSubmissionError(null);
1438 setProviderInputSubmissionError(null);
1403 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));1439 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
1404 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));1440 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1405 setIsDragOverComposer(false);1441 setIsDragOverComposer(false);
...@@ -1826,17 +1862,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1826,17 +1862,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1826 });1862 });
1827 return;1863 return;
1828 }1864 }
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;
1830 if (shouldBlurMobileComposerOnSubmit()) {1879 if (shouldBlurMobileComposerOnSubmit()) {
1831 blurMobileComposerAfterSend();1880 blurMobileComposerAfterSend();
1832 }1881 }
1833 },1882 },
1834 [1883 [
1835 activeThreadId,1884 activeThreadId,
1885 activePendingProgress,
1836 blurMobileComposerAfterSend,1886 blurMobileComposerAfterSend,
1837 isSendDisabled,1887 isSendDisabled,
1838 noProviderAvailable,1888 noProviderAvailable,
1839 onSend,1889 onSend,
1890 promptRef,
1840 shouldBlurMobileComposerOnSubmit,1891 shouldBlurMobileComposerOnSubmit,
1841 ],1892 ],
1842 );1893 );
...@@ -2590,6 +2641,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2590,6 +2641,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2590 selectedModel,2641 selectedModel,
2591 selectedProviderModels,2642 selectedProviderModels,
2592 }),2643 }),
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 },
2593 }),2654 }),
2594 [2655 [
2595 activeThread,2656 activeThread,
...@@ -3058,6 +3119,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3058,6 +3119,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3058 </div>3119 </div>
3059 </div>3120 </div>
30603121
3122 <ComposerPromptLengthValidation
3123 message={providerInputSubmissionError ?? composerSubmissionError}
3124 />
3125
3061 {/* Bottom toolbar */}3126 {/* Bottom toolbar */}
3062 {isComposerCollapsedMobile ? null : activePendingApproval ? (3127 {isComposerCollapsedMobile ? null : activePendingApproval ? (
3063 <div className="flex items-center justify-end gap-2 px-3 pb-3 sm:px-4 sm:pb-4">3128 <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.