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
45 changes: 27 additions & 18 deletions apps/web/app/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from "react"
import { Header } from "@/components/new/header"
import { ChatSidebar } from "@/components/new/chat"
import { MemoriesGrid } from "@/components/new/memories-grid"
import { GraphLayoutView } from "@/components/new/graph-layout-view"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
import { AddDocumentModal } from "@/components/new/add-document"
import { MCPModal } from "@/components/new/mcp-modal"
Expand All @@ -25,13 +26,15 @@ import { useDocumentMutations } from "@/hooks/use-document-mutations"
import { useQuery } from "@tanstack/react-query"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { useViewMode } from "@/lib/view-mode-context"

type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]

export default function NewPage() {
const isMobile = useIsMobile()
const { selectedProject } = useProject()
const { viewMode } = useViewMode()
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
const [isMCPModalOpen, setIsMCPModalOpen] = useState(false)
const [isSearchOpen, setIsSearchOpen] = useState(false)
Expand Down Expand Up @@ -195,26 +198,32 @@ export default function NewPage() {
}}
/>
<main
key={`main-container-${isChatOpen}`}
key={`main-container-${isChatOpen}-${viewMode}`}
className="z-10 flex flex-col md:flex-row relative"
>
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<MemoriesGrid
isChatOpen={isChatOpen}
onOpenDocument={handleOpenDocument}
quickNoteProps={{
onSave: handleQuickNoteSave,
onMaximize: handleMaximize,
isSaving: noteMutation.isPending,
}}
highlightsProps={{
items: highlightsData?.highlights || [],
onChat: handleHighlightsChat,
onShowRelated: handleHighlightsShowRelated,
isLoading: isLoadingHighlights,
}}
/>
</div>
{viewMode === "graph" && !isMobile ? (
<div className="flex-1">
<GraphLayoutView isChatOpen={isChatOpen} />
</div>
) : (
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<MemoriesGrid
isChatOpen={isChatOpen}
onOpenDocument={handleOpenDocument}
quickNoteProps={{
onSave: handleQuickNoteSave,
onMaximize: handleMaximize,
isSaving: noteMutation.isPending,
}}
highlightsProps={{
items: highlightsData?.highlights || [],
onChat: handleHighlightsChat,
onShowRelated: handleHighlightsShowRelated,
isLoading: isLoadingHighlights,
}}
/>
</div>
)}
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
<AnimatePresence mode="popLayout">
<ChatSidebar
Expand Down
168 changes: 12 additions & 156 deletions apps/web/components/graph-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,192 +1,48 @@
"use client"

import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { z } from "zod"
import { MemoryGraph } from "@repo/ui/memory-graph"
import { useState } from "react"
import { MemoryGraph } from "@/components/new/memory-graph/memory-graph"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { AddMemoryView } from "@/components/views/add-memory"
import { useChatOpen, useProject, useGraphModal } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
import { useIsMobile } from "@hooks/use-mobile"

type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]

/**
* Graph Dialog component
*/
export function GraphDialog() {
const { user } = useAuth()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const { selectedProject } = useProject()
const { isOpen } = useChatOpen()
const { isOpen: isChatOpen } = useChatOpen()
const { isOpen: showGraphModal, setIsOpen: setShowGraphModal } =
useGraphModal()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const isMobile = useIsMobile()

const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000

const {
data,
error,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<DocumentsResponse, Error>({
queryKey: ["documents-with-memories", selectedProject],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
body: {
page: pageParam as number,
limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})

if (response.error) {
throw new Error(response.error?.message || "Failed to fetch documents")
}

return response.data
},
getNextPageParam: (lastPage, allPages) => {
if (!lastPage || !lastPage.pagination) return undefined
if (!Array.isArray(allPages)) return undefined

const loaded = allPages.reduce(
(acc, p) => acc + (p.documents?.length ?? 0),
0,
)
if (loaded >= MAX_TOTAL) return undefined

const { currentPage, totalPages } = lastPage.pagination
if (currentPage < totalPages) {
return currentPage + 1
}
return undefined
},
staleTime: 5 * 60 * 1000,
enabled: !!user, // Only run query if user is authenticated
})

const baseDocuments = useMemo(() => {
return (
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
)
}, [data])

const allDocuments = useMemo(() => {
if (injectedDocs.length === 0) return baseDocuments
const byId = new Map<string, DocumentWithMemories>()
for (const d of injectedDocs) byId.set(d.id, d)
for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d)
return Array.from(byId.values())
}, [baseDocuments, injectedDocs])

const totalLoaded = allDocuments.length
const hasMore = hasNextPage
const isLoadingMore = isFetchingNextPage

