From 87edf2e19d9e648a9c7dd45c83927d0116e11c26 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sat, 3 Oct 2026 17:45:27 +0900 Subject: [PATCH] fix(chat-input): stop template dialogs from sending and fix attachment races - Template dialogs no longer submit the outer chat form - Sending is blocked while files or URLs are still extracting - File and URL extraction no longer drop or resurrect entries - IME composition Enter no longer sends - Tool call cards show the error text; keyboard handling on cards fixed - Template import available when empty, edit dialog resets, saved templates refresh - Only png/jpeg/gif/webp images accepted, SVG sent as text; PDF objects released - parse-url request sends the access code --- components/chat-input.tsx | 104 +++++++++++++------- components/chat/ChatLobby.tsx | 2 + components/chat/TemplateCreateDialog.tsx | 3 + components/chat/TemplateEditDialog.tsx | 9 +- components/chat/TemplatePanel.tsx | 60 ++++++++---- components/chat/ToolCallCard.tsx | 62 ++++++------ components/chat/types.ts | 1 + lib/pdf-utils.ts | 9 +- lib/url-utils.ts | 7 +- lib/use-file-processor.tsx | 116 +++++++++++------------ tests/unit/pdf-utils.test.ts | 16 ++++ tests/unit/url-utils.test.ts | 25 +++++ tests/unit/use-file-processor.test.tsx | 102 ++++++++++++++++++++ 13 files changed, 360 insertions(+), 156 deletions(-) create mode 100644 tests/unit/pdf-utils.test.ts create mode 100644 tests/unit/url-utils.test.ts create mode 100644 tests/unit/use-file-processor.test.tsx diff --git a/components/chat-input.tsx b/components/chat-input.tsx index 66635ef..d0834a6 100644 --- a/components/chat-input.tsx +++ b/components/chat-input.tsx @@ -11,7 +11,9 @@ import { } from "lucide-react" import type React from "react" import { + type Dispatch, forwardRef, + type SetStateAction, useCallback, useEffect, useImperativeHandle, @@ -41,9 +43,20 @@ import { FilePreviewList } from "./file-preview-list" const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB const MAX_FILES = 5 +// Image formats every supported model provider accepts (SVG is read as text) +const SUPPORTED_IMAGE_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +] function isValidFileType(file: File): boolean { - return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file) + return ( + SUPPORTED_IMAGE_TYPES.includes(file.type) || + isPdfFile(file) || + isTextFile(file) + ) } function formatFileSize(bytes: number): string { @@ -164,7 +177,7 @@ interface ChatInputProps { { text: string; charCount: number; isExtracting: boolean } > urlData?: Map - onUrlChange?: (data: Map) => void + onUrlChange?: Dispatch>> sessionId?: string error?: Error | null @@ -244,6 +257,11 @@ export const ChatInput = forwardRef( // Allow retry when there's an error (even if status is still "streaming" or "submitted") const isDisabled = (status === "streaming" || status === "submitted") && !error + // Block sending until attached files and URLs have their text, otherwise + // their content would be silently dropped + const isExtractingAttachments = + files.some((file) => pdfData.get(file)?.isExtracting) || + Array.from(urlData?.values() ?? []).some((d) => d.isExtracting) const adjustTextareaHeight = useCallback(() => { const textarea = textareaRef.current @@ -281,6 +299,9 @@ export const ChatInput = forwardRef( } const handleKeyDown = (e: React.KeyboardEvent) => { + // Enter that confirms an IME candidate must not send the message + if (e.nativeEvent.isComposing || e.keyCode === 229) return + const shouldSend = sendShortcut === "enter" ? e.key === "Enter" && @@ -292,7 +313,12 @@ export const ChatInput = forwardRef( if (shouldSend) { e.preventDefault() const form = e.currentTarget.closest("form") - if (form && input.trim() && !isDisabled) { + if ( + form && + input.trim() && + !isDisabled && + !isExtractingAttachments + ) { form.requestSubmit() } } @@ -380,13 +406,9 @@ export const ChatInput = forwardRef( if (isDisabled) return - const droppedFiles = e.dataTransfer.files - const supportedFiles = Array.from(droppedFiles).filter((file) => - isValidFileType(file), - ) - + // Let validateFiles show a toast for unsupported types const { validFiles, errors } = validateFiles( - supportedFiles, + Array.from(e.dataTransfer.files), files.length, dict, ) @@ -401,33 +423,34 @@ export const ChatInput = forwardRef( setIsExtractingUrl(true) + // Use functional updates so a removal or send made while extracting + // is not overwritten when the request finishes try { - const existing = urlData - ? new Map(urlData) - : new Map() - existing.set(url, { - url, - title: url, - content: "", - charCount: 0, - isExtracting: true, - }) - onUrlChange(existing) + onUrlChange((prev) => + new Map(prev).set(url, { + url, + title: url, + content: "", + charCount: 0, + isExtracting: true, + }), + ) const data = await extractUrlContent(url) - const newUrlData = new Map(existing) - newUrlData.set(url, data) - onUrlChange(newUrlData) + // Skip if the URL was removed while extracting + onUrlChange((prev) => + prev.has(url) ? new Map(prev).set(url, data) : prev, + ) setShowUrlDialog(false) } catch (error) { // Remove the URL from the data map on error - const newUrlData = urlData - ? new Map(urlData) - : new Map() - newUrlData.delete(url) - onUrlChange(newUrlData) + onUrlChange((prev) => { + const next = new Map(prev) + next.delete(url) + return next + }) showErrorToast( {error instanceof Error @@ -463,11 +486,12 @@ export const ChatInput = forwardRef( urlData={urlData} onRemoveUrl={ onUrlChange - ? (url) => { - const next = new Map(urlData) - next.delete(url) - onUrlChange(next) - } + ? (url) => + onUrlChange((prev) => { + const next = new Map(prev) + next.delete(url) + return next + }) : undefined } /> @@ -559,7 +583,7 @@ export const ChatInput = forwardRef( ref={fileInputRef} className="hidden" onChange={handleFileChange} - accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml" + accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml" multiple disabled={isDisabled} /> @@ -588,7 +612,11 @@ export const ChatInput = forwardRef( ) : ( + + {importInput} + {importMessageBox && ( +
{importMessageBox}
+ )} {dict.templates.importTemplates} - + {importInput} {/* Import message */} - {importMessage && ( -
- {importMessage.text} -
- )} + {importMessageBox}
{loading @@ -447,6 +469,8 @@ export function TemplatePanel({ handleTemplateClick(template) } onKeyDown={(e) => { + // Ignore keys bubbling up from the action buttons + if (e.target !== e.currentTarget) return if ( e.key === "Enter" || e.key === " " diff --git a/components/chat/ToolCallCard.tsx b/components/chat/ToolCallCard.tsx index ca0a4c1..87ac932 100644 --- a/components/chat/ToolCallCard.tsx +++ b/components/chat/ToolCallCard.tsx @@ -66,7 +66,7 @@ export function ToolCallCard({ dict, }: ToolCallCardProps) { const callId = part.toolCallId - const { state, input, output } = part + const { state, input, output, errorText } = part // Default to expanded for all states (user can manually collapse if needed) const isExpanded = expandedTools[callId] ?? true const toolName = part.type?.replace("tool-", "") @@ -92,6 +92,14 @@ export function ToolCallCard({ } } + // Incomplete XML means the output hit the length limit, unless the user + // stopped the generation themselves + const isTruncated = + state === "output-error" && + errorText !== "Stopped by user" && + (toolName === "display_diagram" || toolName === "append_diagram") && + !isMxCellXmlComplete(input?.xml) + const handleCopy = () => { let textToCopy = "" @@ -161,22 +169,15 @@ export function ToolCallCard({ )} {state === "output-error" && - (() => { - // Check if this is a truncation (incomplete XML) vs real error - const isTruncated = - (toolName === "display_diagram" || - toolName === "append_diagram") && - !isMxCellXmlComplete(input?.xml) - return isTruncated ? ( - - Truncated - - ) : ( - - Error - - ) - })()} + (isTruncated ? ( + + Truncated + + ) : ( + + Error + + ))} {input && Object.keys(input).length > 0 && (