Skip to content
Open
15 changes: 14 additions & 1 deletion apps/web/app/(app)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -164,6 +165,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)
Expand Down Expand Up @@ -491,18 +495,25 @@ export default function NewPage() {
setQueuedChatSeed(userReply)
setQueuedChatModel(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
},
[setViewMode],
)

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")
},
Expand All @@ -513,6 +524,7 @@ export default function NewPage() {
setQueuedChatSeed(null)
setQueuedChatModel(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedHighlightContent(null)
setQueuedMessageSource("highlight")
}, [])
Expand Down Expand Up @@ -632,6 +644,7 @@ export default function NewPage() {
queuedHighlightContent={queuedHighlightContent}
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
queuedAttachments={queuedChatAttachments}
initialSelectedModel={queuedChatModel}
initialChatProject={queuedChatProject}
/>
Expand Down
82 changes: 82 additions & 0 deletions apps/web/components/chat/attachments.ts
Original file line number Diff line number Diff line change
@@ -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 : []
}
87 changes: 83 additions & 4 deletions apps/web/components/chat/home-chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelId>("gemini-2.5-pro")
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
Expand All @@ -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) {
Expand All @@ -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={
<>
Expand Down
Loading
Loading