diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 120bd2b94..29df35c90 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..c627c3030 --- /dev/null +++ b/apps/web/components/chat/attachments.ts @@ -0,0 +1,82 @@ +export const CHAT_ATTACHMENT_ACCEPT = + "image/*,.pdf,application/pdf,.doc,.docx,.txt,.md,.mdx,.markdown,text/markdown" + +export const CHAT_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024 + +const SUPPORTED_EXTENSIONS = new Set([ + ".pdf", + ".doc", + ".docx", + ".txt", + ".md", + ".mdx", + ".markdown", +]) + +export type ChatAttachment = { + id: string + documentId?: string + filename: string + mediaType: string + size: number + saveToMemory: boolean + status: "ready" | "processing" | "failed" + url?: string + contentPreview?: string +} + +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 === "application/pdf") 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 2f7cda2db..bb1ca6760 100644 --- a/apps/web/components/chat/home-chat-composer.tsx +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -8,15 +8,31 @@ import { cn } from "@lib/utils" import type { ModelId } from "@/lib/models" import { SpaceSelector } from "@/components/space-selector" import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space" +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< + ChatAttachmentDraft[] + >([]) const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ @@ -25,10 +41,68 @@ 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 handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -47,6 +121,11 @@ export function HomeChatComposer({ onStop={() => {}} onKeyDown={handleKeyDown} isResponding={false} + attachments={attachmentDrafts} + onAddAttachmentFiles={handleAddAttachmentFiles} + onRemoveAttachment={handleRemoveAttachment} + 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 d140b469e..1ca059d0a 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -51,6 +51,45 @@ 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" +import { cacheFileBlob, removeCachedFile } from "@/lib/file-cache" + +type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" + +const DISCARD_ATTACHMENT_MAX_ATTEMPTS = 15 +const DISCARD_ATTACHMENT_RETRY_MS = 2000 + +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", + url: attachment.url, + contentPreview: attachment.contentPreview, + } +} export function ChatLaunchFab({ onOpen, @@ -101,6 +140,7 @@ export function ChatSidebar({ queuedHighlightContent, onConsumeQueuedMessage, queuedMessageSource = "highlight", + queuedAttachments = null, initialSelectedModel = null, initialChatProject = null, emptyStateSuggestions, @@ -112,6 +152,7 @@ export function ChatSidebar({ queuedHighlightContent?: string | null onConsumeQueuedMessage?: () => void queuedMessageSource?: "highlight" | "home" + queuedAttachments?: ChatAttachmentDraft[] | null initialSelectedModel?: ModelId | null initialChatProject?: string | null emptyStateSuggestions?: string[] @@ -120,6 +161,9 @@ export function ChatSidebar({ const isMobile = useIsMobile() const isPageDesktop = layout === "page" && !isMobile const [input, setInput] = useState("") + const [attachmentDrafts, setAttachmentDrafts] = useState< + ChatAttachmentDraft[] + >([]) const [selectedModel, setSelectedModel] = useState( initialSelectedModel ?? "claude-sonnet-4.6", ) @@ -150,6 +194,12 @@ export function ChatSidebar({ const awaitingHighlightInjectionRef = useRef(false) const pendingHighlightMessageRef = useRef(null) const targetHighlightChatIdRef = useRef(null) + const pendingRequestAttachmentsRef = useRef([]) + const uploadPromisesRef = useRef>>( + new Map(), + ) + const abortControllersRef = useRef>(new Map()) + const discardedDraftIdsRef = useRef>(new Set()) const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ initialChatProject ?? AUTO_CHAT_SPACE_ID, @@ -237,6 +287,9 @@ export function ChatSidebar({ enableSpaceDiscovery: selectedProjectRef.current === AUTO_CHAT_SPACE_ID, model: selectedModelRef.current, + ...(pendingRequestAttachmentsRef.current.length > 0 && { + attachments: pendingRequestAttachmentsRef.current, + }), }, }, }), @@ -299,6 +352,258 @@ export function ChatSidebar({ [clearError], ) + const setAttachmentDraftState = useCallback( + (id: string, patch: Partial) => { + setAttachmentDrafts((prev) => + prev.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ) + }, + [], + ) + + const discardUploadedAttachment = useCallback( + (documentId: string) => { + void removeCachedFile(documentId) + + const run = async (attempt: number): Promise => { + try { + const response = await fetch( + `${chatApiBase}/chat/attachments/${documentId}`, + { + method: "DELETE", + credentials: "include", + }, + ) + + if ( + response.status === 409 && + attempt < DISCARD_ATTACHMENT_MAX_ATTEMPTS + ) { + setTimeout(() => { + void run(attempt + 1) + }, DISCARD_ATTACHMENT_RETRY_MS) + return + } + + if (!response.ok && response.status !== 404) { + console.warn("Failed to discard chat attachment", { + documentId, + status: response.status, + }) + } + } catch (error) { + if (attempt < DISCARD_ATTACHMENT_MAX_ATTEMPTS) { + setTimeout(() => { + void run(attempt + 1) + }, DISCARD_ATTACHMENT_RETRY_MS) + return + } + console.warn("Failed to discard chat attachment", { + documentId, + error, + }) + } + } + + void run(1) + }, + [chatApiBase], + ) + + const uploadAttachmentDraft = useCallback( + ( + draft: ChatAttachmentDraft, + chatIdForUpload: string, + ): Promise => { + if (draft.status === "uploaded" && draft.uploaded) { + return Promise.resolve(draft.uploaded) + } + + const inflight = uploadPromisesRef.current.get(draft.id) + if (inflight) return inflight + + const uploadPromise = (async (): Promise => { + const controller = new AbortController() + abortControllersRef.current.set(draft.id, controller) + + 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", + signal: controller.signal, + }) + + 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) + + abortControllersRef.current.delete(draft.id) + + if (discardedDraftIdsRef.current.has(draft.id)) { + discardedDraftIdsRef.current.delete(draft.id) + if (attachment.documentId) { + discardUploadedAttachment(attachment.documentId) + } + return attachment + } + + if (attachment.documentId) { + void cacheFileBlob( + attachment.documentId, + draft.file, + draft.file.type, + ) + } + setAttachmentDraftState(draft.id, { + status: "uploaded", + uploaded: attachment, + }) + return attachment + } catch (error) { + abortControllersRef.current.delete(draft.id) + uploadPromisesRef.current.delete(draft.id) + + if (error instanceof DOMException && error.name === "AbortError") { + throw error + } + + if (discardedDraftIdsRef.current.has(draft.id)) { + discardedDraftIdsRef.current.delete(draft.id) + throw error + } + + const message = + error instanceof Error + ? error.message + : "Failed to upload attachment" + setAttachmentDraftState(draft.id, { + status: "error", + errorMessage: message, + }) + throw error + } + })() + + uploadPromisesRef.current.set(draft.id, uploadPromise) + return uploadPromise + }, + [chatApiBase, discardUploadedAttachment, 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 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]) + + for (const draft of nextItems) { + void uploadAttachmentDraft(draft, currentChatId).catch(() => { + // Upload errors are reflected on the draft state unless the draft was removed. + }) + } + }, + [attachmentDrafts, currentChatId, uploadAttachmentDraft], + ) + + const handleRemoveAttachment = useCallback( + (id: string) => { + const draft = attachmentDrafts.find((item) => item.id === id) + discardedDraftIdsRef.current.add(id) + + const controller = abortControllersRef.current.get(id) + if (controller) { + controller.abort() + abortControllersRef.current.delete(id) + } + uploadPromisesRef.current.delete(id) + + const documentId = draft?.uploaded?.documentId + if (draft?.status === "uploaded" && documentId) { + discardUploadedAttachment(documentId) + } + + setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id)) + }, + [attachmentDrafts, discardUploadedAttachment], + ) + + 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) @@ -332,37 +637,93 @@ 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 hasBusy = drafts.some( + (d) => d.status === "uploading" || d.status === "queued", + ) + if (hasBusy) return false + + 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, + }) + + setInput("") + setAttachmentDrafts([]) + uploadPromisesRef.current.clear() + abortControllersRef.current.clear() + discardedDraftIdsRef.current.clear() + userJustSentRef.current = true + scrollToBottom() + + void sendMessage({ + text: trimmed || "Analyze the attached file(s).", + metadata: + uploadedAttachments.length > 0 + ? { attachments: uploadedAttachments } + : undefined, + }).finally(() => { + pendingRequestAttachmentsRef.current = [] + }) + + 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], + ) + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && !isMobile) { e.preventDefault() @@ -436,6 +797,13 @@ export function ChatSidebar({ setThreadId(null) setFallbackChatId(newChatId) setInput("") + setAttachmentDrafts([]) + for (const controller of abortControllersRef.current.values()) { + controller.abort() + } + abortControllersRef.current.clear() + discardedDraftIdsRef.current.clear() + uploadPromisesRef.current.clear() }, [setThreadId, setMessages]) const fetchThreads = useCallback(async () => { @@ -476,6 +844,7 @@ export function ChatSidebar({ id: string role: string parts: Array<{ type: string }> + metadata?: unknown createdAt: string }) => ({ id: m.id, @@ -486,6 +855,7 @@ export function ChatSidebar({ parts: (m.parts || []).filter( (p) => p.type === "text" || p.type === "reasoning", ), + metadata: m.metadata, createdAt: new Date(m.createdAt), }), ) @@ -580,9 +950,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 @@ -613,8 +983,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?.() } @@ -623,14 +1008,13 @@ 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. @@ -791,6 +1175,18 @@ 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" || attachment.status === "queued", + ) + const hasErroredAttachment = attachmentDrafts.some( + (attachment) => attachment.status === "error", + ) + const canSendMessage = + (input.trim().length > 0 || attachmentDrafts.length > 0) && + !isResponding && + !hasBusyAttachment && + !hasErroredAttachment const showInputStatusStrip = !isStackedInput const chatHistorySheet = ( @@ -1151,6 +1547,12 @@ export function ChatSidebar({ onStop={handleStop} onKeyDown={handleKeyDown} isResponding={isResponding} + attachments={attachmentDrafts} + onAddAttachmentFiles={handleAddAttachmentFiles} + onRemoveAttachment={handleRemoveAttachment} + 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..350bf028c 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, + Loader2Icon, + PaperclipIcon, + RotateCcwIcon, + XIcon, +} from "lucide-react" +import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" 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,12 @@ 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 + onRetryAttachment?: (id: string) => void + canSend?: boolean + attachmentAccept?: string } export default function ChatInput({ @@ -36,10 +55,19 @@ export default function ChatInput({ onExpandedChange, stackedToolbar, showStatusStrip = true, + attachments = [], + onAddAttachmentFiles, + onRemoveAttachment, + onRetryAttachment, + canSend, + attachmentAccept = CHAT_ATTACHMENT_ACCEPT, }: ChatInputProps) { const [isMultiline, setIsMultiline] = useState(false) const [isExpanded, setIsExpanded] = useState(false) + const [isDraggingFiles, setIsDraggingFiles] = useState(false) const textareaRef = useRef(null) + const fileInputRef = useRef(null) + const dragDepthRef = useRef(0) useEffect(() => { if (!showStatusStrip && isExpanded) { @@ -61,6 +89,96 @@ 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 canAttachFiles = Boolean(onAddAttachmentFiles) && !isResponding + const hasDraggedFiles = (e: React.DragEvent) => + Array.from(e.dataTransfer.types).includes("Files") + + const handleDragEnter = (e: React.DragEvent) => { + if (!hasDraggedFiles(e)) return + e.preventDefault() + e.stopPropagation() + dragDepthRef.current += 1 + if (canAttachFiles) setIsDraggingFiles(true) + } + + const handleDragOver = (e: React.DragEvent) => { + if (!hasDraggedFiles(e)) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = canAttachFiles ? "copy" : "none" + } + + const handleDragLeave = (e: React.DragEvent) => { + if (!hasDraggedFiles(e)) return + e.preventDefault() + e.stopPropagation() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setIsDraggingFiles(false) + } + + const handleDrop = (e: React.DragEvent) => { + if (!hasDraggedFiles(e)) return + e.preventDefault() + e.stopPropagation() + dragDepthRef.current = 0 + setIsDraggingFiles(false) + const files = e.dataTransfer.files + if (canAttachFiles && files.length) onAddAttachmentFiles?.(files) + } + + const showAttachments = attachments.length > 0 + const sendEnabled = canSend ?? value.trim().length > 0 + + const attachmentTray = showAttachments ? ( +
+ {attachments.map((attachment) => { + return ( + + ) + })} +
+ ) : null + + const attachmentButton = onAddAttachmentFiles ? ( + <> + + + + ) : null + + const dropOverlay = isDraggingFiles ? ( +
+ Drop files to attach +
+ ) : null + return ( ) : null} {stackedToolbar ? ( -
+
+ {dropOverlay} + {attachmentTray}