Skip to content
Merged
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
17 changes: 13 additions & 4 deletions 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 @@ -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)
Expand Down Expand Up @@ -494,6 +498,7 @@ export default function NewPage() {
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
},
Expand All @@ -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")
},
Expand All @@ -523,6 +530,7 @@ export default function NewPage() {
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedHighlightContent(null)
setQueuedMessageSource("highlight")
}, [])
Expand Down Expand Up @@ -572,17 +580,17 @@ export default function NewPage() {
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
const isGraphMode = viewMode === "graph"
const showBottomNav = isMobile && !!session
const showBottomNav = isMobile && !!session && !isChatView

return (
<HotkeysProvider>
<div
className={cn(
"relative flex min-h-dvh flex-col bg-[#05080D]",
isGraphMode && "h-dvh overflow-hidden",
(isGraphMode || isChatView) && "h-dvh overflow-hidden",
showBottomNav &&
!isGraphMode &&
"pb-[calc(5.5rem+env(safe-area-inset-bottom))]",
"pb-[calc(4rem+env(safe-area-inset-bottom))]",
)}
>
{showNovaBackdrop && (
Expand Down Expand Up @@ -642,6 +650,7 @@ export default function NewPage() {
queuedHighlightContent={queuedHighlightContent}
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
queuedAttachments={queuedChatAttachments}
initialSelectedModel={queuedChatModel}
initialReasoningEffort={queuedChatReasoningEffort}
initialChatProject={queuedChatProject}
Expand Down Expand Up @@ -761,7 +770,7 @@ export default function NewPage() {
className={cn(
"pointer-events-none fixed inset-x-0 z-30",
showBottomNav
? "bottom-[4.25rem]"
? "bottom-[calc(4rem+env(safe-area-inset-bottom))]"
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
)}
>
Expand Down
12 changes: 6 additions & 6 deletions apps/web/components/bottom-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
<nav
aria-label="Primary"
className={cn(
"fixed inset-x-0 bottom-[calc(0.75rem+env(safe-area-inset-bottom))] z-40 flex justify-center px-3 md:hidden",
"fixed inset-x-0 bottom-0 z-40 border-t border-white/[0.08] bg-[#0A0E14]/85 shadow-[0_-8px_24px_rgba(0,0,0,0.35)] backdrop-blur-xl md:hidden",
dmSansClassName(),
)}
>
<div className="flex w-full items-center justify-around rounded-full border border-[#161F2C] bg-muted/95 px-2.5 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.55)] backdrop-blur-xl">
<div className="flex items-center justify-around px-1 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<NavTab
label="Home"
icon={Home}
Expand All @@ -76,10 +76,10 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
type="button"
aria-label="Open chat"
onClick={() => void setViewMode("chat")}
className="group relative flex size-11 shrink-0 items-center justify-center self-center rounded-full outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/50"
className="group relative flex size-11 shrink-0 items-center justify-center rounded-full shadow-[0_0_18px_rgba(75,160,250,0.35)] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60 active:scale-95"
>
<NovaOrb
size={42}
size={40}
className="pointer-events-none blur-[1px]! transition-transform group-hover:scale-105"
/>
</button>
Expand Down Expand Up @@ -172,13 +172,13 @@ function NavTabButton({
aria-current={active ? "page" : undefined}
onClick={onClick}
className={cn(
"flex shrink-0 flex-col items-center gap-1 rounded-full px-3 py-1.5 outline-none transition-colors",
"flex flex-1 flex-col items-center gap-1 rounded-lg py-1 outline-none transition-colors active:scale-95",
active ? "text-white" : "text-[#737373] hover:text-white",
)}
{...props}
>
{children}
<span className="text-[10px] font-medium leading-none">{label}</span>
<span className="text-[11px] font-medium leading-none">{label}</span>
</button>
)
}
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 : []
}
113 changes: 90 additions & 23 deletions apps/web/components/chat/home-chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -20,13 +28,17 @@ export function HomeChatComposer({
model: ModelId,
projectId: string,
reasoningEffort: ReasoningEffort,
attachments?: ChatAttachmentDraft[],
) => void
className?: string
}) {
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const [attachmentDrafts, setAttachmentDrafts] = useState<
ChatAttachmentDraft[]
>([])
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.3")
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
getDefaultReasoningEffort("gemini-2.5-pro"),
getDefaultReasoningEffort("grok-4.3"),
)
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -66,35 +126,42 @@ export function HomeChatComposer({

return (
<div className={cn(className)}>
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6">
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-2 md:pb-6">
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSend={send}
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={
<>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={handleModelChange}
minimal
/>
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
/>
</>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={handleModelChange}
minimal
/>
}
toolbarTrailing={
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
}
toolbarEnd={
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 shadow-none hover:bg-white/5"
/>
}
/>
</div>
Expand Down
Loading
Loading