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 && (