diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 17433857b..2baed6c82 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" @@ -166,6 +167,9 @@ export default function NewPage() { const [queuedChatProject, setQueuedChatProject] = useState( null, ) + const [queuedChatAttachments, setQueuedChatAttachments] = useState< + ChatAttachmentDraft[] | null + >(null) const [queuedHighlightContent, setQueuedHighlightContent] = useState< string | null >(null) @@ -494,6 +498,7 @@ export default function NewPage() { setQueuedChatModel(null) setQueuedChatReasoningEffort(null) setQueuedChatProject(null) + setQueuedChatAttachments(null) setQueuedMessageSource("highlight") void setViewMode("chat") }, @@ -506,12 +511,14 @@ export default function NewPage() { model: ModelId, projectId: string, reasoningEffort: ReasoningEffort, + attachments?: ChatAttachmentDraft[], ) => { setQueuedHighlightContent(null) setQueuedChatSeed(message) setQueuedChatModel(model) setQueuedChatReasoningEffort(reasoningEffort) setQueuedChatProject(projectId) + setQueuedChatAttachments(attachments ?? null) setQueuedMessageSource("home") void setViewMode("chat") }, @@ -523,6 +530,7 @@ export default function NewPage() { setQueuedChatModel(null) setQueuedChatReasoningEffort(null) setQueuedChatProject(null) + setQueuedChatAttachments(null) setQueuedHighlightContent(null) setQueuedMessageSource("highlight") }, []) @@ -642,6 +650,7 @@ export default function NewPage() { queuedHighlightContent={queuedHighlightContent} onConsumeQueuedMessage={consumeQueuedChat} queuedMessageSource={queuedMessageSource} + queuedAttachments={queuedChatAttachments} initialSelectedModel={queuedChatModel} initialReasoningEffort={queuedChatReasoningEffort} 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 5e78fbb8e..50647e028 100644 --- a/apps/web/components/chat/home-chat-composer.tsx +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -8,6 +8,14 @@ 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" import { ReasoningSelector } from "./reasoning-selector" import { getDefaultReasoningEffort, type ReasoningEffort } from "@/lib/models" @@ -20,10 +28,14 @@ export function HomeChatComposer({ model: ModelId, projectId: string, reasoningEffort: ReasoningEffort, + attachments?: ChatAttachmentDraft[], ) => void className?: string }) { const [input, setInput] = useState("") + const [attachmentDrafts, setAttachmentDrafts] = useState< + ChatAttachmentDraft[] + >([]) const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") const [reasoningEffort, setReasoningEffort] = useState( getDefaultReasoningEffort("gemini-2.5-pro"), @@ -40,15 +52,18 @@ export function HomeChatComposer({ const send = useCallback(() => { const t = input.trim() - if (!t) return + if (!t && attachmentDrafts.length === 0) return onStartChat( t, selectedModel, chatSpaceProjects[0] ?? selectedProject, reasoningEffort, + attachmentDrafts, ) setInput("") + setAttachmentDrafts([]) }, [ + attachmentDrafts, chatSpaceProjects, input, onStartChat, @@ -57,6 +72,51 @@ export function HomeChatComposer({ 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) { e.preventDefault() @@ -74,6 +134,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 80e933bb7..7cc435d85 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -56,8 +56,47 @@ 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" import { ReasoningSelector } from "./reasoning-selector" +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, isMobile, @@ -105,6 +144,7 @@ type QueuedChatMessage = { text: string model: ModelId reasoningEffort: ReasoningEffort + attachments?: ChatAttachment[] } const CHAT_QUEUE_LIMIT = 3 @@ -132,6 +172,7 @@ export function ChatSidebar({ queuedHighlightContent, onConsumeQueuedMessage, queuedMessageSource = "highlight", + queuedAttachments = null, initialSelectedModel = null, initialReasoningEffort = null, initialChatProject = null, @@ -144,6 +185,7 @@ export function ChatSidebar({ queuedHighlightContent?: string | null onConsumeQueuedMessage?: () => void queuedMessageSource?: "highlight" | "home" + queuedAttachments?: ChatAttachmentDraft[] | null initialSelectedModel?: ModelId | null initialReasoningEffort?: ReasoningEffort | null initialChatProject?: string | null @@ -153,6 +195,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", ) @@ -208,6 +253,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, @@ -302,6 +353,9 @@ export function ChatSidebar({ reasoningEffort: sendSettings?.reasoningEffort ?? reasoningEffortRef.current, truncateFromMessageId: truncateFromMessageIdRef.current, + ...(pendingRequestAttachmentsRef.current.length > 0 && { + attachments: pendingRequestAttachmentsRef.current, + }), }, }, } @@ -394,6 +448,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) @@ -427,65 +733,138 @@ export function ChatSidebar({ } }, []) - const handleSend = () => { - const text = input.trim() - if (!text) return + const submitChatMessage = useCallback( + async ( + text: string, + source: ChatMessageSendSource, + drafts = attachmentDrafts, + ): Promise => { + const trimmed = text.trim() + if (!trimmed && drafts.length === 0) return false - if (status === "submitted" || status === "streaming") { - if (messageQueue.length >= CHAT_QUEUE_LIMIT) return + const hasBusy = drafts.some( + (d) => d.status === "uploading" || d.status === "queued", + ) + if (hasBusy) return false + const hasErrored = drafts.some((d) => d.status === "error") + if (hasErrored) return false - setMessageQueue((prev) => { - if (prev.length >= CHAT_QUEUE_LIMIT) return prev - return [ - ...prev, - { - id: generateId(), - text, - model: selectedModel, - reasoningEffort, - }, - ] - }) - setInput("") - analytics.chatMessageSent({ source: "typed" }) - userJustSentRef.current = true - scrollToBottom() - return - } + const chatIdForSend = threadId ?? fallbackChatId - truncateFromMessageIdRef.current = null - if (!threadId) setThreadId(fallbackChatId) - analytics.chatMessageSent({ source: "typed" }) - pendingResponseModelsRef.current.push(selectedModel) - sendMessage({ text }) - setInput("") - userJustSentRef.current = true - scrollToBottom() - } + try { + const uploadedAttachments = + drafts.length > 0 + ? await uploadAttachmentDrafts(drafts, chatIdForSend) + : [] + const messageText = trimmed || "Analyze the attached file(s)." + const isRespondingNow = status === "submitted" || status === "streaming" + + if (isRespondingNow) { + if (messageQueue.length >= CHAT_QUEUE_LIMIT) return false + setMessageQueue((prev) => { + if (prev.length >= CHAT_QUEUE_LIMIT) return prev + return [ + ...prev, + { + id: generateId(), + text: messageText, + model: selectedModel, + reasoningEffort, + attachments: 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() + return true + } - const handleSuggestedQuestion = useCallback( - (suggestion: string) => { - if (status === "submitted" || status === "streaming") return - truncateFromMessageIdRef.current = null - if (!threadId) setThreadId(fallbackChatId) - analytics.chatSuggestedQuestionClicked() - analytics.chatMessageSent({ source: "suggested" }) - pendingResponseModelsRef.current.push(selectedModel) - sendMessage({ text: suggestion }) - userJustSentRef.current = true - scrollToBottom() + truncateFromMessageIdRef.current = null + if (!threadId) setThreadId(fallbackChatId) + 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() + pendingResponseModelsRef.current.push(selectedModel) + + void sendMessage({ + text: messageText, + 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, + messageQueue.length, + reasoningEffort, + scrollToBottom, + selectedModel, sendMessage, setThreadId, status, threadId, - scrollToBottom, - selectedModel, + uploadAttachmentDrafts, ], ) + const handleSend = () => { + void submitChatMessage(input, "typed") + } + + const handleSuggestedQuestion = useCallback( + (suggestion: string) => { + if (status === "submitted" || status === "streaming") return + analytics.chatSuggestedQuestionClicked() + void submitChatMessage(suggestion, "suggested", []) + }, + [status, submitChatMessage], + ) + const handleRegenerateFromUserMessage = useCallback( ( messageId: string, @@ -615,6 +994,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() setMessageQueue([]) pendingResponseModelsRef.current = [] seenAssistantMessageIdsRef.current = new Set() @@ -780,9 +1166,9 @@ export function ChatSidebar({ return } sentQueuedMessageRef.current = queuedMessage - analytics.chatMessageSent({ source: queuedMessageSource }) if (queuedHighlightContent) { + analytics.chatMessageSent({ source: queuedMessageSource }) truncateFromMessageIdRef.current = null // Start a fresh thread for highlight-based chats to avoid overwriting existing conversations const newChatId = generateId() @@ -814,12 +1200,23 @@ export function ChatSidebar({ }, ] } else { - truncateFromMessageIdRef.current = null - if (!threadId) setThreadId(fallbackChatId) - queuedDispatchInFlightRef.current = true - queuedDispatchSawResponseRef.current = false - pendingResponseModelsRef.current.push(selectedModel) - 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?.() } @@ -828,16 +1225,15 @@ export function ChatSidebar({ queuedMessage, queuedHighlightContent, queuedMessageSource, + queuedAttachments, initialSelectedModel, initialReasoningEffort, selectedModel, reasoningEffort, status, - sendMessage, onConsumeQueuedMessage, - fallbackChatId, setThreadId, - threadId, + submitChatMessage, ]) // Inject the pending highlight assistant message once the new Chat instance is ready. @@ -924,7 +1320,16 @@ export function ChatSidebar({ ? prev.slice(1) : prev.filter((item) => item.id !== nextMessage.id), ) - sendMessage({ text: nextMessage.text }) + pendingRequestAttachmentsRef.current = nextMessage.attachments ?? [] + void sendMessage({ + text: nextMessage.text, + metadata: + nextMessage.attachments && nextMessage.attachments.length > 0 + ? { attachments: nextMessage.attachments } + : undefined, + }).finally(() => { + pendingRequestAttachmentsRef.current = [] + }) userJustSentRef.current = true scrollToBottom() }, [ @@ -1053,6 +1458,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" || attachment.status === "queued", + ) + const hasErroredAttachment = attachmentDrafts.some( + (attachment) => attachment.status === "error", + ) + const canSendMessage = + (input.trim().length > 0 || attachmentDrafts.length > 0) && + !hasBusyAttachment && + !hasErroredAttachment const showInputStatusStrip = !isStackedInput || isResponding || messages.length > 0 const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT @@ -1424,6 +1840,12 @@ export function ChatSidebar({ onStop={handleStop} onKeyDown={handleKeyDown} isResponding={isResponding} + attachments={attachmentDrafts} + onAddAttachmentFiles={handleAddAttachmentFiles} + onRemoveAttachment={handleRemoveAttachment} + onRetryAttachment={handleRetryAttachment} + canSend={canSendMessage} + attachmentAccept={CHAT_ATTACHMENT_ACCEPT} sendDisabled={isResponding && isQueueFull} sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`} activeStatus={ diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index 36ceb9b97..a2293a785 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -1,12 +1,27 @@ "use client" -import { BrainIcon, ChevronUpIcon, ZapIcon } from "lucide-react" +import { + BrainIcon, + CheckIcon, + ChevronUpIcon, + Loader2Icon, + PaperclipIcon, + RotateCcwIcon, + XIcon, + ZapIcon, +} 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 { AnimatePresence, motion } from "motion/react" import { SendButton, StopButton } from "./actions" +import { + CHAT_ATTACHMENT_ACCEPT, + type ChatAttachmentDraft, + formatAttachmentSize, +} from "../attachments" import { type ModelId, modelNames, type ReasoningEffort } from "@/lib/models" export interface QueuedChatMessagePreview { @@ -33,6 +48,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({ @@ -50,16 +71,24 @@ 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 isSendDisabled = !value.trim() || sendDisabled + const fileInputRef = useRef(null) + const dragDepthRef = useRef(0) + const canSubmit = canSend ?? value.trim().length > 0 + const isSendDisabled = !canSubmit || sendDisabled const hasQueuedPreview = queuedMessages.length > 0 const resolvedSendDisabledTooltip = - sendDisabled && value.trim() - ? sendDisabledTooltip - : "Type a message to send" + sendDisabled && canSubmit ? sendDisabledTooltip : "Type a message to send" useEffect(() => { if (!showStatusStrip && isExpanded) { @@ -81,6 +110,95 @@ 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 attachmentTray = showAttachments ? ( +
+ {attachments.map((attachment) => { + return ( + + ) + })} +
+ ) : null + + const attachmentButton = onAddAttachmentFiles ? ( + <> + + + + ) : null + + const dropOverlay = isDraggingFiles ? ( +
+ Drop files to attach +
+ ) : null + return ( ) : null} {stackedToolbar ? ( -
+
+ {dropOverlay} + {attachmentTray}