| author | |
| committer | |
| log | 7c55e86320aac9c68ae53a7bc15682b7e14f98bf |
| tree | 1e03e7265c1f60964f84028325b0f1ac7d12cf15 |
| parent | c0f9d917c1ab08d30f2b3715dd25d2175a6d2ecf |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 358 insertions(+), 21 deletions(-)
apps/web/src/components/ChatView.tsx+37-20| ... | ... | @@ -4994,6 +4994,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 4994 | 4994 | draftText: trimmed, |
| 4995 | 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 | 5007 | promptRef.current = ""; |
| 4998 | 5008 | clearComposerDraftContent(composerDraftTarget); |
| 4999 | 5009 | composerRef.current?.resetCursorState(); |
| ... | ... | @@ -5063,24 +5073,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 5063 | 5073 | return; |
| 5064 | 5074 | } |
| 5065 | 5075 | |
| 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 | 5076 | const composerImagesSnapshot = [...composerImages]; |
| 5085 | 5077 | const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; |
| 5086 | 5078 | const composerElementContextsSnapshot = [...composerElementContexts]; |
| ... | ... | @@ -5098,8 +5090,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 5098 | 5090 | messageTextWithPreviewAnnotations, |
| 5099 | 5091 | composerReviewCommentsSnapshot, |
| 5100 | 5092 | ); |
| 5101 | const messageIdForSend = newMessageId(); | |
| 5102 | const messageCreatedAt = new Date().toISOString(); | |
| 5103 | 5093 | const outgoingMessageText = formatOutgoingPrompt({ |
| 5104 | 5094 | provider: ctxSelectedProvider, |
| 5105 | 5095 | model: ctxSelectedModel, |
| ... | ... | @@ -5107,6 +5097,30 @@ function ChatViewContent(props: ChatViewProps) { |
| 5107 | 5097 | effort: ctxSelectedPromptEffort, |
| 5108 | 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 | 5124 | const turnAttachmentsPromise = Promise.all( |
| 5111 | 5125 | composerImagesSnapshot.map(async (image) => ({ |
| 5112 | 5126 | type: "image" as const, |
| ... | ... | @@ -5723,6 +5737,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 5723 | 5737 | effort: ctxSelectedPromptEffort, |
| 5724 | 5738 | text: implementationPrompt, |
| 5725 | 5739 | }); |
| 5740 | if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) { | |
| 5741 | return; | |
| 5742 | } | |
| 5726 | 5743 | const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); |
| 5727 | 5744 | const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; |
| 5728 | 5745 |
apps/web/src/components/chat/ChatComposer.tsx+66-1| ... | ... | @@ -107,6 +107,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded |
| 107 | 107 | import { basenameOfPath } from "../../pierre-icons"; |
| 108 | 108 | import { cn, randomUUID } from "~/lib/utils"; |
| 109 | 109 | import { Separator } from "../ui/separator"; |
| 110 | import { | |
| 111 | getComposerPromptLengthValidationMessage, | |
| 112 | getComposerSubmissionValidationMessage, | |
| 113 | submitComposerDraft, | |
| 114 | } from "./composerSubmission"; | |
| 115 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; | |
| 110 | 116 | |
| 111 | 117 | type ComposerCommandMenuPosition = { |
| 112 | 118 | bottom: number; |
| ... | ... | @@ -488,6 +494,8 @@ export interface ChatComposerHandle { |
| 488 | 494 | selectedModel: string; |
| 489 | 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 | } |
| 492 | 500 | |
| 493 | 501 | // -------------------------------------------------------------------------- |
| ... | ... | @@ -951,6 +959,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 951 | 959 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); |
| 952 | 960 | const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); |
| 953 | 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 | 966 | const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null); |
| 955 | 967 | const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); |
| 956 | 968 | const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ |
| ... | ... | @@ -967,6 +979,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 967 | 979 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 968 | 980 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 969 | 981 | const composerSurfaceRef = useRef<HTMLDivElement>(null); |
| 982 | const providerInputRejectedRef = useRef(false); | |
| 970 | 983 | const composerSelectLockRef = useRef(false); |
| 971 | 984 | const composerMenuOpenRef = useRef(false); |
| 972 | 985 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); |
| ... | ... | @@ -1309,6 +1322,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1309 | 1322 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); |
| 1310 | 1323 | }, [prompt, promptRef]); |
| 1311 | 1324 | |
| 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 | 1346 | useEffect(() => { |
| 1313 | 1347 | composerImagesRef.current = composerImages; |
| 1314 | 1348 | }, [composerImages, composerImagesRef]); |
| ... | ... | @@ -1400,6 +1434,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1400 | 1434 | // ------------------------------------------------------------------ |
| 1401 | 1435 | useEffect(() => { |
| 1402 | 1436 | setComposerHighlightedItemId(null); |
| 1437 | setComposerSubmissionError(null); | |
| 1438 | setProviderInputSubmissionError(null); | |
| 1403 | 1439 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); |
| 1404 | 1440 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| 1405 | 1441 | setIsDragOverComposer(false); |
| ... | ... | @@ -1826,17 +1862,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1826 | 1862 | }); |
| 1827 | 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 | 1879 | if (shouldBlurMobileComposerOnSubmit()) { |
| 1831 | 1880 | blurMobileComposerAfterSend(); |
| 1832 | 1881 | } |
| 1833 | 1882 | }, |
| 1834 | 1883 | [ |
| 1835 | 1884 | activeThreadId, |
| 1885 | activePendingProgress, | |
| 1836 | 1886 | blurMobileComposerAfterSend, |
| 1837 | 1887 | isSendDisabled, |
| 1838 | 1888 | noProviderAvailable, |
| 1839 | 1889 | onSend, |
| 1890 | promptRef, | |
| 1840 | 1891 | shouldBlurMobileComposerOnSubmit, |
| 1841 | 1892 | ], |
| 1842 | 1893 | ); |
| ... | ... | @@ -2590,6 +2641,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2590 | 2641 | selectedModel, |
| 2591 | 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 | 2656 | activeThread, |
| ... | ... | @@ -3058,6 +3119,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3058 | 3119 | </div> |
| 3059 | 3120 | </div> |
| 3060 | 3121 | |
| 3122 | <ComposerPromptLengthValidation | |
| 3123 | message={providerInputSubmissionError ?? composerSubmissionError} | |
| 3124 | /> | |
| 3125 | ||
| 3061 | 3126 | {/* Bottom toolbar */} |
| 3062 | 3127 | {isComposerCollapsedMobile ? null : activePendingApproval ? ( |
| 3063 | 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 @@ |
| 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 | }); |
apps/web/src/components/chat/ComposerPromptLengthValidation.tsx created+13| ... | ... | @@ -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 | } |
apps/web/src/components/chat/composerSubmission.test.ts created+170| ... | ... | @@ -0,0 +1,170 @@ |
| 1 | import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { submitComposerDraft } from "./composerSubmission"; | |
| 5 | ||
| 6 | describe("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 @@ |
| 1 | import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | type ComposerSubmitEvent = { preventDefault: () => void }; | |
| 4 | ||
| 5 | type ComposerSubmissionInput = { | |
| 6 | prompt: string; | |
| 7 | providerInput?: string; | |
| 8 | submissionTarget: "provider-turn" | "pending-user-input"; | |
| 9 | }; | |
| 10 | ||
| 11 | export 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 | ||
| 19 | export function getComposerSubmissionValidationMessage( | |
| 20 | options: ComposerSubmissionInput, | |
| 21 | ): string | null { | |
| 22 | return options.submissionTarget === "provider-turn" | |
| 23 | ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt) | |
| 24 | : null; | |
| 25 | } | |
| 26 | ||
| 27 | export 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 | ||
| 3 | Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the | |
| 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into | |
| 5 | multiple messages, then send again in the same thread. |