Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
81 changes: 81 additions & 0 deletions apps/web/components/chat/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export const CHAT_ATTACHMENT_ACCEPT =
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown"

export const CHAT_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024

const SUPPORTED_EXTENSIONS = new Set([
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".csv",
".txt",
".md",
".mdx",
])

export type ChatAttachment = {
id: string
documentId?: string
filename: string
mediaType: string
size: number
saveToMemory: boolean
status: "ready" | "processing" | "failed"
}

export type ChatAttachmentDraftStatus =
| "queued"
| "uploading"
| "uploaded"
| "error"

export type ChatAttachmentDraft = {
id: string
file: File
saveToMemory: boolean
status: ChatAttachmentDraftStatus
errorMessage?: string
uploaded?: ChatAttachment
}

export type ChatAttachmentMessageMetadata = {
attachments?: ChatAttachment[]
}

export function isAcceptedChatAttachment(file: File): boolean {
if (file.size > CHAT_ATTACHMENT_MAX_BYTES) return false
const name = file.name.toLowerCase()
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""
if (SUPPORTED_EXTENSIONS.has(ext)) return true
if (file.type.startsWith("image/")) return true
if (file.type === "text/markdown") return true
return false
}

export function chatAttachmentKey(file: File): string {
return `${file.name}:${file.size}:${file.lastModified}`
}

export function createChatAttachmentDraft(file: File): ChatAttachmentDraft {
return {
id: crypto.randomUUID(),
file,
saveToMemory: true,
status: "queued",
}
}

export function formatAttachmentSize(size: number): string {
if (size < 1024) return `${size} B`
const kb = size / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
return `${(kb / 1024).toFixed(1)} MB`
}

export function getChatMessageAttachments(metadata: unknown): ChatAttachment[] {
const attachments = (metadata as ChatAttachmentMessageMetadata | undefined)
?.attachments
return Array.isArray(attachments) ? attachments : []
}
96 changes: 92 additions & 4 deletions apps/web/components/chat/home-chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,31 @@ import { useProject } from "@/stores"
import { cn } from "@lib/utils"
import type { ModelId } from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
import { toast } from "sonner"
import {
chatAttachmentKey,
CHAT_ATTACHMENT_ACCEPT,
createChatAttachmentDraft,
type ChatAttachmentDraft,
isAcceptedChatAttachment,
} from "./attachments"

export function HomeChatComposer({
onStartChat,
className,
}: {
onStartChat: (message: string, model: ModelId, projectId: string) => void
onStartChat: (
message: string,
model: ModelId,
projectId: string,
attachments?: ChatAttachmentDraft[],
) => void
className?: string
}) {
const [input, setInput] = useState("")
const [attachmentDrafts, setAttachmentDrafts] = useState<
ChatAttachmentDraft[]
>([])
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
Expand All @@ -24,10 +40,76 @@ export function HomeChatComposer({

const send = useCallback(() => {
const t = input.trim()
if (!t) return
onStartChat(t, selectedModel, chatSpaceProjects[0] ?? selectedProject)
if (!t && attachmentDrafts.length === 0) return
onStartChat(
t,
selectedModel,
chatSpaceProjects[0] ?? selectedProject,
attachmentDrafts,
)
setInput("")
}, [chatSpaceProjects, input, onStartChat, selectedModel, selectedProject])
setAttachmentDrafts([])
}, [
attachmentDrafts,
chatSpaceProjects,
input,
onStartChat,
selectedModel,
selectedProject,
])

const handleAddAttachmentFiles = useCallback(
(files: FileList | File[]) => {
const incoming = Array.from(files)
const accepted = incoming.filter(isAcceptedChatAttachment)
const rejected = incoming.length - accepted.length
if (rejected > 0) {
toast.error(
rejected === 1
? "One attachment is not supported or is over 50MB"
: `${rejected} attachments are not supported or are over 50MB`,
)
}
if (accepted.length === 0) return

const existingKeys = new Set(
attachmentDrafts.map((item) => chatAttachmentKey(item.file)),
)
const nextItems: ChatAttachmentDraft[] = []
let duplicateCount = 0
for (const file of accepted) {
const key = chatAttachmentKey(file)
if (existingKeys.has(key)) {
duplicateCount++
continue
}
existingKeys.add(key)
nextItems.push(createChatAttachmentDraft(file))
}
if (duplicateCount > 0) {
toast.message(
duplicateCount === 1
? "Skipped duplicate attachment"
: `Skipped ${duplicateCount} duplicate attachments`,
)
}
if (nextItems.length === 0) return
setAttachmentDrafts((prev) => [...prev, ...nextItems])
},
[attachmentDrafts],
)

const handleRemoveAttachment = useCallback((id: string) => {
setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id))
}, [])

const handleToggleAttachmentSave = useCallback((id: string) => {
setAttachmentDrafts((prev) =>
prev.map((item) =>
item.id === id ? { ...item, saveToMemory: !item.saveToMemory } : item,
),
)
}, [])

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
Expand All @@ -46,6 +128,12 @@ export function HomeChatComposer({
onStop={() => {}}
onKeyDown={handleKeyDown}
isResponding={false}
attachments={attachmentDrafts}
onAddAttachmentFiles={handleAddAttachmentFiles}
onRemoveAttachment={handleRemoveAttachment}
onToggleAttachmentSave={handleToggleAttachmentSave}
canSend={input.trim().length > 0 || attachmentDrafts.length > 0}
attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
showStatusStrip={false}
stackedToolbar={
<>
Expand Down
Loading
Loading