Skip to content

Commit 7c55e86

Browse files
authored
fix(web): reject oversized prompts before provider turn start (#6602)
1 parent c0f9d91 commit 7c55e86

7 files changed

Lines changed: 358 additions & 21 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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,15 +5090,37 @@ 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,
51065096
models: ctxSelectedProviderModels,
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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded
107107
import { basenameOfPath } from "../../pierre-icons";
108108
import { cn, randomUUID } from "~/lib/utils";
109109
import { Separator } from "../ui/separator";
110+
import {
111+
getComposerPromptLengthValidationMessage,
112+
getComposerSubmissionValidationMessage,
113+
submitComposerDraft,
114+
} from "./composerSubmission";
115+
import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
110116

111117
type 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">
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { describe, expect, it } from "vite-plus/test";
4+
5+
import { getComposerPromptLengthValidationMessage } from "./composerSubmission";
6+
import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
7+
8+
describe("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+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export 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+
}

0 commit comments

Comments
 (0)