From 55e02b8cc855aa5d38b48188c7edf8331b0cb90b Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 25 May 2026 22:20:47 +0530 Subject: [PATCH 01/10] implement Nova chat attachments on the web side --- apps/web/app/(app)/page.tsx | 15 +- apps/web/components/chat/attachments.ts | 83 +++++ .../components/chat/home-chat-composer.tsx | 96 +++++- apps/web/components/chat/index.tsx | 314 ++++++++++++++++-- apps/web/components/chat/input/index.tsx | 151 ++++++++- .../components/chat/message/user-message.tsx | 33 +- apps/web/lib/analytics.ts | 3 + 7 files changed, 660 insertions(+), 35 deletions(-) create mode 100644 apps/web/components/chat/attachments.ts diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index c177ec1e8..9be1b914b 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -13,6 +13,7 @@ import { useQueryState } from "nuqs" import { Header, PublicHeader } from "@/components/header" import { MobileBottomNav } from "@/components/bottom-nav" import { ChatSidebar, HomeChatComposer } from "@/components/chat" +import type { ChatAttachmentDraft } from "@/components/chat/attachments" import { DashboardView } from "@/components/dashboard-view" import { MemoriesGrid } from "@/components/memories-grid" import { GraphLayoutView } from "@/components/graph-layout-view" @@ -164,6 +165,9 @@ export default function NewPage() { const [queuedChatProject, setQueuedChatProject] = useState( null, ) + const [queuedChatAttachments, setQueuedChatAttachments] = useState< + ChatAttachmentDraft[] | null + >(null) const [queuedHighlightContent, setQueuedHighlightContent] = useState< string | null >(null) @@ -491,6 +495,7 @@ export default function NewPage() { setQueuedChatSeed(userReply) setQueuedChatModel(null) setQueuedChatProject(null) + setQueuedChatAttachments(null) setQueuedMessageSource("highlight") void setViewMode("chat") }, @@ -498,11 +503,17 @@ export default function NewPage() { ) const handleHomeChatStart = useCallback( - (message: string, model: ModelId, projectId: string) => { + ( + message: string, + model: ModelId, + projectId: string, + attachments?: ChatAttachmentDraft[], + ) => { setQueuedHighlightContent(null) setQueuedChatSeed(message) setQueuedChatModel(model) setQueuedChatProject(projectId) + setQueuedChatAttachments(attachments ?? null) setQueuedMessageSource("home") void setViewMode("chat") }, @@ -513,6 +524,7 @@ export default function NewPage() { setQueuedChatSeed(null) setQueuedChatModel(null) setQueuedChatProject(null) + setQueuedChatAttachments(null) setQueuedHighlightContent(null) setQueuedMessageSource("highlight") }, []) @@ -632,6 +644,7 @@ export default function NewPage() { queuedHighlightContent={queuedHighlightContent} onConsumeQueuedMessage={consumeQueuedChat} queuedMessageSource={queuedMessageSource} + queuedAttachments={queuedChatAttachments} initialSelectedModel={queuedChatModel} initialChatProject={queuedChatProject} /> diff --git a/apps/web/components/chat/attachments.ts b/apps/web/components/chat/attachments.ts new file mode 100644 index 000000000..da0a6fc1d --- /dev/null +++ b/apps/web/components/chat/attachments.ts @@ -0,0 +1,83 @@ +export const CHAT_ATTACHMENT_ACCEPT = + "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown" + +export const CHAT_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024 + +const SUPPORTED_EXTENSIONS = new Set([ + ".pdf", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".csv", + ".txt", + ".md", + ".mdx", +]) + +export type ChatAttachment = { + id: string + documentId?: string + filename: string + mediaType: string + size: number + saveToMemory: boolean + status: "ready" | "processing" | "failed" +} + +export type ChatAttachmentDraftStatus = + | "queued" + | "uploading" + | "uploaded" + | "error" + +export type ChatAttachmentDraft = { + id: string + file: File + saveToMemory: boolean + status: ChatAttachmentDraftStatus + errorMessage?: string + uploaded?: ChatAttachment +} + +export type ChatAttachmentMessageMetadata = { + attachments?: ChatAttachment[] +} + +export function isAcceptedChatAttachment(file: File): boolean { + if (file.size > CHAT_ATTACHMENT_MAX_BYTES) return false + const name = file.name.toLowerCase() + const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : "" + if (SUPPORTED_EXTENSIONS.has(ext)) return true + if (file.type.startsWith("image/")) return true + if (file.type === "text/markdown") return true + return false +} + +export function chatAttachmentKey(file: File): string { + return `${file.name}:${file.size}:${file.lastModified}` +} + +export function createChatAttachmentDraft(file: File): ChatAttachmentDraft { + return { + id: crypto.randomUUID(), + file, + saveToMemory: true, + status: "queued", + } +} + +export function formatAttachmentSize(size: number): string { + if (size < 1024) return `${size} B` + const kb = size / 1024 + if (kb < 1024) return `${kb.toFixed(1)} KB` + return `${(kb / 1024).toFixed(1)} MB` +} + +export function getChatMessageAttachments( + metadata: unknown, +): ChatAttachment[] { + const attachments = (metadata as ChatAttachmentMessageMetadata | undefined) + ?.attachments + return Array.isArray(attachments) ? attachments : [] +} diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx index 9e50fe337..86ed7eed9 100644 --- a/apps/web/components/chat/home-chat-composer.tsx +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -7,15 +7,31 @@ import { useProject } from "@/stores" import { cn } from "@lib/utils" import type { ModelId } from "@/lib/models" import { SpaceSelector } from "@/components/space-selector" +import { toast } from "sonner" +import { + chatAttachmentKey, + CHAT_ATTACHMENT_ACCEPT, + createChatAttachmentDraft, + type ChatAttachmentDraft, + isAcceptedChatAttachment, +} from "./attachments" export function HomeChatComposer({ onStartChat, className, }: { - onStartChat: (message: string, model: ModelId, projectId: string) => void + onStartChat: ( + message: string, + model: ModelId, + projectId: string, + attachments?: ChatAttachmentDraft[], + ) => void className?: string }) { const [input, setInput] = useState("") + const [attachmentDrafts, setAttachmentDrafts] = useState( + [], + ) const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ @@ -24,10 +40,76 @@ export function HomeChatComposer({ const send = useCallback(() => { const t = input.trim() - if (!t) return - onStartChat(t, selectedModel, chatSpaceProjects[0] ?? selectedProject) + if (!t && attachmentDrafts.length === 0) return + onStartChat( + t, + selectedModel, + chatSpaceProjects[0] ?? selectedProject, + attachmentDrafts, + ) setInput("") - }, [chatSpaceProjects, input, onStartChat, selectedModel, selectedProject]) + setAttachmentDrafts([]) + }, [ + attachmentDrafts, + chatSpaceProjects, + input, + onStartChat, + selectedModel, + selectedProject, + ]) + + const handleAddAttachmentFiles = useCallback( + (files: FileList | File[]) => { + const incoming = Array.from(files) + const accepted = incoming.filter(isAcceptedChatAttachment) + const rejected = incoming.length - accepted.length + if (rejected > 0) { + toast.error( + rejected === 1 + ? "One attachment is not supported or is over 50MB" + : `${rejected} attachments are not supported or are over 50MB`, + ) + } + if (accepted.length === 0) return + + const existingKeys = new Set( + attachmentDrafts.map((item) => chatAttachmentKey(item.file)), + ) + const nextItems: ChatAttachmentDraft[] = [] + let duplicateCount = 0 + for (const file of accepted) { + const key = chatAttachmentKey(file) + if (existingKeys.has(key)) { + duplicateCount++ + continue + } + existingKeys.add(key) + nextItems.push(createChatAttachmentDraft(file)) + } + if (duplicateCount > 0) { + toast.message( + duplicateCount === 1 + ? "Skipped duplicate attachment" + : `Skipped ${duplicateCount} duplicate attachments`, + ) + } + if (nextItems.length === 0) return + setAttachmentDrafts((prev) => [...prev, ...nextItems]) + }, + [attachmentDrafts], + ) + + const handleRemoveAttachment = useCallback((id: string) => { + setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id)) + }, []) + + const handleToggleAttachmentSave = useCallback((id: string) => { + setAttachmentDrafts((prev) => + prev.map((item) => + item.id === id ? { ...item, saveToMemory: !item.saveToMemory } : item, + ), + ) + }, []) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -46,6 +128,12 @@ export function HomeChatComposer({ onStop={() => {}} onKeyDown={handleKeyDown} isResponding={false} + attachments={attachmentDrafts} + onAddAttachmentFiles={handleAddAttachmentFiles} + onRemoveAttachment={handleRemoveAttachment} + onToggleAttachmentSave={handleToggleAttachmentSave} + canSend={input.trim().length > 0 || attachmentDrafts.length > 0} + attachmentAccept={CHAT_ATTACHMENT_ACCEPT} showStatusStrip={false} stackedToolbar={ <> diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 98dc46d7b..d2ae457c8 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -52,6 +52,39 @@ import { useViewMode } from "@/lib/view-mode-context" import { threadParam } from "@/lib/search-params" import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space" import { ChatEmptyStatePlaceholder } from "./chat-empty-state" +import { toast } from "sonner" +import { + chatAttachmentKey, + CHAT_ATTACHMENT_ACCEPT, + createChatAttachmentDraft, + type ChatAttachment, + type ChatAttachmentDraft, + isAcceptedChatAttachment, +} from "./attachments" + +type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" + +type RawChatAttachmentResponse = Partial & { + attachment?: Partial +} + +function normalizeChatAttachmentResponse( + data: RawChatAttachmentResponse, + draft: ChatAttachmentDraft, +): ChatAttachment { + const attachment = data.attachment ?? data + const id = attachment.id ?? attachment.documentId ?? draft.id + return { + id, + documentId: attachment.documentId, + filename: attachment.filename ?? draft.file.name, + mediaType: + (attachment.mediaType ?? draft.file.type) || "application/octet-stream", + size: attachment.size ?? draft.file.size, + saveToMemory: attachment.saveToMemory ?? draft.saveToMemory, + status: attachment.status ?? "ready", + } +} export function ChatLaunchFab({ onOpen, @@ -102,6 +135,7 @@ export function ChatSidebar({ queuedHighlightContent, onConsumeQueuedMessage, queuedMessageSource = "highlight", + queuedAttachments = null, initialSelectedModel = null, initialChatProject = null, emptyStateSuggestions, @@ -113,6 +147,7 @@ export function ChatSidebar({ queuedHighlightContent?: string | null onConsumeQueuedMessage?: () => void queuedMessageSource?: "highlight" | "home" + queuedAttachments?: ChatAttachmentDraft[] | null initialSelectedModel?: ModelId | null initialChatProject?: string | null emptyStateSuggestions?: string[] @@ -121,6 +156,9 @@ export function ChatSidebar({ const isMobile = useIsMobile() const isPageDesktop = layout === "page" && !isMobile const [input, setInput] = useState("") + const [attachmentDrafts, setAttachmentDrafts] = useState( + [], + ) const [selectedModel, setSelectedModel] = useState( initialSelectedModel ?? "claude-sonnet-4.6", ) @@ -151,6 +189,7 @@ export function ChatSidebar({ const awaitingHighlightInjectionRef = useRef(false) const pendingHighlightMessageRef = useRef(null) const targetHighlightChatIdRef = useRef(null) + const pendingRequestAttachmentsRef = useRef([]) const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ initialChatProject ?? selectedProject, @@ -238,6 +277,9 @@ export function ChatSidebar({ enableSpaceDiscovery: selectedProjectRef.current === AUTO_CHAT_SPACE_ID, model: selectedModelRef.current, + ...(pendingRequestAttachmentsRef.current.length > 0 && { + attachments: pendingRequestAttachmentsRef.current, + }), }, }, }), @@ -300,6 +342,149 @@ export function ChatSidebar({ [clearError], ) + const setAttachmentDraftState = useCallback( + (id: string, patch: Partial) => { + setAttachmentDrafts((prev) => + prev.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ) + }, + [], + ) + + const handleAddAttachmentFiles = useCallback( + (files: FileList | File[]) => { + const incoming = Array.from(files) + const accepted = incoming.filter(isAcceptedChatAttachment) + const rejected = incoming.length - accepted.length + if (rejected > 0) { + toast.error( + rejected === 1 + ? "One attachment is not supported or is over 50MB" + : `${rejected} attachments are not supported or are over 50MB`, + ) + } + if (accepted.length === 0) return + + const existingKeys = new Set( + attachmentDrafts.map((item) => chatAttachmentKey(item.file)), + ) + const nextItems: ChatAttachmentDraft[] = [] + let duplicateCount = 0 + for (const file of accepted) { + const key = chatAttachmentKey(file) + if (existingKeys.has(key)) { + duplicateCount++ + continue + } + existingKeys.add(key) + nextItems.push(createChatAttachmentDraft(file)) + } + if (duplicateCount > 0) { + toast.message( + duplicateCount === 1 + ? "Skipped duplicate attachment" + : `Skipped ${duplicateCount} duplicate attachments`, + ) + } + if (nextItems.length === 0) return + setAttachmentDrafts((prev) => [...prev, ...nextItems]) + }, + [attachmentDrafts], + ) + + const handleRemoveAttachment = useCallback((id: string) => { + setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id)) + }, []) + + const handleToggleAttachmentSave = useCallback((id: string) => { + setAttachmentDrafts((prev) => + prev.map((item) => + item.id === id ? { ...item, saveToMemory: !item.saveToMemory } : item, + ), + ) + }, []) + + const uploadAttachmentDraft = useCallback( + async ( + draft: ChatAttachmentDraft, + chatIdForUpload: string, + ): Promise => { + if (draft.status === "uploaded" && draft.uploaded) { + return draft.uploaded + } + + setAttachmentDraftState(draft.id, { + status: "uploading", + errorMessage: undefined, + }) + + const formData = new FormData() + formData.append("file", draft.file) + formData.append("threadId", chatIdForUpload) + formData.append("projectId", selectedProjectRef.current) + formData.append("saveToMemory", String(draft.saveToMemory)) + + try { + const response = await fetch(`${chatApiBase}/chat/attachments`, { + method: "POST", + body: formData, + credentials: "include", + }) + + if (!response.ok) { + let message = "Failed to upload attachment" + try { + const error = (await response.json()) as { + error?: string + message?: string + } + message = error.error ?? error.message ?? message + } catch { + // keep the fallback error + } + throw new Error(message) + } + + const data = (await response.json()) as RawChatAttachmentResponse + const attachment = normalizeChatAttachmentResponse(data, draft) + setAttachmentDraftState(draft.id, { + status: "uploaded", + uploaded: attachment, + }) + return attachment + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to upload attachment" + setAttachmentDraftState(draft.id, { + status: "error", + errorMessage: message, + }) + throw error + } + }, + [chatApiBase, setAttachmentDraftState], + ) + + const uploadAttachmentDrafts = useCallback( + async (drafts: ChatAttachmentDraft[], chatIdForUpload: string) => { + const uploaded: ChatAttachment[] = [] + for (const draft of drafts) { + uploaded.push(await uploadAttachmentDraft(draft, chatIdForUpload)) + } + return uploaded + }, + [uploadAttachmentDraft], + ) + + const handleRetryAttachment = useCallback( + (id: string) => { + const draft = attachmentDrafts.find((item) => item.id === id) + if (!draft) return + void uploadAttachmentDraft(draft, currentChatId) + }, + [attachmentDrafts, currentChatId, uploadAttachmentDraft], + ) + useEffect(() => { if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) { setMessages(pendingThreadLoad.messages) @@ -333,34 +518,80 @@ export function ChatSidebar({ } }, []) - const handleSend = () => { - if (!input.trim() || status === "submitted" || status === "streaming") - return - if (!threadId) setThreadId(fallbackChatId) - analytics.chatMessageSent({ source: "typed" }) - sendMessage({ text: input }) - setInput("") - userJustSentRef.current = true - scrollToBottom() - } + const submitChatMessage = useCallback( + async ( + text: string, + source: ChatMessageSendSource, + drafts = attachmentDrafts, + ): Promise => { + if (status === "submitted" || status === "streaming") return false + const trimmed = text.trim() + if (!trimmed && drafts.length === 0) return false - const handleSuggestedQuestion = useCallback( - (suggestion: string) => { - if (status === "submitted" || status === "streaming") return + const chatIdForSend = threadId ?? fallbackChatId if (!threadId) setThreadId(fallbackChatId) - analytics.chatSuggestedQuestionClicked() - analytics.chatMessageSent({ source: "suggested" }) - sendMessage({ text: suggestion }) - userJustSentRef.current = true - scrollToBottom() + + try { + const uploadedAttachments = + drafts.length > 0 + ? await uploadAttachmentDrafts(drafts, chatIdForSend) + : [] + pendingRequestAttachmentsRef.current = uploadedAttachments + analytics.chatMessageSent({ + source, + attachment_count: uploadedAttachments.length, + saved_attachment_count: uploadedAttachments.filter( + (attachment) => attachment.saveToMemory, + ).length, + temporary_attachment_count: uploadedAttachments.filter( + (attachment) => !attachment.saveToMemory, + ).length, + }) + await sendMessage({ + text: trimmed || "Analyze the attached file(s).", + metadata: + uploadedAttachments.length > 0 + ? { attachments: uploadedAttachments } + : undefined, + }) + pendingRequestAttachmentsRef.current = [] + setInput("") + setAttachmentDrafts([]) + userJustSentRef.current = true + scrollToBottom() + return true + } catch (error) { + pendingRequestAttachmentsRef.current = [] + toast.error("Failed to send message", { + description: + error instanceof Error ? error.message : "Please try again.", + }) + return false + } }, [ + attachmentDrafts, fallbackChatId, + scrollToBottom, sendMessage, setThreadId, status, threadId, - scrollToBottom, + uploadAttachmentDrafts, + ], + ) + + const handleSend = () => { + void submitChatMessage(input, "typed") + } + + const handleSuggestedQuestion = useCallback( + (suggestion: string) => { + analytics.chatSuggestedQuestionClicked() + void submitChatMessage(suggestion, "suggested", []) + }, + [ + submitChatMessage, ], ) @@ -426,6 +657,7 @@ export function ChatSidebar({ setThreadId(null) setFallbackChatId(newChatId) setInput("") + setAttachmentDrafts([]) }, [setThreadId, setMessages]) const fetchThreads = useCallback(async () => { @@ -466,6 +698,7 @@ export function ChatSidebar({ id: string role: string parts: Array<{ type: string }> + metadata?: unknown createdAt: string }) => ({ id: m.id, @@ -476,6 +709,7 @@ export function ChatSidebar({ parts: (m.parts || []).filter( (p) => p.type === "text" || p.type === "reasoning", ), + metadata: m.metadata, createdAt: new Date(m.createdAt), }), ) @@ -570,9 +804,9 @@ export function ChatSidebar({ return } sentQueuedMessageRef.current = queuedMessage - analytics.chatMessageSent({ source: queuedMessageSource }) if (queuedHighlightContent) { + analytics.chatMessageSent({ source: queuedMessageSource }) // Start a fresh thread for highlight-based chats to avoid overwriting existing conversations const newChatId = generateId() chatIdRef.current = newChatId @@ -603,8 +837,23 @@ export function ChatSidebar({ }, ] } else { - if (!threadId) setThreadId(fallbackChatId) - sendMessage({ text: queuedMessage }) + if (queuedAttachments?.length) { + setAttachmentDrafts(queuedAttachments) + } + void submitChatMessage( + queuedMessage, + queuedMessageSource, + queuedAttachments ?? [], + ).then((sent) => { + if (!sent) { + setInput(queuedMessage) + if (queuedAttachments?.length) { + setAttachmentDrafts(queuedAttachments) + } + } + onConsumeQueuedMessage?.() + }) + return } onConsumeQueuedMessage?.() } @@ -613,14 +862,15 @@ export function ChatSidebar({ queuedMessage, queuedHighlightContent, queuedMessageSource, + queuedAttachments, initialSelectedModel, selectedModel, status, - sendMessage, onConsumeQueuedMessage, fallbackChatId, setThreadId, threadId, + submitChatMessage, ]) // Inject the pending highlight assistant message once the new Chat instance is ready. @@ -781,6 +1031,17 @@ export function ChatSidebar({ const isStackedInput = layout === "page" const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput const isResponding = status === "submitted" || status === "streaming" + const hasBusyAttachment = attachmentDrafts.some( + (attachment) => attachment.status === "uploading", + ) + const hasErroredAttachment = attachmentDrafts.some( + (attachment) => attachment.status === "error", + ) + const canSendMessage = + (input.trim().length > 0 || attachmentDrafts.length > 0) && + !isResponding && + !hasBusyAttachment && + !hasErroredAttachment const showInputStatusStrip = !isStackedInput || isResponding || messages.length > 0 @@ -1151,6 +1412,13 @@ export function ChatSidebar({ onStop={stop} onKeyDown={handleKeyDown} isResponding={isResponding} + attachments={attachmentDrafts} + onAddAttachmentFiles={handleAddAttachmentFiles} + onRemoveAttachment={handleRemoveAttachment} + onToggleAttachmentSave={handleToggleAttachmentSave} + onRetryAttachment={handleRetryAttachment} + canSend={canSendMessage} + attachmentAccept={CHAT_ATTACHMENT_ACCEPT} activeStatus={ status === "submitted" ? "Thinking…" diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index d24276d09..e6ccd5ede 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -1,12 +1,25 @@ "use client" -import { ChevronUpIcon } from "lucide-react" +import { + CheckIcon, + ChevronUpIcon, + FileIcon, + Loader2Icon, + PaperclipIcon, + RotateCcwIcon, + XIcon, +} from "lucide-react" import NovaOrb from "@/components/nova/nova-orb" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { type ReactNode, useEffect, useRef, useState } from "react" import { motion } from "motion/react" import { SendButton, StopButton } from "./actions" +import { + CHAT_ATTACHMENT_ACCEPT, + type ChatAttachmentDraft, + formatAttachmentSize, +} from "../attachments" interface ChatInputProps { value: string @@ -22,6 +35,13 @@ interface ChatInputProps { stackedToolbar?: ReactNode /** Nova status row + chain-of-thought toggle (off for e.g. home composer) */ showStatusStrip?: boolean + attachments?: ChatAttachmentDraft[] + onAddAttachmentFiles?: (files: FileList | File[]) => void + onRemoveAttachment?: (id: string) => void + onToggleAttachmentSave?: (id: string) => void + onRetryAttachment?: (id: string) => void + canSend?: boolean + attachmentAccept?: string } export default function ChatInput({ @@ -36,10 +56,18 @@ export default function ChatInput({ onExpandedChange, stackedToolbar, showStatusStrip = true, + attachments = [], + onAddAttachmentFiles, + onRemoveAttachment, + onToggleAttachmentSave, + onRetryAttachment, + canSend, + attachmentAccept = CHAT_ATTACHMENT_ACCEPT, }: ChatInputProps) { const [isMultiline, setIsMultiline] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const textareaRef = useRef(null) + const fileInputRef = useRef(null) useEffect(() => { if (!showStatusStrip && isExpanded) { @@ -61,6 +89,115 @@ export default function ChatInput({ setIsMultiline(textarea.scrollHeight > 52) } + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files + if (files?.length) onAddAttachmentFiles?.(files) + e.target.value = "" + } + + const showAttachments = attachments.length > 0 + const sendEnabled = canSend ?? value.trim().length > 0 + + const attachmentTray = showAttachments ? ( +
+ {attachments.map((attachment) => { + const isUploading = attachment.status === "uploading" + const isUploaded = attachment.status === "uploaded" + const isError = attachment.status === "error" + return ( +
+
+ {isUploading ? ( + + ) : isUploaded ? ( + + ) : ( + + )} +
+
+
+ {attachment.file.name} +
+
+ {isError + ? attachment.errorMessage || "Upload failed" + : formatAttachmentSize(attachment.file.size)} +
+
+ + {isError ? ( + + ) : null} + +
+ ) + })} +
+ ) : null + + const attachmentButton = onAddAttachmentFiles ? ( + <> + + + + ) : null + return ( + {attachmentTray}