const loadMoreDocuments = useCallback(async (): Promise<void> => {
if (hasNextPage && !isFetchingNextPage) {
await fetchNextPage()
return
}
return
}, [hasNextPage, isFetchingNextPage, fetchNextPage])

// Handle highlighted documents injection for chat
useEffect(() => {
if (!isOpen) return
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) return
const present = new Set<string>()
for (const d of [...baseDocuments, ...injectedDocs]) {
if (d.id) present.add(d.id)
if (d.customId) present.add(d.customId as string)
}
const missing = allHighlightDocumentIds.filter(
(id: string) => !present.has(id),
)
if (missing.length === 0) return
let cancelled = false
const run = async () => {
try {
const resp = await $fetch("@post/documents/documents/by-ids", {
body: {
ids: missing,
by: "customId",
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
if (cancelled || resp?.error) return
const extraDocs = resp?.data?.documents as
| DocumentWithMemories[]
| undefined
if (!extraDocs || extraDocs.length === 0) return
setInjectedDocs((prev) => {
const seen = new Set<string>([
...prev.map((d) => d.id),
...baseDocuments.map((d) => d.id),
])
const merged = [...prev]
for (const doc of extraDocs) {
if (!seen.has(doc.id)) {
merged.push(doc)
seen.add(doc.id)
}
}
return merged
})
} catch {}
}
void run()
return () => {
cancelled = true
}
}, [
isOpen,
allHighlightDocumentIds,
baseDocuments,
injectedDocs,
selectedProject,
])

if (!user) return null

// Convert selectedProject to containerTags array
const containerTags = selectedProject ? [selectedProject] : undefined

return (
<>
<Dialog open={showGraphModal} onOpenChange={setShowGraphModal}>
<DialogContent
className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl"
className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl"
showCloseButton={true}
>
<DialogTitle className="sr-only">Memory Graph</DialogTitle>
<div className="w-full h-full">
<MemoryGraph
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
loadMoreDocuments={loadMoreDocuments}
totalLoaded={totalLoaded}
containerTags={containerTags}
variant="console"
showSpacesSelector={true}
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isOpen}
highlightsVisible={isChatOpen}
>
<div className="absolute inset-0 flex items-center justify-center">
{!isMobile ? (
Expand Down
58 changes: 58 additions & 0 deletions apps/web/components/new/graph-layout-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client"

import { memo, useState, useCallback } from "react"
import { MemoryGraph } from "./memory-graph/memory-graph"
import { useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
import { Share2 } from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"

interface GraphLayoutViewProps {
isChatOpen: boolean
}

export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
const { selectedProject } = useProject()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const [_isShareModalOpen, setIsShareModalOpen] = useState(false)

const containerTags = selectedProject ? [selectedProject] : undefined

const handleShare = useCallback(() => {
setIsShareModalOpen(true)
}, [])

return (
<div className="relative w-full h-[calc(100vh-56px)]">
{/* Full-width graph */}
<div className="absolute inset-0">
<MemoryGraph
containerTags={containerTags}
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isChatOpen}
maxNodes={200}
/>
</div>

{/* Share graph button - top left */}
<div className="absolute top-4 left-4 z-[15]">
<Button
variant="headers"
className={cn(
"rounded-full text-base gap-2 h-10!",
dmSansClassName(),
)}
onClick={handleShare}
>
<Share2 className="size-4" />
Share graph
</Button>
</div>
</div>
)
})

GraphLayoutView.displayName = "GraphLayoutView"
12 changes: 10 additions & 2 deletions apps/web/components/new/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useIsMobile } from "@hooks/use-mobile"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useState } from "react"
import { FeedbackModal } from "./feedback-modal"
import { useViewMode } from "@/lib/view-mode-context"

interface HeaderProps {
onAddMemory?: () => void
Expand All @@ -59,6 +60,7 @@ export function Header({
const isMobile = useIsMobile()
const { resetOrgOnboarded } = useOrgOnboarding()
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false)
const { viewMode, setViewMode } = useViewMode()

const handleTryOnboarding = () => {
resetOrgOnboarded()
Expand Down Expand Up @@ -151,7 +153,10 @@ export function Header({
)}
</div>
{!isMobile && (
<Tabs defaultValue="grid">
<Tabs
value={viewMode === "list" ? "grid" : "graph"}
onValueChange={(v) => setViewMode(v === "grid" ? "list" : "graph")}
>
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
<TabsTrigger
value="grid"
Expand Down Expand Up @@ -400,7 +405,10 @@ export function Header({
</DropdownMenu>
)}
</div>
<FeedbackModal isOpen={isFeedbackOpen} onClose={() => setIsFeedbackOpen(false)} />
<FeedbackModal
isOpen={isFeedbackOpen}
onClose={() => setIsFeedbackOpen(false)}
/>
</div>
)
}
Loading