implement Nova chat attachments on the web side (#1004)

- Added attachment draft/shared types and validation in components/chat/attachments.ts.
- Added paperclip upload UI to Nova composer with file chips, size/status, remove, retry, and per-file Save / Chat only toggle.
- Wired uploads before send to /chat/attachments, then sends returned attachment references in chat message metadata.
- Preserved attachment metadata when loading threads and rendered attachment chips on user messages.
- Added attachment support from the home composer into the full chat view.
- Extended chat analytics with attachment counts.

<img width="1905" height="900" alt="image" src="https://github.com/user-attachments/assets/631001b8-7c68-4015-b36b-06c69cdad271" />
<img width="1323" height="1600" alt="image" src="https://github.com/user-attachments/assets/77eee08a-b235-41eb-b03a-f406b81b46e7" />

- ensured responsiveness
This commit is contained in:
ishaanxgupta 2026-06-08 06:18:28 +00:00
parent 428d3ccefa
commit 70955480fd
11 changed files with 1211 additions and 137 deletions

View file

@ -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<string | null>(
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}

View file

@ -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 : []
}

View file

@ -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<ModelId>("gemini-2.5-pro")
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
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={
<>

View file

@ -57,8 +57,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<ChatAttachment> & {
attachment?: Partial<ChatAttachment>
}
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,
@ -106,6 +145,7 @@ type QueuedChatMessage = {
text: string
model: ModelId
reasoningEffort: ReasoningEffort
attachments?: ChatAttachment[]
}
const CHAT_QUEUE_LIMIT = 3
@ -133,6 +173,7 @@ export function ChatSidebar({
queuedHighlightContent,
onConsumeQueuedMessage,
queuedMessageSource = "highlight",
queuedAttachments = null,
initialSelectedModel = null,
initialReasoningEffort = null,
initialChatProject = null,
@ -145,6 +186,7 @@ export function ChatSidebar({
queuedHighlightContent?: string | null
onConsumeQueuedMessage?: () => void
queuedMessageSource?: "highlight" | "home"
queuedAttachments?: ChatAttachmentDraft[] | null
initialSelectedModel?: ModelId | null
initialReasoningEffort?: ReasoningEffort | null
initialChatProject?: string | null
@ -154,6 +196,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<ModelId>(
initialSelectedModel ?? "claude-sonnet-4.6",
)
@ -222,6 +267,12 @@ export function ChatSidebar({
const awaitingHighlightInjectionRef = useRef(false)
const pendingHighlightMessageRef = useRef<UIMessage[] | null>(null)
const targetHighlightChatIdRef = useRef<string | null>(null)
const pendingRequestAttachmentsRef = useRef<ChatAttachment[]>([])
const uploadPromisesRef = useRef<Map<string, Promise<ChatAttachment>>>(
new Map(),
)
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
const discardedDraftIdsRef = useRef<Set<string>>(new Set())
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
initialChatProject ?? AUTO_CHAT_SPACE_ID,
@ -316,6 +367,9 @@ export function ChatSidebar({
reasoningEffort:
sendSettings?.reasoningEffort ?? reasoningEffortRef.current,
truncateFromMessageId: truncateFromMessageIdRef.current,
...(pendingRequestAttachmentsRef.current.length > 0 && {
attachments: pendingRequestAttachmentsRef.current,
}),
},
},
}
@ -408,6 +462,258 @@ export function ChatSidebar({
[clearError],
)
const setAttachmentDraftState = useCallback(
(id: string, patch: Partial<ChatAttachmentDraft>) => {
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<void> => {
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<ChatAttachment> => {
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<ChatAttachment> => {
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)
@ -441,63 +747,136 @@ export function ChatSidebar({
}
}, [])
const submitChatMessage = useCallback(
async (
text: string,
source: ChatMessageSendSource,
drafts = attachmentDrafts,
): Promise<boolean> => {
const trimmed = text.trim()
if (!trimmed && drafts.length === 0) return false
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
const chatIdForSend = threadId ?? fallbackChatId
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
}
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,
uploadAttachmentDrafts,
],
)
const handleSend = () => {
const text = input.trim()
if (!text) return
if (status === "submitted" || status === "streaming") {
if (messageQueue.length >= CHAT_QUEUE_LIMIT) return
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
}
truncateFromMessageIdRef.current = null
if (!threadId) setThreadId(fallbackChatId)
analytics.chatMessageSent({ source: "typed" })
pendingResponseModelsRef.current.push(selectedModel)
sendMessage({ text })
setInput("")
userJustSentRef.current = true
scrollToBottom()
void submitChatMessage(input, "typed")
}
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()
void submitChatMessage(suggestion, "suggested", [])
},
[
fallbackChatId,
sendMessage,
setThreadId,
status,
threadId,
scrollToBottom,
selectedModel,
],
[status, submitChatMessage],
)
const handleRegenerateFromUserMessage = useCallback(
@ -624,6 +1003,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()
@ -791,9 +1177,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()
@ -825,12 +1211,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?.()
}
@ -839,16 +1236,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.
@ -935,7 +1331,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()
}, [
@ -1064,6 +1469,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
@ -1526,6 +1942,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={

View file

@ -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<HTMLTextAreaElement>(null)
const isSendDisabled = !value.trim() || sendDisabled
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 ? (
<div className="scrollbar-none flex max-w-full gap-2 overflow-x-auto px-0 pb-1 sm:px-1">
{attachments.map((attachment) => {
return (
<AttachmentPreviewChip
key={attachment.id}
attachment={attachment}
onRemove={onRemoveAttachment}
onRetry={onRetryAttachment}
/>
)
})}
</div>
) : null
const attachmentButton = onAddAttachmentFiles ? (
<>
<input
ref={fileInputRef}
type="file"
multiple
accept={attachmentAccept}
onChange={handleFileSelect}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isResponding}
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-[#242832] bg-black text-[#A6B0BE] transition-colors hover:border-[#3A4049] hover:bg-[#111418] hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Attach files"
title="Attach files"
>
<PaperclipIcon className="size-4" />
</button>
</>
) : null
const dropOverlay = isDraggingFiles ? (
<div className="pointer-events-none absolute inset-1 z-10 grid place-items-center rounded-lg border border-dashed border-[#4B5563] bg-black/70 text-sm font-medium text-fg-primary backdrop-blur-sm">
Drop files to attach
</div>
) : null
return (
<motion.div
className={cn("relative z-20!")}
@ -195,7 +313,16 @@ export default function ChatInput({
</>
) : null}
{stackedToolbar ? (
<div className="relative z-30 flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
<fieldset
aria-label="Chat input with file drop zone"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className="relative z-30 m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10"
>
{dropOverlay}
{attachmentTray}
<textarea
ref={textareaRef}
value={value}
@ -207,12 +334,13 @@ export default function ChatInput({
rows={1}
/>
<div className="flex items-center gap-2">
{attachmentButton}
<div className="flex min-w-0 flex-1 items-center gap-2">
{stackedToolbar}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{isResponding && <StopButton onClick={onStop} />}
{(!isResponding || value.trim()) && (
{(!isResponding || canSubmit) && (
<SendButton
onClick={onSend}
disabled={isSendDisabled}
@ -221,14 +349,21 @@ export default function ChatInput({
)}
</div>
</div>
</div>
</fieldset>
) : (
<div
<fieldset
aria-label="Chat input with file drop zone"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex items-end gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
"relative m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
isMultiline && "flex-col",
)}
>
{dropOverlay}
{attachmentTray}
<textarea
ref={textareaRef}
value={value}
@ -239,9 +374,10 @@ export default function ChatInput({
style={{ minHeight: "36px" }}
rows={1}
/>
<div className="flex items-center gap-1.5 transition-all duration-200">
<div className="flex w-full items-center justify-end gap-2 transition-all duration-200">
{attachmentButton}
{isResponding && <StopButton onClick={onStop} />}
{(!isResponding || value.trim()) && (
{(!isResponding || canSubmit) && (
<SendButton
onClick={onSend}
disabled={isSendDisabled}
@ -249,8 +385,197 @@ export default function ChatInput({
/>
)}
</div>
</div>
</fieldset>
)}
</motion.div>
)
}
function AttachmentPreviewChip({
attachment,
onRemove,
onRetry,
}: {
attachment: ChatAttachmentDraft
onRemove?: (id: string) => void
onRetry?: (id: string) => void
}) {
const [objectUrl, setObjectUrl] = useState<string | null>(null)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const isUploading = attachment.status === "uploading"
const isUploaded = attachment.status === "uploaded"
const isError = attachment.status === "error"
const isImage = attachment.file.type.startsWith("image/")
const extension = getAttachmentExtension(attachment.file)
useEffect(() => {
if (!isImage) {
setObjectUrl(null)
return
}
const url = URL.createObjectURL(attachment.file)
setObjectUrl(url)
return () => URL.revokeObjectURL(url)
}, [attachment.file, isImage])
const statusLabel = isError
? attachment.errorMessage || "Upload failed"
: isUploading
? "Uploading..."
: isUploaded
? "Uploaded"
: formatAttachmentSize(attachment.file.size)
return (
<>
<div
className={cn(
"group relative flex h-11 w-[min(280px,calc(100vw-4.5rem))] shrink-0 items-center gap-2 overflow-hidden rounded-xl border border-[#1A1D22] bg-[#050607] px-2 text-sm text-fg-primary shadow-[0_6px_18px_rgba(0,0,0,0.22)] transition-colors hover:border-[#30343B] hover:bg-[#080A0D] focus-within:border-[#30343B] sm:w-auto sm:min-w-[220px] sm:max-w-[300px] sm:hover:border-[#2261CA66] sm:hover:bg-[#041127] sm:focus-within:border-[#2261CA66] sm:focus-within:bg-[#041127]",
isImage && objectUrl && "cursor-pointer",
isError && "border-red-400/40 bg-red-950/20 hover:border-red-400/50",
)}
title={statusLabel}
>
<button
type="button"
onClick={() => isImage && objectUrl && setIsPreviewOpen(true)}
disabled={!isImage || !objectUrl}
className={cn(
"relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#242832] bg-black",
isImage && objectUrl && "cursor-pointer hover:border-[#4B5563]",
(!isImage || !objectUrl) && "cursor-default",
)}
aria-label={
isImage ? `Preview ${attachment.file.name}` : attachment.file.name
}
>
{isImage && objectUrl ? (
<img
src={objectUrl}
alt=""
className="size-full object-cover"
draggable={false}
/>
) : (
<DocumentFileGlyph label={extension} />
)}
{isUploading || isUploaded ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
{isUploading ? (
<Loader2Icon className="size-3.5 animate-spin text-[#C8D1DC]" />
) : (
<CheckIcon className="size-3.5 text-emerald-400" />
)}
</div>
) : null}
</button>
<div className="min-w-0 flex-1 pr-1">
<div
className="truncate font-medium leading-none text-fg-primary"
title={attachment.file.name}
>
{attachment.file.name}
</div>
<div className="mt-1 truncate text-[11px] leading-none text-fg-faint">
{statusLabel}
</div>
</div>
<div className="hidden shrink-0 items-center gap-1 opacity-0 transition-opacity sm:flex sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
{isError ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onRetry?.(attachment.id)
}}
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-[#242832] bg-black text-fg-faint transition-colors hover:border-[#3A4049] hover:bg-[#111418] hover:text-fg-primary"
aria-label={`Retry ${attachment.file.name}`}
title={statusLabel}
>
<RotateCcwIcon className="size-3.5" />
</button>
) : null}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onRemove?.(attachment.id)
}}
disabled={isUploading}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${attachment.file.name}`}
>
<XIcon className="size-4" />
</button>
</div>
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-32px)] max-w-none gap-0 overflow-hidden rounded-xl border border-[#1D222A] bg-[#050607] p-0 text-fg-primary shadow-[0_24px_80px_rgba(0,0,0,0.65)] sm:w-[min(92vw,980px)] sm:max-w-[980px]"
>
<DialogTitle className="sr-only">
Preview {attachment.file.name}
</DialogTitle>
<div className="flex min-h-0 flex-col">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_32px] items-center gap-2 border-[#171B22] border-b px-3 py-2 sm:px-4 sm:py-3">
<div className="min-w-0">
<p className="truncate font-medium text-fg-primary text-sm">
{attachment.file.name}
</p>
<p className="mt-0.5 text-[11px] text-fg-faint">
{formatAttachmentSize(attachment.file.size)}
</p>
</div>
<button
type="button"
onClick={() => setIsPreviewOpen(false)}
className="flex size-8 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary focus:outline-none focus:ring-2 focus:ring-[#3374FF]/40"
>
<XIcon className="size-4" />
<span className="sr-only">Close preview</span>
</button>
</div>
<div className="grid h-[min(58dvh,380px)] place-items-center bg-black px-4 py-5 sm:h-[min(76dvh,680px)] sm:px-6 sm:py-6">
{objectUrl ? (
<img
src={objectUrl}
alt={attachment.file.name}
className="block max-h-full max-w-full rounded-lg object-contain"
draggable={false}
/>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
function DocumentFileGlyph({ label }: { label: string }) {
return (
<div className="relative flex size-6 items-end justify-center rounded-[4px] border border-[#30343B] bg-[#07090C] pb-1">
<div className="absolute right-0 top-0 size-2.5 border-[#30343B] border-b border-l bg-black" />
<span className="max-w-[22px] truncate text-[8px] font-bold uppercase leading-none text-[#AAB2BD]">
{label}
</span>
</div>
)
}
function getAttachmentExtension(file: File): string {
const name = file.name
const dotIndex = name.lastIndexOf(".")
if (dotIndex > -1 && dotIndex < name.length - 1) {
const extension = name.slice(dotIndex + 1).toLowerCase()
if (extension === "md" || extension === "markdown") return "MD"
if (extension === "pdf") return "PDF"
return extension.slice(0, 3)
}
if (file.type === "text/markdown") return "MD"
if (file.type.includes("pdf")) return "PDF"
if (file.type.startsWith("text/")) return "TXT"
return "FILE"
}

View file

@ -2,7 +2,7 @@
import { memo, useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Copy, Check, PencilIcon, PencilOffIcon } from "lucide-react"
import { Check, Copy, FileIcon, PencilIcon, PencilOffIcon } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
import ChatModelSelector from "../model-selector"
import { ReasoningSelector } from "../reasoning-selector"
@ -12,6 +12,7 @@ import {
type ModelId,
type ReasoningEffort,
} from "@/lib/models"
import { formatAttachmentSize, getChatMessageAttachments } from "../attachments"
interface UserMessageProps {
message: UIMessage
@ -45,6 +46,7 @@ export const UserMessage = memo(function UserMessage({
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
const attachments = getChatMessageAttachments(message.metadata)
const startEditing = () => {
setDraft(text)
@ -71,7 +73,31 @@ export const UserMessage = memo(function UserMessage({
}, [isEditing])
return (
<div className="flex flex-col items-end w-full">
<div className="flex w-full flex-col items-end">
{attachments.length > 0 ? (
<div className="mb-2 flex w-full max-w-[80%] flex-col items-end gap-1.5">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex max-w-full min-w-0 items-center gap-2 rounded-lg border border-[#303949] bg-[#0D121A]/80 px-2.5 py-2 text-left shadow-[0_6px_18px_rgba(0,0,0,0.18)]"
>
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[#111A27]">
<FileIcon className="size-3.5 text-[#8FC3FF]" />
</div>
<div className="min-w-0">
<div className="truncate text-xs font-medium text-white">
{attachment.filename}
</div>
<div className="truncate text-[11px] text-white/50">
{formatAttachmentSize(attachment.size)}
{" | "}
{attachment.saveToMemory ? "Saved" : "Chat only"}
</div>
</div>
</div>
))}
</div>
) : null}
<AnimatePresence mode="popLayout" initial={false}>
{isEditing ? (
<motion.div
@ -126,7 +152,9 @@ export const UserMessage = memo(function UserMessage({
transition={{ duration: 0.18, ease: "easeOut" }}
className="max-w-[80%] origin-top-right rounded-[12px] bg-[#1B1F24] p-3 px-[14px]"
>
<p className="text-sm text-white whitespace-pre-wrap">{text}</p>
{text ? (
<p className="text-sm text-white whitespace-pre-wrap">{text}</p>
) : null}
</motion.div>
)}
</AnimatePresence>

View file

@ -1,31 +1,60 @@
"use client"
import { useCallback, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { getCachedFileBlob } from "@/lib/file-cache"
interface ImagePreviewProps {
url: string
title?: string | null
documentId?: string | null
}
export function ImagePreview({ url, title }: ImagePreviewProps) {
export function ImagePreview({ url, title, documentId }: ImagePreviewProps) {
const [imageError, setImageError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [retryKey, setRetryKey] = useState(0)
const [activeSrc, setActiveSrc] = useState(url)
const objectUrlRef = useRef<string | null>(null)
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
}
}, [])
// On first failure, wait briefly then force a re-render with a new key to
// retry the fetch (covers transient R2 timing issues).
// On second failure, give up and show the error state.
const handleImageError = useCallback(() => {
if (retryKey === 0) {
setTimeout(() => setRetryKey(1), 500)
return
}
if (retryKey === 1 && documentId) {
getCachedFileBlob(documentId).then((blob) => {
if (blob) {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
}
const objUrl = URL.createObjectURL(blob)
objectUrlRef.current = objUrl
setActiveSrc(objUrl)
setRetryKey(2)
} else {
setImageError(true)
setIsLoading(false)
}
})
return
}
setImageError(true)
setIsLoading(false)
}, [retryKey])
}, [retryKey, documentId])
if (imageError || !url) {
if (imageError || !activeSrc) {
return (
<div className="flex items-center justify-center h-full text-[#737373]">
<p>Failed to load image</p>
@ -43,7 +72,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${url})`,
backgroundImage: `url(${activeSrc})`,
filter: "blur(100px)",
transform: "scale(1.1)",
opacity: isLoading ? 0.5 : 1,
@ -52,7 +81,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
<div className="absolute inset-0 bg-black/30" />
<img
key={retryKey}
src={url}
src={activeSrc}
alt={title || "Image preview"}
className={cn(
"relative max-w-full max-h-full size-auto object-contain z-10",

View file

@ -88,7 +88,13 @@ export function DocumentContent({
switch (contentType) {
case "image":
return <ImagePreview url={document.url ?? ""} title={document.title} />
return (
<ImagePreview
url={document.url ?? ""}
title={document.title}
documentId={document.id}
/>
)
case "tweet":
return (

View file

@ -1,23 +1,55 @@
"use client"
import { Document, Page, pdfjs } from "react-pdf"
import { useCallback, useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import "react-pdf/dist/Page/AnnotationLayer.css"
import "react-pdf/dist/Page/TextLayer.css"
import { getCachedFileBlob } from "@/lib/file-cache"
// Configure PDF.js worker to use local package
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString()
type FileSource = string | { url: string; withCredentials: boolean } | null
interface PdfViewerProps {
url: string | null | undefined
documentId?: string | null
}
export function PdfViewer({ url, documentId }: PdfViewerProps) {
const fileSource = useMemo(() => {
const [cachedUrl, setCachedUrl] = useState<string | null>(null)
const [cacheChecked, setCacheChecked] = useState(false)
const objectUrlRef = useRef<string | null>(null)
useEffect(() => {
let revoked = false
if (!documentId) {
setCacheChecked(true)
return
}
getCachedFileBlob(documentId).then((blob) => {
if (revoked) return
if (blob) {
const objUrl = URL.createObjectURL(blob)
objectUrlRef.current = objUrl
setCachedUrl(objUrl)
}
setCacheChecked(true)
})
return () => {
revoked = true
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
}
}, [documentId])
const remoteFileSource: FileSource = useMemo(() => {
if (!url) return null
try {
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
@ -32,12 +64,34 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
return url
}, [url, documentId])
const backendProxySource: FileSource = useMemo(() => {
if (!documentId) return null
const base =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
return { url: `${base}/v3/file-proxy/${documentId}`, withCredentials: true }
}, [documentId])
const [numPages, setNumPages] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [retryKey, setRetryKey] = useState(0)
const [failedSources, setFailedSources] = useState(0)
if (!url) {
const fileSource = useMemo((): FileSource => {
if (cachedUrl) return cachedUrl
if (failedSources === 0) return remoteFileSource
if (failedSources === 1 && backendProxySource) return backendProxySource
return null
}, [cachedUrl, failedSources, remoteFileSource, backendProxySource])
if (!cacheChecked) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
Loading PDF
</div>
)
}
if (!url && !cachedUrl) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
No PDF URL provided
@ -51,24 +105,26 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
setError(null)
}
// On first failure, wait briefly then force a re-mount of the Document
// component to retry (covers transient R2 timing issues).
// On second failure, give up and show the error state.
const onDocumentLoadError = useCallback(
(err: Error) => {
if (retryKey === 0) {
setTimeout(() => {
setRetryKey(1)
setLoading(true)
setError(null)
}, 500)
return
}
function onDocumentLoadError(err: Error) {
if (cachedUrl) {
setError(err.message || "Failed to load PDF")
setLoading(false)
},
[retryKey],
)
return
}
const nextFailed = failedSources + 1
const hasMoreSources =
(nextFailed === 1 && backendProxySource !== null) || nextFailed < 1
if (hasMoreSources) {
setFailedSources(nextFailed)
setLoading(true)
setError(null)
} else {
setError(err.message || "Failed to load PDF")
setLoading(false)
}
}
return (
<div className="flex flex-col size-full overflow-hidden scrollbar-thin">
@ -82,35 +138,33 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
Error: {error}
</div>
)}
<div className="flex-1 overflow-auto w-full">
<Document
key={retryKey}
file={
fileSource ||
"https://corsproxy.io/?" +
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={null}
className="w-full"
>
{numPages && (
<div className="flex flex-col items-center gap-4 py-4 w-full">
{Array.from(new Array(numPages), (_, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
renderTextLayer
renderAnnotationLayer
className="shadow-lg"
width={630}
/>
))}
</div>
)}
</Document>
</div>
{fileSource && (
<div className="flex-1 overflow-auto w-full">
<Document
key={`${failedSources}-${cachedUrl ? "cache" : "remote"}`}
file={fileSource}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={null}
className="w-full"
>
{numPages && (
<div className="flex flex-col items-center gap-4 py-4 w-full">
{Array.from(new Array(numPages), (_, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
renderTextLayer
renderAnnotationLayer
className="shadow-lg"
width={630}
/>
))}
</div>
)}
</Document>
</div>
)}
</div>
)
}

View file

@ -122,6 +122,9 @@ export const analytics = {
// chat analytics
chatMessageSent: (props: {
source: "typed" | "suggested" | "highlight" | "home"
attachment_count?: number
saved_attachment_count?: number
temporary_attachment_count?: number
}) => safeCapture("chat_message_sent", props),
chatSuggestedQuestionClicked: () =>

View file

@ -0,0 +1,51 @@
import { createStore, get, set, del } from "idb-keyval"
const fileCacheStore = createStore("supermemory-file-cache", "blobs")
interface CachedFile {
blob: Blob
mimeType: string
}
export async function cacheFileBlob(
documentId: string,
blob: Blob,
mimeType: string,
): Promise<void> {
try {
await set(
documentId,
{ blob, mimeType } satisfies CachedFile,
fileCacheStore,
)
} catch {
// Storage full or unavailable — non-critical, skip silently
}
}
export async function getCachedFileBlob(
documentId: string,
): Promise<Blob | null> {
try {
const cached = await get<CachedFile>(documentId, fileCacheStore)
return cached?.blob ?? null
} catch {
return null
}
}
export async function getCachedFileUrl(
documentId: string,
): Promise<string | null> {
const blob = await getCachedFileBlob(documentId)
if (!blob) return null
return URL.createObjectURL(blob)
}
export async function removeCachedFile(documentId: string): Promise<void> {
try {
await del(documentId, fileCacheStore)
} catch {
// non-critical
}
}