mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat: major iteration on the app (#896)
feat: major iteration on the app add: dashboard improvements few more improvements add few more improvements add few more changes home improvmeents few more improvements add lot of modifications fix few things
This commit is contained in:
parent
654386c644
commit
68b6c66321
60 changed files with 5406 additions and 1361 deletions
18
apps/web/app/(app)/old/onboarding/page.tsx
Normal file
18
apps/web/app/(app)/old/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/old/onboarding/welcome?step=input")
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black flex items-center justify-center">
|
||||
<div className="text-white/50 text-sm">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import { useRouter, useSearchParams } from "next/navigation"
|
|||
import { useOnboardingContext, type MemoryFormData } from "../layout"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export const SETUP_STEPS = ["relatable", "integrations"] as const
|
||||
export const SETUP_STEPS = ["integrations"] as const
|
||||
export type SetupStep = (typeof SETUP_STEPS)[number]
|
||||
|
||||
interface SetupContextValue {
|
||||
|
|
@ -41,7 +41,7 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
|
|||
const stepParam = searchParams.get("step")
|
||||
const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep)
|
||||
? (stepParam as SetupStep)
|
||||
: "relatable"
|
||||
: "integrations"
|
||||
const hasTrackedInitialStep = useRef(false)
|
||||
|
||||
const goToStep = useCallback(
|
||||
45
apps/web/app/(app)/old/onboarding/setup/page.tsx
Normal file
45
apps/web/app/(app)/old/onboarding/setup/page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatePresence } from "motion/react"
|
||||
|
||||
import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step"
|
||||
|
||||
import { SetupHeader } from "@/components/onboarding/setup/header"
|
||||
import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
import { useSetupContext } from "./layout"
|
||||
|
||||
export default function SetupPage() {
|
||||
const { memoryFormData } = useSetupContext()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden bg-black">
|
||||
<SetupHeader />
|
||||
|
||||
<AnimatedGradientBackground animateFromBottom={false} />
|
||||
|
||||
<main className="relative min-h-screen">
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-col lg:flex-row h-[calc(100vh-90px)] relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-start p-4 md:p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<IntegrationsStep key="integrations" />
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar formData={memoryFormData} />
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isMobile && <ChatSidebar formData={memoryFormData} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
|||
if (isMountedRef.current) {
|
||||
setShowWelcomeContent(true)
|
||||
}
|
||||
}, 1000)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setShowWelcomeContent(true)
|
||||
|
|
@ -97,7 +97,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
|||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" })
|
||||
router.replace("/onboarding/welcome?step=welcome")
|
||||
router.replace("/old/onboarding/welcome?step=welcome")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
|
|
@ -109,7 +109,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
|||
step: "username",
|
||||
trigger: "auto",
|
||||
})
|
||||
router.replace("/onboarding/welcome?step=username")
|
||||
router.replace("/old/onboarding/welcome?step=username")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
|
|
@ -133,14 +133,14 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
|||
const goToStep = useCallback(
|
||||
(step: WelcomeStep) => {
|
||||
analytics.onboardingStepViewed({ step, trigger: "user" })
|
||||
router.push(`/onboarding/welcome?step=${step}`)
|
||||
router.push(`/old/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(
|
||||
(step = "relatable") => {
|
||||
router.push(`/onboarding/setup?step=${step}`)
|
||||
(step = "integrations") => {
|
||||
router.push(`/old/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
|
@ -12,7 +12,6 @@ import { OnboardingContentStep } from "@/components/onboarding/welcome/continue-
|
|||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
|
||||
import {
|
||||
useWelcomeContext,
|
||||
|
|
@ -212,7 +211,7 @@ export default function WelcomePage() {
|
|||
const showUserSupermemory = currentStep === "username"
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black">
|
||||
<div className="relative h-screen overflow-hidden bg-black">
|
||||
<InitialHeader
|
||||
showUserSupermemory={
|
||||
currentStep === "features" || currentStep === "memories"
|
||||
|
|
@ -221,10 +220,6 @@ export default function WelcomePage() {
|
|||
name={name}
|
||||
/>
|
||||
|
||||
{currentStep === "input" && (
|
||||
<AnimatedGradientBackground animateFromBottom={true} />
|
||||
)}
|
||||
|
||||
{showWelcomeContent && (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
|
||||
<motion.div
|
||||
|
|
@ -254,7 +249,6 @@ export default function WelcomePage() {
|
|||
initial={{
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
y: 60,
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,74 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
import { RelatableQuestion } from "@/components/onboarding/setup/relatable-question"
|
||||
import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step"
|
||||
|
||||
import { SetupHeader } from "@/components/onboarding/setup/header"
|
||||
import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
import { useSetupContext, type SetupStep } from "./layout"
|
||||
|
||||
function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep("relatable")}
|
||||
className="text-blue-400 underline"
|
||||
>
|
||||
Go to first step
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const { memoryFormData, currentStep, goToStep } = useSetupContext()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case "relatable":
|
||||
return <RelatableQuestion key="relatable" />
|
||||
case "integrations":
|
||||
return <IntegrationsStep key="integrations" />
|
||||
default:
|
||||
return <StepNotFound key="not-found" goToStep={goToStep} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black">
|
||||
<SetupHeader />
|
||||
|
||||
<AnimatedGradientBackground animateFromBottom={false} />
|
||||
|
||||
<main className="relative min-h-screen">
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-col lg:flex-row h-[calc(100vh-90px)] relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-start p-4 md:p-8">
|
||||
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar formData={memoryFormData} />
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isMobile && <ChatSidebar formData={memoryFormData} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,18 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Header } from "@/components/header"
|
||||
import { ChatSidebar } from "@/components/chat"
|
||||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
import { IntegrationsView } from "@/components/integrations-view"
|
||||
|
|
@ -15,7 +24,6 @@ import { FullscreenNoteModal } from "@/components/fullscreen-note-modal"
|
|||
import type { HighlightItem } from "@/components/highlights-card"
|
||||
import { HotkeysProvider } from "react-hotkeys-hook"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useProject } from "@/stores"
|
||||
|
|
@ -26,11 +34,14 @@ import {
|
|||
useQuickNoteDraft,
|
||||
} from "@/stores/quick-note-draft"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
import { useDocumentMutations } from "@/hooks/use-document-mutations"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import type { MemoryOfDay } from "@/components/dashboard-view"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
|
|
@ -39,15 +50,36 @@ import {
|
|||
qParam,
|
||||
docParam,
|
||||
fullscreenParam,
|
||||
chatParam,
|
||||
integrationParam,
|
||||
pluginsPanelParam,
|
||||
type IntegrationParamValue,
|
||||
} from "@/lib/search-params"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
function subscribeViewportWidth(cb: () => void) {
|
||||
window.addEventListener("resize", cb)
|
||||
return () => window.removeEventListener("resize", cb)
|
||||
}
|
||||
|
||||
function getViewportWidth() {
|
||||
return window.innerWidth
|
||||
}
|
||||
|
||||
const GRADIENT_TOP_WIDTH_MAX = 1440
|
||||
|
||||
function gradientTopPositionForWidth(width: number) {
|
||||
const minW = 320
|
||||
const pctWide = 15
|
||||
const pctNarrow = 70
|
||||
const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width))
|
||||
const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW)
|
||||
const eased = t * t
|
||||
return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%`
|
||||
}
|
||||
|
||||
function ViewErrorFallback() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
|
|
@ -68,34 +100,28 @@ function ViewErrorFallback() {
|
|||
export default function NewPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { user, session } = useAuth()
|
||||
const {
|
||||
selectedProject,
|
||||
isNovaSpaces,
|
||||
novaContainerTags,
|
||||
selectedProjects,
|
||||
setSelectedProjects,
|
||||
} = useProject()
|
||||
const selectedProjectTag = selectedProjects[0]
|
||||
const isNovaContext =
|
||||
isNovaSpaces ||
|
||||
(selectedProjectTag !== undefined &&
|
||||
novaContainerTags.includes(selectedProjectTag))
|
||||
const { allProjects } = useContainerTags()
|
||||
const emptyStateSpaceName =
|
||||
!isNovaSpaces && selectedProjectTag
|
||||
? selectedProjectTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: (allProjects.find((p) => p.containerTag === selectedProjectTag)
|
||||
?.name ?? selectedProjectTag)
|
||||
: undefined
|
||||
|
||||
const handleSwitchToAllSpacesFromEmptyState = useCallback(() => {
|
||||
analytics.spaceSwitched({ space_id: "nova_spaces" })
|
||||
setSelectedProjects([])
|
||||
}, [setSelectedProjects])
|
||||
const { selectedProject, selectedProjects } = useProject()
|
||||
const selectedProjectTag = selectedProjects[0]
|
||||
const { allProjects } = useContainerTags()
|
||||
const dashboardSpaceLabel = useMemo(
|
||||
() =>
|
||||
getChatSpaceDisplayLabel({
|
||||
selectedProject,
|
||||
allProjects,
|
||||
}),
|
||||
[selectedProject, allProjects],
|
||||
)
|
||||
const emptyStateSpaceName = selectedProjectTag
|
||||
? selectedProjectTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ??
|
||||
selectedProjectTag)
|
||||
: undefined
|
||||
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const queryClient = useQueryClient()
|
||||
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
|
||||
|
||||
// Chrome extension auth: send session token via postMessage so the content script can store it
|
||||
useEffect(() => {
|
||||
|
|
@ -122,12 +148,14 @@ export default function NewPage() {
|
|||
"fullscreen",
|
||||
fullscreenParam,
|
||||
)
|
||||
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
|
||||
const [integrationFromUrl, setIntegration] = useQueryState(
|
||||
"integration",
|
||||
integrationParam,
|
||||
)
|
||||
const [pluginsPanelFromUrl] = useQueryState("plugins", pluginsPanelParam)
|
||||
const [pluginsPanelFromUrl, setPluginsPanel] = useQueryState(
|
||||
"plugins",
|
||||
pluginsPanelParam,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (integrationFromUrl || pluginsPanelFromUrl === true) {
|
||||
|
|
@ -138,6 +166,10 @@ export default function NewPage() {
|
|||
// Ephemeral local state (not worth URL-encoding)
|
||||
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
|
||||
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
|
||||
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
|
||||
const [queuedMessageSource, setQueuedMessageSource] = useState<
|
||||
"highlight" | "home"
|
||||
>("highlight")
|
||||
const [selectedDocument, setSelectedDocument] =
|
||||
useState<DocumentWithMemories | null>(null)
|
||||
|
||||
|
|
@ -177,6 +209,8 @@ export default function NewPage() {
|
|||
|
||||
const resetDraft = useQuickNoteDraftReset(selectedProject)
|
||||
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
|
||||
const quickNoteDraftRef = useRef(quickNoteDraft)
|
||||
quickNoteDraftRef.current = quickNoteDraft
|
||||
|
||||
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
|
||||
onClose: () => {
|
||||
|
|
@ -247,20 +281,31 @@ export default function NewPage() {
|
|||
const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
|
||||
const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours
|
||||
|
||||
const handleResetHighlights = useCallback(async () => {
|
||||
toast.success("Refreshing daily brief…")
|
||||
try {
|
||||
await caches.delete(HIGHLIGHTS_CACHE_NAME)
|
||||
} catch {}
|
||||
setHighlightsForceAt(Date.now())
|
||||
}, [])
|
||||
|
||||
const { data: highlightsData, isLoading: isLoadingHighlights } =
|
||||
useQuery<SpaceHighlightsResponse>({
|
||||
queryKey: ["space-highlights", selectedProject],
|
||||
queryKey: ["space-highlights", selectedProject, highlightsForceAt],
|
||||
queryFn: async (): Promise<SpaceHighlightsResponse> => {
|
||||
const spaceId = selectedProject || "sm_project_default"
|
||||
const forceRefresh = highlightsForceAt > 0
|
||||
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
|
||||
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cached = await cache.match(cacheKey)
|
||||
if (cached) {
|
||||
const age =
|
||||
Date.now() - Number(cached.headers.get("x-cached-at") || 0)
|
||||
if (age < HIGHLIGHTS_MAX_AGE) {
|
||||
return cached.json()
|
||||
if (!forceRefresh) {
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cached = await cache.match(cacheKey)
|
||||
if (cached) {
|
||||
const age =
|
||||
Date.now() - Number(cached.headers.get("x-cached-at") || 0)
|
||||
if (age < HIGHLIGHTS_MAX_AGE) {
|
||||
return cached.json()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +321,7 @@ export default function NewPage() {
|
|||
questionsCount: 4,
|
||||
includeHighlights: true,
|
||||
includeQuestions: true,
|
||||
forceRefresh,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
|
@ -286,13 +332,21 @@ export default function NewPage() {
|
|||
|
||||
const data = await response.json()
|
||||
|
||||
const cacheResponse = new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-cached-at": String(Date.now()),
|
||||
},
|
||||
})
|
||||
await cache.put(cacheKey, cacheResponse)
|
||||
// Update browser cache with fresh data (works for both normal and forced refresh)
|
||||
try {
|
||||
const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cacheResponse = new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-cached-at": String(Date.now()),
|
||||
},
|
||||
})
|
||||
await freshCache.put(cacheKey, cacheResponse)
|
||||
} catch {}
|
||||
|
||||
// Reset force flag after the forced fetch completes so future project-switches
|
||||
// use the normal cache path instead of always bypassing it.
|
||||
if (forceRefresh) setHighlightsForceAt(0)
|
||||
|
||||
return data
|
||||
},
|
||||
|
|
@ -300,6 +354,37 @@ export default function NewPage() {
|
|||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: memoryOfDay = null } = useQuery<MemoryOfDay | null>({
|
||||
queryKey: [
|
||||
"memory-of-day",
|
||||
user?.id,
|
||||
new Date().toISOString().slice(0, 10),
|
||||
],
|
||||
queryFn: async (): Promise<MemoryOfDay | null> => {
|
||||
const cacheKey = `memory-of-day:${user?.id}:${new Date().toISOString().slice(0, 10)}`
|
||||
try {
|
||||
const stored = localStorage.getItem(cacheKey)
|
||||
if (stored) return JSON.parse(stored) as MemoryOfDay
|
||||
} catch {}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as MemoryOfDay | null
|
||||
if (data) {
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(data))
|
||||
} catch {}
|
||||
}
|
||||
return data
|
||||
},
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
useHotkeys("c", () => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
|
|
@ -324,7 +409,7 @@ export default function NewPage() {
|
|||
const handleQuickNoteSave = useCallback(
|
||||
(content: string) => {
|
||||
if (content.trim()) {
|
||||
const hadPreviousContent = quickNoteDraft.trim().length > 0
|
||||
const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0
|
||||
noteMutation.mutate(
|
||||
{ content, project: selectedProject },
|
||||
{
|
||||
|
|
@ -339,7 +424,7 @@ export default function NewPage() {
|
|||
)
|
||||
}
|
||||
},
|
||||
[selectedProject, noteMutation, quickNoteDraft],
|
||||
[selectedProject, noteMutation],
|
||||
)
|
||||
|
||||
const handleFullScreenSave = useCallback(
|
||||
|
|
@ -375,11 +460,29 @@ export default function NewPage() {
|
|||
const handleHighlightsChat = useCallback(
|
||||
(seed: string) => {
|
||||
setQueuedChatSeed(seed)
|
||||
setIsChatOpen(true)
|
||||
setQueuedChatModel(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
[setIsChatOpen],
|
||||
[setViewMode],
|
||||
)
|
||||
|
||||
const handleHomeChatStart = useCallback(
|
||||
(message: string, model: ModelId) => {
|
||||
setQueuedChatSeed(message)
|
||||
setQueuedChatModel(model)
|
||||
setQueuedMessageSource("home")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
[setViewMode],
|
||||
)
|
||||
|
||||
const consumeQueuedChat = useCallback(() => {
|
||||
setQueuedChatSeed(null)
|
||||
setQueuedChatModel(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
}, [])
|
||||
|
||||
const handleHighlightsShowRelated = useCallback(
|
||||
(query: string) => {
|
||||
analytics.searchOpened({ source: "highlight_related" })
|
||||
|
|
@ -401,6 +504,11 @@ export default function NewPage() {
|
|||
[setViewMode, setIntegration],
|
||||
)
|
||||
|
||||
const handleOpenPlugins = useCallback(() => {
|
||||
void setViewMode("integrations")
|
||||
void setPluginsPanel(true)
|
||||
}, [setViewMode, setPluginsPanel])
|
||||
|
||||
const handleAddMemory = useCallback(
|
||||
(tab: "note" | "link") => {
|
||||
analytics.addDocumentModalOpened()
|
||||
|
|
@ -409,120 +517,193 @@ export default function NewPage() {
|
|||
[setAddDoc],
|
||||
)
|
||||
|
||||
const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile
|
||||
const viewportWidth = useSyncExternalStore(
|
||||
subscribeViewportWidth,
|
||||
getViewportWidth,
|
||||
() => GRADIENT_TOP_WIDTH_MAX,
|
||||
)
|
||||
const gradientTopPosition = gradientTopPositionForWidth(viewportWidth)
|
||||
|
||||
const isChatView = viewMode === "chat"
|
||||
const isGraphMode = viewMode === "graph" && !isMobile
|
||||
const isMemoriesDesktop = viewMode === "list" && !isMobile
|
||||
const isHomeDesktop = viewMode === "dashboard" && !isMobile
|
||||
const showNovaBackdrop = isGraphMode || isMemoriesDesktop
|
||||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-black min-h-screen",
|
||||
isGraphMode && "h-screen overflow-hidden",
|
||||
"relative flex min-h-dvh flex-col bg-black",
|
||||
isGraphMode && "h-dvh overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<AnimatedGradientBackground
|
||||
topPosition="15%"
|
||||
animateFromBottom={false}
|
||||
/>
|
||||
{isGraphMode && (
|
||||
<div
|
||||
id="graph-dotted-grid"
|
||||
className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
|
||||
/>
|
||||
{showNovaBackdrop && (
|
||||
<>
|
||||
<AnimatedGradientBackground
|
||||
animateFromBottom={isHomeDesktop}
|
||||
topPosition={gradientTopPosition}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-0",
|
||||
isHomeDesktop
|
||||
? "bg-[linear-gradient(to_top,rgb(0_0_0/0.88)_0%,rgb(0_0_0/0.52)_38%,rgb(0_0_0/0.42)_100%)]"
|
||||
: "bg-black/50",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
id="graph-dotted-grid"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]",
|
||||
isHomeDesktop && "opacity-70",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Header
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenChat={() => setIsChatOpen(true)}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
<main
|
||||
key={`main-container-${chatOpen}-${viewMode}`}
|
||||
className={cn(
|
||||
"z-10 relative",
|
||||
isGraphMode && "h-[calc(100vh-86px)] overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative z-10 flex flex-col md:flex-row h-full")}>
|
||||
<ErrorBoundary fallback={<ViewErrorFallback />}>
|
||||
{viewMode === "integrations" ? (
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
|
||||
<IntegrationsView />
|
||||
</div>
|
||||
) : viewMode === "graph" && !isMobile ? (
|
||||
<div className="flex-1">
|
||||
<GraphLayoutView isChatOpen={chatOpen} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
|
||||
<MemoriesGrid
|
||||
isChatOpen={chatOpen}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onEnterSelectionMode={handleEnterSelectionMode}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onClearSelection={handleClearSelection}
|
||||
onSelectAllVisible={handleSelectAllVisible}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
isBulkDeleting={bulkDeleteMutation.isPending}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
onMaximize: handleMaximize,
|
||||
isSaving: noteMutation.isPending,
|
||||
}}
|
||||
highlightsProps={{
|
||||
items: highlightsData?.highlights || [],
|
||||
onChat: handleHighlightsChat,
|
||||
onShowRelated: handleHighlightsShowRelated,
|
||||
isLoading: isLoadingHighlights,
|
||||
}}
|
||||
emptyStateProps={
|
||||
isNovaContext
|
||||
? {
|
||||
onAddMemory: handleAddMemory,
|
||||
onOpenIntegrations: handleOpenIntegrations,
|
||||
isAllSpaces: isNovaSpaces,
|
||||
spaceName: emptyStateSpaceName,
|
||||
onSwitchToAllSpaces: isNovaSpaces
|
||||
? undefined
|
||||
: handleSwitchToAllSpacesFromEmptyState,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={`main-container-${viewMode}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"relative z-10 flex min-h-0 flex-1 flex-col",
|
||||
(isGraphMode || isChatView) && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex min-h-0 flex-1 flex-col md:flex-row",
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ErrorBoundary>
|
||||
<ChatSidebar
|
||||
isChatOpen={chatOpen}
|
||||
setIsChatOpen={(open) => setIsChatOpen(open)}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
>
|
||||
<ErrorBoundary fallback={<ViewErrorFallback />}>
|
||||
{isChatView ? (
|
||||
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col md:self-stretch">
|
||||
<ChatSidebar
|
||||
layout="page"
|
||||
isChatOpen
|
||||
setIsChatOpen={(open) => {
|
||||
if (!open) void setViewMode("dashboard")
|
||||
}}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={consumeQueuedChat}
|
||||
queuedMessageSource={queuedMessageSource}
|
||||
initialSelectedModel={queuedChatModel}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
/>
|
||||
</div>
|
||||
) : viewMode === "integrations" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
|
||||
<IntegrationsView />
|
||||
</div>
|
||||
) : viewMode === "graph" && !isMobile ? (
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<GraphLayoutView />
|
||||
</div>
|
||||
) : viewMode === "list" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0",
|
||||
"pb-10 md:pb-12",
|
||||
)}
|
||||
>
|
||||
<MemoriesGrid
|
||||
isChatOpen={false}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onEnterSelectionMode={handleEnterSelectionMode}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onClearSelection={handleClearSelection}
|
||||
onSelectAllVisible={handleSelectAllVisible}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
isBulkDeleting={bulkDeleteMutation.isPending}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
onMaximize: handleMaximize,
|
||||
isSaving: noteMutation.isPending,
|
||||
}}
|
||||
highlightsProps={{
|
||||
items: highlightsData?.highlights || [],
|
||||
onChat: handleHighlightsChat,
|
||||
onShowRelated: handleHighlightsShowRelated,
|
||||
isLoading: isLoadingHighlights,
|
||||
}}
|
||||
emptyStateProps={{
|
||||
onAddMemory: handleAddMemory,
|
||||
onOpenIntegrations: handleOpenIntegrations,
|
||||
isAllSpaces: false,
|
||||
spaceName: emptyStateSpaceName,
|
||||
onSwitchToAllSpaces: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DashboardView
|
||||
spaceLabel={dashboardSpaceLabel}
|
||||
headerNotice={
|
||||
viewMode === "graph" && isMobile ? (
|
||||
<div
|
||||
id="graph-mobile-notice"
|
||||
className="rounded-lg border border-[#2261CA33] bg-[#041127] px-3 py-2.5 text-sm text-[#8B8B8B]"
|
||||
>
|
||||
<span className="font-medium text-white">
|
||||
Graph view is available on desktop.
|
||||
</span>{" "}
|
||||
Use a larger screen for the full graph, or keep
|
||||
working from this home view.
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
highlights={highlightsData?.highlights ?? []}
|
||||
isLoadingHighlights={isLoadingHighlights}
|
||||
onAddMemory={handleAddMemory}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
onOpenIntegrations={handleOpenIntegrations}
|
||||
onOpenPlugins={handleOpenPlugins}
|
||||
onNavigateToMemories={() => void setViewMode("list")}
|
||||
onNavigateToGraph={() => void setViewMode("graph")}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onHighlightsChat={handleHighlightsChat}
|
||||
onHighlightsShowRelated={handleHighlightsShowRelated}
|
||||
onResetHighlights={handleResetHighlights}
|
||||
memoryOfDay={memoryOfDay}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
|
||||
{isDashboardShell && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30 bg-gradient-to-t from-black via-black/80 to-transparent pt-12",
|
||||
isMobile ? "bottom-[4.5rem]" : "bottom-0",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<HomeChatComposer onStartChat={handleHomeChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isMobile && (
|
||||
<ChatSidebar
|
||||
isChatOpen={chatOpen}
|
||||
setIsChatOpen={(open) => setIsChatOpen(open)}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddDocumentModal
|
||||
|
|
@ -536,7 +717,6 @@ export default function NewPage() {
|
|||
if (!open) setSearchPrefill("")
|
||||
}}
|
||||
projectId={selectedProject}
|
||||
novaContainerTags={isNovaSpaces ? novaContainerTags : undefined}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { motion } from "motion/react"
|
|||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import Account from "@/components/settings/account"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
|
|
@ -16,7 +16,11 @@ import { useRouter } from "next/navigation"
|
|||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { Sun } from "lucide-react"
|
||||
import { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
|
||||
import { useResetOrganization } from "@/hooks/use-reset-organization"
|
||||
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
|
||||
|
||||
const TABS = ["account", "integrations", "connections", "support"] as const
|
||||
type SettingsTab = (typeof TABS)[number]
|
||||
|
|
@ -28,6 +32,14 @@ type NavItem = {
|
|||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
type DangerItem = {
|
||||
id: "logout" | "reset" | "delete"
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
color: "neutral" | "amber" | "red"
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
id: "account",
|
||||
|
|
@ -103,6 +115,51 @@ const NAV_ITEMS: NavItem[] = [
|
|||
},
|
||||
]
|
||||
|
||||
const DANGER_ITEMS: DangerItem[] = [
|
||||
{
|
||||
id: "logout",
|
||||
label: "Log out",
|
||||
description: "Sign out of your account on this device",
|
||||
icon: <LogOut className="size-5" />,
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "reset",
|
||||
label: "Reset data",
|
||||
description: "Erase all memories, connections and spaces",
|
||||
icon: <RotateCcw className="size-5" />,
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete account",
|
||||
description: "Permanently delete your account and all data",
|
||||
icon: <Trash2 className="size-5" />,
|
||||
color: "red",
|
||||
},
|
||||
]
|
||||
|
||||
const DANGER_COLORS: Record<
|
||||
DangerItem["color"],
|
||||
{ idle: string; hover: string; icon: string }
|
||||
> = {
|
||||
neutral: {
|
||||
idle: "text-white/50",
|
||||
hover: "hover:text-white",
|
||||
icon: "text-white/40",
|
||||
},
|
||||
amber: {
|
||||
idle: "text-[#7A6030]",
|
||||
hover: "hover:text-[#C7991B]",
|
||||
icon: "text-[#7A6030]",
|
||||
},
|
||||
red: {
|
||||
idle: "text-[#6B2A2A]",
|
||||
hover: "hover:text-[#C73B1B]",
|
||||
icon: "text-[#6B2A2A]",
|
||||
},
|
||||
}
|
||||
|
||||
function parseHashToTab(hash: string): SettingsTab {
|
||||
const cleaned = hash.replace("#", "").toLowerCase()
|
||||
return TABS.includes(cleaned as SettingsTab)
|
||||
|
|
@ -133,13 +190,40 @@ export function UserSupermemory({ name }: { name: string }) {
|
|||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const { user, org } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
|
||||
const hasInitialized = useRef(false)
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
|
||||
const [resetConfirmation, setResetConfirmation] = useState("")
|
||||
const resetOrganization = useResetOrganization()
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
|
||||
const deleteUserAccount = useDeleteUserAccount()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut()
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteEmailConfirm !== user?.email) return
|
||||
deleteUserAccount.mutate(
|
||||
{ confirmation: deleteEmailConfirm },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteEmailConfirm("")
|
||||
router.push("/login")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return
|
||||
hasInitialized.current = true
|
||||
|
|
@ -277,6 +361,55 @@ export default function SettingsPage() {
|
|||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Divider */}
|
||||
{!isMobile && <div className="my-1 h-px bg-[#0F1621]" />}
|
||||
|
||||
{DANGER_ITEMS.map((item) => {
|
||||
const colors = DANGER_COLORS[item.color]
|
||||
const handleClick = () => {
|
||||
if (item.id === "logout") handleLogout()
|
||||
else if (item.id === "reset") setIsResetDialogOpen(true)
|
||||
else if (item.id === "delete") setIsDeleteDialogOpen(true)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"rounded-xl transition-colors flex items-start gap-3 shrink-0 group",
|
||||
isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
|
||||
"hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
colors.idle,
|
||||
colors.hover,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!isMobile && "mt-0.5",
|
||||
colors.icon,
|
||||
`group-hover:${colors.hover.replace("hover:", "")}`,
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<span className="font-medium whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-sm opacity-60">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-4 md:overflow-y-auto md:max-w-2xl [scrollbar-gutter:stable] md:pr-[17px]">
|
||||
|
|
@ -303,6 +436,169 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Reset data dialog */}
|
||||
{(() => {
|
||||
const confirmText = org?.name || user?.name || ""
|
||||
return (
|
||||
<Dialog
|
||||
open={isResetDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsResetDialogOpen(open)
|
||||
if (!open) setResetConfirmation("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Reset all data?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
This permanently removes:
|
||||
</p>
|
||||
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
|
||||
<li>All documents and memories</li>
|
||||
<li>All connections (Google Drive, Notion, etc.)</li>
|
||||
<li>All custom spaces (default space stays)</li>
|
||||
<li>Organization settings and filters</li>
|
||||
</ul>
|
||||
<p className="text-sm text-[#8B8B8B] mt-1">
|
||||
Your account and billing plan stay intact.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
{confirmText || "your name"}
|
||||
</strong>{" "}
|
||||
to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetConfirmation}
|
||||
onChange={(e) => setResetConfirmation(e.target.value)}
|
||||
placeholder={confirmText || "Your name"}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!confirmText ||
|
||||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
resetOrganization.mutate(
|
||||
{ confirmation: confirmText },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsResetDialogOpen(false)
|
||||
setResetConfirmation("")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{resetOrganization.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-[15px]" />
|
||||
)}
|
||||
{resetOrganization.isPending
|
||||
? "Resetting…"
|
||||
: "Reset organization"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteDialogOpen(open)
|
||||
if (!open) setDeleteEmailConfirm("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Delete your account?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Permanently deletes all your data and cancels any active
|
||||
subscriptions.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type your email{" "}
|
||||
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
|
||||
confirm:
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={deleteEmailConfirm}
|
||||
onChange={(e) => setDeleteEmailConfirm(e.target.value)}
|
||||
placeholder={user?.email ?? "your@email.com"}
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{deleteUserAccount.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-[15px]" />
|
||||
)}
|
||||
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,55 +8,51 @@ export function AnimatedGradientBackground({
|
|||
animateFromBottom?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
style={{ top: animateFromBottom ? undefined : topPosition }}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: animateFromBottom ? 0 : [1, 0, 1],
|
||||
top: animateFromBottom ? "0%" : topPosition,
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: animateFromBottom
|
||||
? { duration: 2, ease: "easeOut" }
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
animateFromBottom
|
||||
? { opacity: 1 }
|
||||
: { opacity: [1, 0, 1], top: topPosition }
|
||||
}
|
||||
transition={
|
||||
animateFromBottom
|
||||
? { duration: 1, ease: "easeOut" }
|
||||
: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
top: animateFromBottom
|
||||
? { duration: 0.75, ease: "easeOut" }
|
||||
: undefined,
|
||||
}}
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
style={{ top: animateFromBottom ? undefined : topPosition }}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: animateFromBottom ? 0 : [0, 1, 0],
|
||||
top: animateFromBottom ? "0%" : topPosition,
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: animateFromBottom
|
||||
? { duration: 2, ease: "easeOut" }
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
animateFromBottom
|
||||
? { opacity: 1 }
|
||||
: { opacity: [0, 1, 0], top: topPosition }
|
||||
}
|
||||
transition={
|
||||
animateFromBottom
|
||||
? { duration: 1, ease: "easeOut", delay: 0.2 }
|
||||
: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
top: animateFromBottom
|
||||
? { duration: 0.75, ease: "easeOut" }
|
||||
: undefined,
|
||||
}}
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
|
||||
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-bottom bg-no-repeat"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
|
|
|
|||
53
apps/web/components/chat/chat-graph-context-rail.tsx
Normal file
53
apps/web/components/chat/chat-graph-context-rail.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { MemoryGraph } from "@/components/memory-graph"
|
||||
import { useProject } from "@/stores"
|
||||
import { extractHighlightDocumentIdsFromMessages } from "@/lib/chat-highlight-documents"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
||||
export function ChatGraphContextRail({
|
||||
messages,
|
||||
className,
|
||||
}: {
|
||||
messages: UIMessage[]
|
||||
className?: string
|
||||
}) {
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const highlightIds = useMemo(
|
||||
() => extractHighlightDocumentIdsFromMessages(messages),
|
||||
[messages],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
id="chat-graph-context-rail"
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col bg-black",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute top-3 left-4 z-20">
|
||||
<p className="text-xs font-medium text-white/70">Memory map</p>
|
||||
<p className="mt-0.5 max-w-[14rem] text-[10px] leading-snug text-white/35">
|
||||
{highlightIds.length > 0
|
||||
? `${highlightIds.length} memor${highlightIds.length === 1 ? "y" : "ies"} used by Nova`
|
||||
: "Memories used by Nova will be highlighted here"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-24 bg-gradient-to-r from-transparent to-black" />
|
||||
<div className="min-h-0 flex-1 pt-10">
|
||||
<MemoryGraph
|
||||
containerTags={effectiveContainerTags}
|
||||
variant="consumer"
|
||||
highlightDocumentIds={highlightIds}
|
||||
highlightsVisible={highlightIds.length > 0}
|
||||
maxNodes={160}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
apps/web/components/chat/home-chat-composer.tsx
Normal file
82
apps/web/components/chat/home-chat-composer.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import ChatInput from "./input"
|
||||
import ChatModelSelector from "./model-selector"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
import { useProject } from "@/stores"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
|
||||
export function HomeChatComposer({
|
||||
onStartChat,
|
||||
className,
|
||||
}: {
|
||||
onStartChat: (message: string, model: ModelId) => void
|
||||
className?: string
|
||||
}) {
|
||||
const [input, setInput] = useState("")
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const { selectedProject } = useProject()
|
||||
const { allProjects } = useContainerTags()
|
||||
const chatSpaceLabel = useMemo(
|
||||
() =>
|
||||
getChatSpaceDisplayLabel({
|
||||
selectedProject,
|
||||
allProjects,
|
||||
}),
|
||||
[selectedProject, allProjects],
|
||||
)
|
||||
|
||||
const send = useCallback(() => {
|
||||
const t = input.trim()
|
||||
if (!t) return
|
||||
onStartChat(t, selectedModel)
|
||||
setInput("")
|
||||
}, [input, onStartChat, selectedModel])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-3 md:pb-4">
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onSend={send}
|
||||
onStop={() => {}}
|
||||
onKeyDown={handleKeyDown}
|
||||
isResponding={false}
|
||||
showStatusStrip={false}
|
||||
stackedToolbar={
|
||||
<>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
minimal
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex max-w-[min(160px,35vw)] min-w-0 shrink items-center rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
title={chatSpaceLabel}
|
||||
>
|
||||
<span className="truncate text-sm text-[#FAFAFA]">
|
||||
{chatSpaceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@ import { ChevronUpIcon } from "lucide-react"
|
|||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useRef, useState } from "react"
|
||||
import { type ReactNode, useRef, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { SendButton, StopButton } from "./actions"
|
||||
|
||||
|
|
@ -18,6 +18,10 @@ interface ChatInputProps {
|
|||
activeStatus?: string
|
||||
chainOfThoughtComponent?: React.ReactNode
|
||||
onExpandedChange?: (expanded: boolean) => void
|
||||
/** Model + space controls on one row with send; textarea full-width above */
|
||||
stackedToolbar?: ReactNode
|
||||
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
|
||||
showStatusStrip?: boolean
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
|
|
@ -30,6 +34,8 @@ export default function ChatInput({
|
|||
activeStatus,
|
||||
chainOfThoughtComponent,
|
||||
onExpandedChange,
|
||||
stackedToolbar,
|
||||
showStatusStrip = true,
|
||||
}: ChatInputProps) {
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
|
@ -52,82 +58,122 @@ export default function ChatInput({
|
|||
<motion.div
|
||||
className={cn("relative z-20!")}
|
||||
animate={{
|
||||
padding: isExpanded ? "16px" : "0",
|
||||
margin: isExpanded ? "0" : "16px",
|
||||
borderRadius: isExpanded ? "0 0 12px 12px" : "12px",
|
||||
backgroundColor: isExpanded ? "#000B1B" : "#01173C",
|
||||
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
|
||||
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
|
||||
borderRadius: showStatusStrip
|
||||
? isExpanded
|
||||
? "0 0 12px 12px"
|
||||
: "12px"
|
||||
: "0",
|
||||
backgroundColor: showStatusStrip
|
||||
? isExpanded
|
||||
? "#000B1B"
|
||||
: "#01173C"
|
||||
: "transparent",
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
|
||||
isExpanded
|
||||
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isExpanded ? 50 : 0,
|
||||
}}
|
||||
>
|
||||
{chainOfThoughtComponent}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
|
||||
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => {
|
||||
const newExpanded = !isExpanded
|
||||
setIsExpanded(newExpanded)
|
||||
onExpandedChange?.(newExpanded)
|
||||
}}
|
||||
disabled={!chainOfThoughtComponent}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<NovaOrb size={24} className="blur-[1px]! z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
</div>
|
||||
{chainOfThoughtComponent && (
|
||||
<ChevronUpIcon
|
||||
{showStatusStrip ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"size-4 text-[#525D6E] transition-transform duration-300",
|
||||
isExpanded && "rotate-180",
|
||||
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
|
||||
isExpanded
|
||||
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isExpanded ? 50 : 0,
|
||||
}}
|
||||
>
|
||||
{chainOfThoughtComponent}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
|
||||
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => {
|
||||
const newExpanded = !isExpanded
|
||||
setIsExpanded(newExpanded)
|
||||
onExpandedChange?.(newExpanded)
|
||||
}}
|
||||
disabled={!chainOfThoughtComponent}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<NovaOrb size={24} className="blur-[1px]! z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
</div>
|
||||
{chainOfThoughtComponent && (
|
||||
<ChevronUpIcon
|
||||
className={cn(
|
||||
"size-4 text-[#525D6E] transition-transform duration-300",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{stackedToolbar ? (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-[#52596633] bg-[#070E1B] p-2 transition-all duration-200 focus-within:outline-1 focus-within:outline-[#525D6EB2]">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask your supermemory..."
|
||||
className="w-full resize-none overflow-y-auto bg-transparent p-2 transition-all duration-200 placeholder:text-[#525D6E] focus:outline-none"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 bg-[#070E1B] rounded-xl p-2 border-[#52596633] border focus-within:outline-[#525D6EB2] focus-within:outline-1 transition-all duration-200",
|
||||
isMultiline && "flex-col",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask your supermemory..."
|
||||
className="bg-transparent w-full p-2 placeholder:text-[#525D6E] focus:outline-none resize-none overflow-y-auto transition-all duration-200"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
<div className="transition-all duration-200">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{stackedToolbar}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-xl border border-[#52596633] bg-[#070E1B] p-2 transition-all duration-200 focus-within:outline-1 focus-within:outline-[#525D6EB2]",
|
||||
isMultiline && "flex-col",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask your supermemory..."
|
||||
className="w-full resize-none overflow-y-auto bg-transparent p-2 transition-all duration-200 placeholder:text-[#525D6E] focus:outline-none"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
<div className="transition-all duration-200">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@ import { analytics } from "@/lib/analytics"
|
|||
interface ChatModelSelectorProps {
|
||||
selectedModel?: ModelId
|
||||
onModelChange?: (model: ModelId) => void
|
||||
/** Compact pill matching inline send control (black + #161F2C border, rounded-full) */
|
||||
minimal?: boolean
|
||||
}
|
||||
|
||||
export default function ChatModelSelector({
|
||||
selectedModel: selectedModelProp,
|
||||
onModelChange,
|
||||
minimal = false,
|
||||
}: ChatModelSelectorProps = {}) {
|
||||
const [internalModel, setInternalModel] =
|
||||
useState<ModelId>("claude-sonnet-4.6")
|
||||
|
|
@ -34,25 +37,44 @@ export default function ChatModelSelector({
|
|||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const trigger = minimal ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 text-sm transition-colors hover:bg-[#161F2C]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<p className="min-w-0 truncate text-left text-[#FAFAFA]">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-[#525D6E]">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 text-[#525D6E]" />
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"h-10! max-w-[min(100%,220px)] shrink gap-1 rounded-full border-[#73737333] bg-[#0D121A] text-base",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<p className="truncate text-sm">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-[#737373]">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full text-base gap-1 h-10! border-[#73737333] bg-[#0D121A]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-[#737373]">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
<div className="relative flex min-w-0 shrink items-center gap-2">
|
||||
{trigger}
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
|
|
@ -64,7 +86,7 @@ export default function ChatModelSelector({
|
|||
aria-label="Close model selector"
|
||||
/>
|
||||
|
||||
<div className="absolute top-full left-0 mt-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="p-2 space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
|
|
|
|||
869
apps/web/components/dashboard-view.tsx
Normal file
869
apps/web/components/dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,869 @@
|
|||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useMemo, useState, useEffect } from "react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Lightbulb,
|
||||
Link2,
|
||||
RotateCcw,
|
||||
SearchIcon,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import type { z } from "zod"
|
||||
import { CHROME_EXTENSION_URL, RAYCAST_EXTENSION_URL } from "@lib/constants"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useProject } from "@/stores"
|
||||
import {
|
||||
HighlightsCard,
|
||||
type HighlightItem,
|
||||
} from "@/components/highlights-card"
|
||||
import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
|
||||
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { IntegrationParamValue } from "@/lib/search-params"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import {
|
||||
usePersonalization,
|
||||
type Profession,
|
||||
} from "@/hooks/use-personalization"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
const fadeUp = {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
|
||||
},
|
||||
}
|
||||
|
||||
const CYCLE_INTERVAL_MS = 8_000
|
||||
|
||||
const PLUGIN_TAGLINES: Record<Profession, Partial<Record<string, string>>> = {
|
||||
developer: {
|
||||
mcp: "Ask Claude about your saved docs and specs from any IDE",
|
||||
chrome: "Save Stack Overflow answers, docs and repos in one click",
|
||||
raycast: "Search your tech docs and snippets without context switching",
|
||||
notion: "Make your engineering specs and RFCs instantly findable",
|
||||
"google-drive": "Query your design docs, code specs and shared files",
|
||||
},
|
||||
research: {
|
||||
mcp: "Ask Claude across your entire reading list and notes",
|
||||
chrome: "Clip papers and articles directly while you read",
|
||||
raycast: "Pull up citations and notes without breaking your focus",
|
||||
notion: "Keep your literature review alongside your saved papers",
|
||||
"google-drive": "Index datasets, papers and research docs in one place",
|
||||
},
|
||||
finance: {
|
||||
mcp: "Ask Claude about your saved thesis notes and research",
|
||||
chrome: "Save earnings calls, market reports and articles instantly",
|
||||
raycast: "Surface your research and models without breaking flow",
|
||||
notion: "Make your investment thesis and portfolio notes searchable",
|
||||
"google-drive": "Query your financial models, decks and reports instantly",
|
||||
},
|
||||
design: {
|
||||
mcp: "Ask Claude about your saved briefs and design research",
|
||||
chrome: "Save inspiration and references as you browse",
|
||||
raycast: "Find your saved references and briefs from anywhere",
|
||||
notion: "Make your design system docs and briefs searchable",
|
||||
"google-drive": "Index your briefs, feedback docs and creative assets",
|
||||
},
|
||||
legal: {
|
||||
mcp: "Ask Claude across your saved contracts and case notes",
|
||||
chrome: "Clip case law, statutes and legal articles in one click",
|
||||
raycast: "Surface contracts and precedents without leaving your workflow",
|
||||
notion: "Keep memos, briefs and case notes instantly searchable",
|
||||
"google-drive": "Index contracts, filings and legal research docs",
|
||||
},
|
||||
marketing: {
|
||||
mcp: "Ask Claude across your saved campaigns and research",
|
||||
chrome: "Save competitor pages and inspiration as you browse",
|
||||
raycast: "Pull up campaign briefs and notes without context switching",
|
||||
notion: "Make your content calendar and campaign briefs searchable",
|
||||
"google-drive": "Query campaign reports, briefs and creative assets",
|
||||
},
|
||||
medical: {
|
||||
mcp: "Ask Claude across your medical literature and clinical notes",
|
||||
chrome: "Save studies and clinical resources while you read",
|
||||
raycast: "Surface guidelines and notes without breaking your flow",
|
||||
notion: "Keep clinical notes and research in one searchable place",
|
||||
"google-drive": "Index guidelines, studies and patient education docs",
|
||||
},
|
||||
default: {
|
||||
mcp: "Ask Claude using your own saved knowledge",
|
||||
chrome: "Save any page in one click while you browse",
|
||||
raycast: "Search your memory without leaving the keyboard",
|
||||
notion: "Make every note and doc instantly searchable",
|
||||
"google-drive": "Ask questions across your docs, slides and sheets",
|
||||
},
|
||||
}
|
||||
|
||||
export type MemoryOfDay = {
|
||||
memories: string[]
|
||||
timeLabel: string
|
||||
sourceDocumentId: string | null
|
||||
}
|
||||
|
||||
const TIPS: Record<Profession, string[]> = {
|
||||
developer: [
|
||||
"Use ⌘K to search code snippets and docs by intent, not just keywords",
|
||||
"Connect Claude MCP to query your saved knowledge from any IDE",
|
||||
"Save GitHub repos and READMEs — ask questions across all of them",
|
||||
"Use 'Related' on highlights to find connected technical concepts",
|
||||
],
|
||||
research: [
|
||||
"Save papers and ask questions across your entire reading list",
|
||||
"Use 'Related' on highlights to surface connected research",
|
||||
"Connect Notion to index your notes alongside your papers",
|
||||
"Semantic search means you can ask questions, not just search titles",
|
||||
],
|
||||
finance: [
|
||||
"Save articles and ask follow-up questions across your research",
|
||||
"Connect Notion to keep your investment thesis searchable",
|
||||
"Use ⌘K to find specific data points across all your saves",
|
||||
"Daily Brief surfaces connections you may have missed",
|
||||
],
|
||||
design: [
|
||||
"Save inspiration and search by concept — 'minimalist UI' finds the right ones",
|
||||
"Use ⌘K to rediscover references by meaning, not filename",
|
||||
"Connect Notion to make your briefs and moodboards searchable",
|
||||
"Chrome extension saves any page in one click while you browse",
|
||||
],
|
||||
legal: [
|
||||
"Save documents and search across them semantically in seconds",
|
||||
"Connect Notion to index your memos and case notes together",
|
||||
"Use Daily Brief to resurface relevant precedents automatically",
|
||||
"Google Drive sync keeps your contracts indexed and queryable",
|
||||
],
|
||||
marketing: [
|
||||
"Save campaigns and resources — ask what worked across all of them",
|
||||
"Chrome extension captures competitor pages in one click",
|
||||
"Use 'Related' to find similar campaigns in your archive",
|
||||
"Connect Notion to make your campaign briefs instantly searchable",
|
||||
],
|
||||
medical: [
|
||||
"Save studies and query across your entire reading list",
|
||||
"Connect Notion to keep clinical notes alongside research",
|
||||
"Use ⌘K to find specific findings across hundreds of papers",
|
||||
"Daily Brief surfaces relevant research from your saves automatically",
|
||||
],
|
||||
default: [
|
||||
"Use ⌘K to search by meaning — ask questions, not just keywords",
|
||||
"Daily Brief surfaces insights from your saves each morning",
|
||||
"Chrome extension saves any page in one click while you browse",
|
||||
"Connect integrations to make all your knowledge searchable here",
|
||||
],
|
||||
}
|
||||
|
||||
const PROFESSION_PLUGIN_ORDER: Record<Profession, string[]> = {
|
||||
developer: ["mcp", "chrome", "raycast", "notion", "google-drive"],
|
||||
research: ["notion", "chrome", "google-drive", "mcp", "raycast"],
|
||||
finance: ["notion", "google-drive", "chrome", "mcp", "raycast"],
|
||||
design: ["chrome", "notion", "raycast", "mcp", "google-drive"],
|
||||
legal: ["notion", "google-drive", "chrome", "mcp", "raycast"],
|
||||
marketing: ["chrome", "notion", "raycast", "google-drive", "mcp"],
|
||||
medical: ["notion", "chrome", "google-drive", "mcp", "raycast"],
|
||||
default: ["mcp", "chrome", "notion", "raycast", "google-drive"],
|
||||
}
|
||||
|
||||
const PROFESSION_LABELS: {
|
||||
value: Exclude<Profession, "default">
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "developer", label: "Developer" },
|
||||
{ value: "research", label: "Researcher" },
|
||||
{ value: "finance", label: "Finance" },
|
||||
{ value: "design", label: "Designer" },
|
||||
{ value: "legal", label: "Legal" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "medical", label: "Medical" },
|
||||
]
|
||||
|
||||
// Static plugin metadata — shared between PluginPromoCard and RecommendedPluginsCard
|
||||
const PLUGIN_STATIC = [
|
||||
{
|
||||
id: "mcp",
|
||||
name: "Claude MCP",
|
||||
Icon: MCPIcon,
|
||||
accentColor: "#D4A853",
|
||||
tagline: "Ask Claude from your own saved knowledge, not just training data",
|
||||
cta: "Set up",
|
||||
},
|
||||
{
|
||||
id: "chrome",
|
||||
name: "Chrome Extension",
|
||||
Icon: ChromeIcon,
|
||||
accentColor: "#4BA0FA",
|
||||
tagline: "Save any page in one click — findable by meaning, forever",
|
||||
cta: "Install",
|
||||
},
|
||||
{
|
||||
id: "raycast",
|
||||
name: "Raycast",
|
||||
Icon: RaycastIcon,
|
||||
accentColor: "#FF6363",
|
||||
tagline: "Search your entire memory without leaving your keyboard",
|
||||
cta: "Install",
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Notion",
|
||||
Icon: Notion,
|
||||
accentColor: "#FAFAFA",
|
||||
tagline: "Sync your workspace and make every note searchable everywhere",
|
||||
cta: "Connect",
|
||||
},
|
||||
{
|
||||
id: "google-drive",
|
||||
name: "Google Drive",
|
||||
Icon: GoogleDrive,
|
||||
accentColor: "#4BA0FA",
|
||||
tagline:
|
||||
"Index your Drive files — ask questions across docs, slides, sheets",
|
||||
cta: "Connect",
|
||||
},
|
||||
] as const
|
||||
|
||||
function RecommendedPluginsCard({
|
||||
profession,
|
||||
setProfession,
|
||||
connectedProviders,
|
||||
hasMcp,
|
||||
onOpenPlugins,
|
||||
onOpenIntegrations,
|
||||
}: {
|
||||
profession: Profession
|
||||
setProfession: (p: Profession) => void
|
||||
connectedProviders: Set<string>
|
||||
hasMcp: boolean
|
||||
onOpenPlugins: () => void
|
||||
onOpenIntegrations: (integration?: IntegrationParamValue) => void
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
useEffect(() => {
|
||||
setIsEditing(false)
|
||||
}, [])
|
||||
const showPicker = profession === "default" || isEditing
|
||||
const allPlugins = useMemo(() => {
|
||||
const onClicks: Record<string, () => void> = {
|
||||
mcp: onOpenPlugins,
|
||||
chrome: () =>
|
||||
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
|
||||
raycast: () =>
|
||||
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
|
||||
notion: () => onOpenIntegrations("notion"),
|
||||
"google-drive": () => onOpenIntegrations("google-drive"),
|
||||
}
|
||||
const connected: Record<string, boolean> = {
|
||||
mcp: hasMcp,
|
||||
chrome: false,
|
||||
raycast: false,
|
||||
notion: connectedProviders.has("notion"),
|
||||
"google-drive": connectedProviders.has("google-drive"),
|
||||
}
|
||||
return PLUGIN_STATIC.map((p) => ({
|
||||
...p,
|
||||
connected: connected[p.id] ?? false,
|
||||
onClick: onClicks[p.id]!,
|
||||
}))
|
||||
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
|
||||
|
||||
const order = PROFESSION_PLUGIN_ORDER[profession]
|
||||
const suggestions = useMemo(
|
||||
() =>
|
||||
order
|
||||
.map((id) => allPlugins.find((p) => p.id === id))
|
||||
.filter((p): p is NonNullable<typeof p> => !!p && !p.connected)
|
||||
.slice(0, 3),
|
||||
[order, allPlugins],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#050709] border border-[#0F1621] rounded-xl px-3 py-2 flex flex-col gap-1",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{showPicker ? (
|
||||
<div className="px-1 py-2 flex flex-col gap-2.5">
|
||||
<p className="text-[11px] text-[#737373]">
|
||||
{isEditing ? "Change your field:" : "What's your field?"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PROFESSION_LABELS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProfession(value)
|
||||
setIsEditing(false)
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all cursor-pointer",
|
||||
profession === value
|
||||
? "border-[#3374FF]/40 bg-[#3374FF]/10 text-[#6BB0FF]"
|
||||
: "border-[#161F2C] text-[#525D6E] hover:border-[#3374FF]/25 hover:text-[#4BA0FA]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="text-[10px] text-[#3A4455] hover:text-[#525D6E] transition-colors text-left cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<p className="text-[11px] text-[#525D6E] text-center">
|
||||
You're all set ✓
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul>
|
||||
{suggestions.map((plugin) => (
|
||||
<li key={plugin.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={plugin.onClick}
|
||||
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-2 hover:bg-[#0D121A] transition-colors cursor-pointer"
|
||||
>
|
||||
<plugin.Icon className="size-4 shrink-0 text-[#525D6E]" />
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-[12px] text-[#737373] group-hover:text-white transition-colors leading-tight">
|
||||
{plugin.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#525D6E] leading-tight mt-0.5">
|
||||
{PLUGIN_TAGLINES[profession][plugin.id] ?? plugin.tagline}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium text-[#3374FF] group-hover:text-[#6BB0FF] transition-colors">
|
||||
{plugin.cta} →
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-left px-2 pb-1 text-[10px] text-[#3A4455] hover:text-[#525D6E] transition-colors cursor-pointer"
|
||||
>
|
||||
Not a{" "}
|
||||
{PROFESSION_LABELS.find(
|
||||
(p) => p.value === profession,
|
||||
)?.label.toLowerCase()}
|
||||
? Change →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryOfDayCard({ data }: { data: MemoryOfDay }) {
|
||||
const router = useRouter()
|
||||
|
||||
const memory = data.memories[0]
|
||||
|
||||
if (!memory) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/?view=list")}
|
||||
className={cn(
|
||||
"group w-full h-full text-left bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col justify-between hover:border-[rgba(255,255,255,0.10)] transition-colors cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<span className="self-start text-[9px] font-semibold tracking-[0.12em] uppercase text-[#4BA0FA] bg-[#4BA0FA]/10 rounded-full px-2 py-0.5">
|
||||
{data.timeLabel}
|
||||
</span>
|
||||
<p className="text-[12px] text-[#8B9DB5] leading-relaxed line-clamp-4">
|
||||
{memory}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-[#2A3A50] group-hover:text-[#4A6A80] transition-colors">
|
||||
View memories →
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginPromoCard({
|
||||
hasMcp,
|
||||
connectedProviders,
|
||||
onOpenPlugins,
|
||||
onOpenIntegrations,
|
||||
}: {
|
||||
hasMcp: boolean
|
||||
connectedProviders: Set<string>
|
||||
onOpenPlugins: () => void
|
||||
onOpenIntegrations: (integration?: IntegrationParamValue) => void
|
||||
}) {
|
||||
const plugins = useMemo(() => {
|
||||
const onClicks: Record<string, () => void> = {
|
||||
mcp: onOpenPlugins,
|
||||
chrome: () =>
|
||||
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
|
||||
raycast: () =>
|
||||
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
|
||||
notion: () => onOpenIntegrations("notion"),
|
||||
"google-drive": () => onOpenIntegrations("google-drive"),
|
||||
}
|
||||
const connected: Record<string, boolean> = {
|
||||
mcp: hasMcp,
|
||||
chrome: false,
|
||||
raycast: false,
|
||||
notion: connectedProviders.has("notion"),
|
||||
"google-drive": connectedProviders.has("google-drive"),
|
||||
}
|
||||
return PLUGIN_STATIC.map((p) => ({
|
||||
...p,
|
||||
connected: connected[p.id] ?? false,
|
||||
onClick: onClicks[p.id]!,
|
||||
})).filter((p) => !p.connected)
|
||||
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
|
||||
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
// Reset when the plugins list changes length (e.g., user connects one)
|
||||
useEffect(() => {
|
||||
setIndex(0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (plugins.length <= 1) return
|
||||
const id = setInterval(
|
||||
() => setIndex((i) => (i + 1) % plugins.length),
|
||||
CYCLE_INTERVAL_MS,
|
||||
)
|
||||
return () => clearInterval(id)
|
||||
}, [plugins.length])
|
||||
|
||||
const safeIndex = Math.min(index, plugins.length - 1)
|
||||
const plugin = plugins[safeIndex]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col justify-between gap-3 h-full",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{plugin ? (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={plugin.id}
|
||||
initial={{ opacity: 0, x: 16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col gap-3 flex-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<plugin.Icon className="size-7 shrink-0" />
|
||||
{plugins.length > 1 && (
|
||||
<div className="flex gap-1">
|
||||
{plugins.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
className={cn(
|
||||
"rounded-full transition-all cursor-pointer",
|
||||
i === safeIndex
|
||||
? "w-3 h-1 bg-[#4BA0FA]"
|
||||
: "size-1 bg-[#2A3040] hover:bg-[#3A4455]",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] font-semibold text-[#FAFAFA] leading-tight">
|
||||
{plugin.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-[#525D6E] leading-normal">
|
||||
{plugin.tagline}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={plugin.onClick}
|
||||
className="w-full bg-[#0D121A] rounded-lg px-3 py-1.5 text-[11px] font-medium text-[#4BA0FA] hover:text-white hover:bg-[#141C28] transition-colors cursor-pointer text-left flex items-center justify-between group"
|
||||
style={{ boxShadow: "inset 1px 1px 2px rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<span>{plugin.cta}</span>
|
||||
<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-1.5 text-center">
|
||||
<Terminal className="size-4 text-[#3A4455]" />
|
||||
<p className="text-[10px] text-[#3A4455]">
|
||||
All integrations connected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardView({
|
||||
spaceLabel,
|
||||
headerNotice,
|
||||
highlights,
|
||||
isLoadingHighlights,
|
||||
onAddMemory,
|
||||
onOpenSearch,
|
||||
onOpenIntegrations,
|
||||
onOpenPlugins,
|
||||
onNavigateToMemories,
|
||||
onNavigateToGraph,
|
||||
onOpenDocument,
|
||||
onHighlightsChat,
|
||||
onHighlightsShowRelated,
|
||||
onResetHighlights,
|
||||
memoryOfDay,
|
||||
}: {
|
||||
spaceLabel: string
|
||||
headerNotice?: ReactNode
|
||||
highlights: HighlightItem[]
|
||||
isLoadingHighlights: boolean
|
||||
onAddMemory: (tab: "note" | "link") => void
|
||||
onOpenSearch: () => void
|
||||
onOpenIntegrations: (integration?: IntegrationParamValue) => void
|
||||
onOpenPlugins: () => void
|
||||
onNavigateToMemories: () => void
|
||||
onNavigateToGraph: () => void
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
onHighlightsChat: (seed: string) => void
|
||||
onHighlightsShowRelated: (query: string) => void
|
||||
onResetHighlights: () => void
|
||||
memoryOfDay: MemoryOfDay | null
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const _router = useRouter()
|
||||
const { data: recentsData } = useQuery({
|
||||
queryKey: ["dashboard-recents", effectiveContainerTags],
|
||||
queryFn: async (): Promise<DocumentsResponse> => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: 1,
|
||||
limit: 5,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: effectiveContainerTags,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) throw new Error(response.error?.message)
|
||||
return response.data as DocumentsResponse
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const { data: connections = [] } = useQuery({
|
||||
queryKey: ["connections-list", effectiveContainerTags],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: effectiveContainerTags },
|
||||
})
|
||||
if (response.error) return []
|
||||
return response.data ?? []
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const { data: mcpData } = useQuery({
|
||||
queryKey: ["mcp-status"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/mcp/has-login")
|
||||
return response.data ?? { previousLogin: false }
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const {
|
||||
copy: personalizedCopy,
|
||||
profession,
|
||||
setProfession,
|
||||
} = usePersonalization()
|
||||
|
||||
const recents = recentsData?.documents ?? []
|
||||
const totalMemories = recentsData?.pagination?.totalItems ?? 0
|
||||
const hasMcp = mcpData?.previousLogin ?? false
|
||||
const connectedProviders = new Set(connections.map((c) => c.provider))
|
||||
|
||||
const dayOfYear = Math.round(
|
||||
(Date.now() - new Date(new Date().getFullYear(), 0, 1).getTime()) /
|
||||
86_400_000,
|
||||
)
|
||||
const tips = TIPS[profession]
|
||||
const tip = tips[dayOfYear % tips.length]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto p-4 pt-2! pb-32 md:p-6 md:pb-36 md:pr-0",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
|
||||
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
|
||||
|
||||
{/* Header */}
|
||||
<motion.header
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0 }}
|
||||
className="flex items-end justify-between gap-4 border-b border-[#0F1621] pb-4"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
|
||||
Home
|
||||
</p>
|
||||
<h1 className="text-xl font-medium tracking-tight text-white md:text-2xl">
|
||||
{spaceLabel}
|
||||
</h1>
|
||||
</div>
|
||||
{totalMemories > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateToGraph}
|
||||
className="group relative shrink-0 w-[140px] h-[56px] rounded-xl overflow-hidden border border-[rgba(255,255,255,0.05)] hover:border-[rgba(255,255,255,0.14)] transition-all bg-[#0B1017] hover:scale-[1.02]"
|
||||
aria-label="Open graph view"
|
||||
>
|
||||
<StaticGraphPreview
|
||||
documentCount={totalMemories}
|
||||
memoryCount={totalMemories * 6}
|
||||
width={140}
|
||||
height={56}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
View graph
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</motion.header>
|
||||
|
||||
{/* Daily Brief — hero */}
|
||||
<motion.section
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0.05 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
|
||||
Daily brief
|
||||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetHighlights}
|
||||
className="text-[#2A3040] hover:text-[#5A6478] transition-colors cursor-pointer"
|
||||
aria-label="Refresh daily brief"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Refresh daily brief
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-3 items-stretch">
|
||||
<div className="flex-[4] min-w-0">
|
||||
<HighlightsCard
|
||||
items={highlights}
|
||||
onChat={onHighlightsChat}
|
||||
onShowRelated={onHighlightsShowRelated}
|
||||
isLoading={isLoadingHighlights}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[2] hidden sm:block min-w-0">
|
||||
{memoryOfDay ? (
|
||||
<MemoryOfDayCard data={memoryOfDay} />
|
||||
) : (
|
||||
<PluginPromoCard
|
||||
hasMcp={hasMcp}
|
||||
connectedProviders={connectedProviders}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onOpenIntegrations={onOpenIntegrations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* Actions + connection status — single unified row */}
|
||||
<motion.section
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0.1 }}
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
{/* Quick actions */}
|
||||
<div className="flex items-center gap-0.5 -mx-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddMemory("link")}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-[#5A6478] hover:bg-[#0D121A] hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<Link2 className="size-3.5 shrink-0" />
|
||||
{personalizedCopy.saveLink}
|
||||
</button>
|
||||
<span className="text-[#1A2030] select-none">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddMemory("note")}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-[#5A6478] hover:bg-[#0D121A] hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0" />
|
||||
{personalizedCopy.writeNote}
|
||||
</button>
|
||||
<span className="text-[#1A2030] select-none">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
onOpenSearch()
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-[#5A6478] hover:bg-[#0D121A] hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tip of the day */}
|
||||
<p className="hidden sm:flex items-center gap-1.5 text-[11px] text-[#525D6E] min-w-0 overflow-hidden">
|
||||
<Lightbulb className="size-3 shrink-0 text-[#3374FF]" />
|
||||
<span className="truncate">{tip}</span>
|
||||
</p>
|
||||
</motion.section>
|
||||
|
||||
{/* Recently saved + Suggested for you */}
|
||||
<motion.section
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0.15 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{recents.length > 0 ? (
|
||||
<>
|
||||
{/* Shared header row — both labels aligned */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-[3] min-w-0">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
|
||||
Recently saved
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-[2] min-w-0 hidden sm:block">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
|
||||
Suggested for you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content row */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<ul className="flex-[3] min-w-0 space-y-0.5">
|
||||
{recents.map((doc) => {
|
||||
const isLink = !!doc.url
|
||||
return (
|
||||
<li key={doc.id ?? doc.customId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenDocument(doc)}
|
||||
className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-[#0D121A]"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[#0D121A] group-hover:bg-[#131B28] transition-colors">
|
||||
{isLink ? (
|
||||
<ExternalLink className="size-3 text-[#3A4455]" />
|
||||
) : (
|
||||
<FileText className="size-3 text-[#3A4455]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-[#737373] group-hover:text-white transition-colors">
|
||||
{doc.title?.trim() || "Untitled"}
|
||||
</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-[#1E2736] group-hover:text-[#3A4455] transition-colors" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex-[2] min-w-0 hidden sm:block">
|
||||
<RecommendedPluginsCard
|
||||
profession={profession}
|
||||
setProfession={setProfession}
|
||||
connectedProviders={connectedProviders}
|
||||
hasMcp={hasMcp}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onOpenIntegrations={onOpenIntegrations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* No recents yet — show suggestions full-width */
|
||||
<>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
|
||||
Suggested for you
|
||||
</p>
|
||||
<div className="max-w-sm">
|
||||
<RecommendedPluginsCard
|
||||
profession={profession}
|
||||
setProfession={setProfession}
|
||||
connectedProviders={connectedProviders}
|
||||
hasMcp={hasMcp}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onOpenIntegrations={onOpenIntegrations}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -96,14 +96,14 @@ export const FilePreview = memo(function FilePreview({
|
|||
className="w-4 h-4"
|
||||
/>
|
||||
<p
|
||||
className={cn(dmSansClassName(), "text-[10px] font-semibold")}
|
||||
className={cn(dmSansClassName(), "text-[11px] font-semibold")}
|
||||
style={{ color: color }}
|
||||
>
|
||||
{extension}
|
||||
</p>
|
||||
</div>
|
||||
{document.content && (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.content}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -24,20 +24,20 @@ export function GoogleDocsPreview({
|
|||
url={document.url}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
{document.summary ? (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.summary}
|
||||
</p>
|
||||
) : document.content ? (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.content}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
No summary available
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] font-semibold flex items-center gap-1",
|
||||
"text-[13px] font-semibold flex items-center gap-1",
|
||||
)}
|
||||
>
|
||||
<ClaudeDesktopIcon className="size-3" />
|
||||
|
|
@ -26,12 +26,12 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
|
|||
</div>
|
||||
<div className="space-y-[6px]">
|
||||
{document.title && (
|
||||
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
{document.title}
|
||||
</p>
|
||||
)}
|
||||
{document.content && (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.content}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
|
|||
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<DocumentIcon type="note" className="w-4 h-4" />
|
||||
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
Note
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -23,14 +23,14 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] font-semibold line-clamp-2 leading-[125%]",
|
||||
"text-[13px] font-semibold line-clamp-2 leading-[125%]",
|
||||
)}
|
||||
>
|
||||
{document.title}
|
||||
</p>
|
||||
)}
|
||||
{document.summary && (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.summary}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function NotionPreview({
|
|||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] tracking-wide text-[#929292] uppercase",
|
||||
"text-[11px] tracking-wide text-[#929292] uppercase",
|
||||
)}
|
||||
>
|
||||
Notion
|
||||
|
|
@ -85,7 +85,7 @@ export function NotionPreview({
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
|
||||
"text-[13px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
|
||||
)}
|
||||
>
|
||||
{document.title}
|
||||
|
|
@ -128,6 +128,8 @@ export function NotionPreview({
|
|||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
className="w-full h-full text-white"
|
||||
aria-label="Checked"
|
||||
role="img"
|
||||
>
|
||||
<path
|
||||
d="M2.5 5L4.5 7L7.5 3.5"
|
||||
|
|
@ -142,7 +144,7 @@ export function NotionPreview({
|
|||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[10px] line-clamp-1 leading-[140%]",
|
||||
"text-[11px] line-clamp-1 leading-[140%]",
|
||||
block.checked
|
||||
? "text-[#555] line-through"
|
||||
: "text-[#737373]",
|
||||
|
|
@ -158,7 +160,7 @@ export function NotionPreview({
|
|||
return (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<div className="mt-[5px] w-[4px] h-[4px] rounded-full bg-[#555] shrink-0" />
|
||||
<p className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]">
|
||||
{block.text}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -168,7 +170,7 @@ export function NotionPreview({
|
|||
return (
|
||||
<p
|
||||
key={i}
|
||||
className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]"
|
||||
className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]"
|
||||
>
|
||||
{block.text}
|
||||
</p>
|
||||
|
|
@ -176,7 +178,7 @@ export function NotionPreview({
|
|||
})}
|
||||
</div>
|
||||
) : document.summary ? (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.summary}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
@ -189,7 +191,7 @@ export function NotionPreview({
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-semibold flex items-center gap-1",
|
||||
"text-[11px] font-semibold flex items-center gap-1",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
|
|
@ -203,7 +205,7 @@ export function NotionPreview({
|
|||
{document.memoryEntries.length}
|
||||
</p>
|
||||
)}
|
||||
<p className={cn(dmSansClassName(), "text-[10px] text-[#737373]")}>
|
||||
<p className={cn(dmSansClassName(), "text-[11px] text-[#737373]")}>
|
||||
{new Date(document.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ function CustomTweetHeader({
|
|||
<div className="flex gap-0.5 items-center">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]",
|
||||
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[13px] truncate tracking-[-0.12px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -73,7 +73,7 @@ function CustomTweetHeader({
|
|||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium leading-tight overflow-hidden text-[#737373] text-[12px] truncate tracking-[-0.12px]",
|
||||
"font-medium leading-tight overflow-hidden text-[#737373] text-[13px] truncate tracking-[-0.12px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ export const YoutubePreview = memo(function YoutubePreview({
|
|||
return (
|
||||
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
|
||||
{document.title && (
|
||||
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
{document.title}
|
||||
</p>
|
||||
)}
|
||||
{document.content && (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.content}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -128,9 +128,15 @@ function TextDocumentIcon({ className }: { className?: string }) {
|
|||
|
||||
function XIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("font-bold", className)} style={{ color: "#FFFFFF" }}>
|
||||
𝕏
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn("text-white", className)}
|
||||
>
|
||||
<title>X (Twitter)</title>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export function DocumentContent({
|
|||
<TweetContent
|
||||
url={document.url}
|
||||
tweetMetadata={document.metadata?.sm_internal_twitter_metadata}
|
||||
content={document.content}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ import { ExternalLinkIcon } from "lucide-react"
|
|||
interface TweetContentProps {
|
||||
url?: string | null
|
||||
tweetMetadata?: unknown
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
|
||||
export function TweetContent({
|
||||
url,
|
||||
tweetMetadata,
|
||||
content,
|
||||
}: TweetContentProps) {
|
||||
if (tweetMetadata) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center w-full p-4 overflow-auto">
|
||||
|
|
@ -18,6 +23,27 @@ export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
|
|||
)
|
||||
}
|
||||
|
||||
if (content) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col w-full p-6 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-[#E5E5E5] font-sans leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-blue-400 hover:underline mt-4"
|
||||
>
|
||||
View on X
|
||||
<ExternalLinkIcon className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<p>Tweet preview unavailable</p>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export function Summary({
|
|||
<p className="text-[16px] font-semibold text-[#FAFAFA] line-clamp-1 leading-[125%]">
|
||||
Summary
|
||||
</p>
|
||||
<div className="text-[#737373] text-[10px] leading-[150%]">
|
||||
<div className="flex items-center gap-1 text-[#737373] opacity-50 text-[10px] leading-[150%]">
|
||||
<SyncLogoIcon className="w-[10px] h-[10px]" />
|
||||
powered by supermemory
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ interface DocumentsCommandPaletteProps {
|
|||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
projectId: string
|
||||
novaContainerTags?: string[]
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
onAddMemory?: () => void
|
||||
onOpenIntegrations?: () => void
|
||||
|
|
@ -46,7 +45,6 @@ export function DocumentsCommandPalette({
|
|||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
novaContainerTags,
|
||||
onOpenDocument,
|
||||
onAddMemory,
|
||||
onOpenIntegrations,
|
||||
|
|
@ -159,8 +157,7 @@ export function DocumentsCommandPalette({
|
|||
body: {
|
||||
q: search.trim(),
|
||||
limit: 10,
|
||||
containerTags:
|
||||
novaContainerTags ?? (projectId ? [projectId] : undefined),
|
||||
containerTags: projectId ? [projectId] : undefined,
|
||||
includeSummary: true,
|
||||
},
|
||||
signal: controller.signal,
|
||||
|
|
@ -178,7 +175,7 @@ export function DocumentsCommandPalette({
|
|||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [search, projectId, novaContainerTags])
|
||||
}, [search, projectId])
|
||||
|
||||
// Build the item list
|
||||
const hasQuery = search.trim().length > 0
|
||||
|
|
@ -202,7 +199,7 @@ export function DocumentsCommandPalette({
|
|||
// Reset selection on items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [search, searchResults.length])
|
||||
}, [])
|
||||
|
||||
// Scroll selected into view
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
if (organizations === null) return
|
||||
if (organizations.length > 0) return
|
||||
if (pathname.startsWith("/onboarding")) return
|
||||
router.replace("/onboarding/welcome?step=input")
|
||||
router.replace("/onboarding")
|
||||
}, [session, organizations, isRestoring, pathname, router])
|
||||
|
||||
return children
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ import { dmSansClassName } from "@/lib/fonts"
|
|||
import { ShareModal } from "./share-modal"
|
||||
import { shareParam } from "@/lib/search-params"
|
||||
|
||||
interface GraphLayoutViewProps {
|
||||
isChatOpen: boolean
|
||||
}
|
||||
|
||||
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
|
||||
export const GraphLayoutView = memo(function GraphLayoutView() {
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useQueryState(
|
||||
|
|
@ -34,14 +30,14 @@ export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
|
|||
}, [setIsShareModalOpen])
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[calc(100vh-86px)]">
|
||||
<div className="relative h-full min-h-0 w-full">
|
||||
{/* Full-width graph */}
|
||||
<div className="absolute inset-0">
|
||||
<MemoryGraph
|
||||
containerTags={effectiveContainerTags}
|
||||
variant="consumer"
|
||||
highlightDocumentIds={allHighlightDocumentIds}
|
||||
highlightsVisible={isChatOpen}
|
||||
highlightsVisible
|
||||
maxNodes={undefined}
|
||||
canvasRef={canvasRef}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { Logo } from "@ui/assets/Logo"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
LayoutGridIcon,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
Settings,
|
||||
|
|
@ -13,11 +12,12 @@ import {
|
|||
ExternalLink,
|
||||
MenuIcon,
|
||||
MessageCircleIcon,
|
||||
LifeBuoy,
|
||||
LayoutGrid,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
|
||||
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { useProject } from "@/stores"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
|
|
@ -34,17 +35,16 @@ import { useIsMobile } from "@hooks/use-mobile"
|
|||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { FeedbackModal } from "./feedback-modal"
|
||||
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { feedbackParam } from "@/lib/search-params"
|
||||
|
||||
interface HeaderProps {
|
||||
onAddMemory?: () => void
|
||||
onOpenChat?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
||||
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
||||
const { user, isRestoring } = useAuth()
|
||||
const { selectedProjects, setSelectedProjects } = useProject()
|
||||
const router = useRouter()
|
||||
|
|
@ -65,21 +65,21 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
return (
|
||||
<div className="flex p-3 md:p-4 justify-between items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-2 md:gap-4 z-10! min-w-0">
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
|
||||
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-lg px-2 py-1.5 -ml-2 cursor-pointer hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors shrink-0"
|
||||
className="-ml-2 flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
<Logo className="h-7" />
|
||||
<Logo className="h-6 md:h-7" />
|
||||
{!isMobile && userName && (
|
||||
<div className="flex flex-col items-start justify-center ml-2">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
|
||||
<p className="text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
|
||||
{userName}
|
||||
</p>
|
||||
<p className="text-white font-bold text-xl leading-none -mt-1">
|
||||
<p className="-mt-0.5 text-base leading-none font-medium text-white/90 sm:text-lg">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -129,7 +129,6 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="self-stretch w-px bg-[#FFFFFF33] hidden md:block" />
|
||||
{!isMobile && (
|
||||
<SpaceSelector
|
||||
selectedProjects={selectedProjects}
|
||||
|
|
@ -140,47 +139,94 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
)}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<Tabs
|
||||
value={viewMode === "list" ? "grid" : viewMode}
|
||||
onValueChange={(v) =>
|
||||
setViewMode(v === "grid" ? "list" : (v as ViewMode))
|
||||
}
|
||||
>
|
||||
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
|
||||
<TabsTrigger
|
||||
value="grid"
|
||||
className={cn(
|
||||
"rounded-full data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<LayoutGridIcon className="size-4" />
|
||||
Grid
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="graph"
|
||||
className={cn(
|
||||
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<GraphIcon className="size-4" />
|
||||
Graph
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="integrations"
|
||||
className={cn(
|
||||
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<IntegrationsIcon className="size-4" />
|
||||
Integrations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Home"
|
||||
aria-current={viewMode === "dashboard" ? "page" : undefined}
|
||||
onClick={() => void setViewMode("dashboard")}
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
viewMode === "dashboard"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Home
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Content"
|
||||
aria-orientation="horizontal"
|
||||
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:thin]"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
mode: "integrations" as const,
|
||||
label: "Integrations",
|
||||
icon: IntegrationsIcon,
|
||||
},
|
||||
{ mode: "graph" as const, label: "Graph", icon: GraphIcon },
|
||||
{
|
||||
mode: "list" as const,
|
||||
label: "Memories",
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
] as const
|
||||
).map(({ mode, label, icon: Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === mode}
|
||||
onClick={() => void setViewMode(mode)}
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
|
||||
viewMode === mode
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "text-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 sm:size-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Chat"
|
||||
aria-current={viewMode === "chat" ? "page" : undefined}
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
viewMode === "chat"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Chat
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 z-10!">
|
||||
<div className="z-10! flex shrink-0 items-center gap-1.5">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SpaceSelector
|
||||
|
|
@ -217,6 +263,13 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
<Plus className="h-4 w-4 text-[#737373]" />
|
||||
Add memory
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("dashboard")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Home className="h-4 w-4 text-[#737373]" />
|
||||
Home
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setViewMode("integrations")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
|
|
@ -225,7 +278,28 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
Integrations
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenChat}
|
||||
onClick={() => void setViewMode("graph")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<GraphIcon className="h-4 w-4 text-[#737373]" />
|
||||
Graph
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenSearch?.()}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<SearchIcon className="h-4 w-4 text-[#737373]" />
|
||||
Search
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("list")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 text-[#737373]" />
|
||||
Memories
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
|
||||
|
|
@ -236,7 +310,7 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
onClick={handleFeedback}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
|
||||
<LifeBuoy className="h-4 w-4 text-[#737373]" />
|
||||
Feedback
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
|
|
@ -251,62 +325,48 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
onClick={onAddMemory}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="size-4" />
|
||||
Add memory
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
C
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
onClick={onOpenSearch}
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
|
||||
<svg
|
||||
className="size-[7.5px]"
|
||||
viewBox="0 0 9 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0",
|
||||
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
|
||||
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onAddMemory}
|
||||
aria-label="Add memory"
|
||||
>
|
||||
<title>Command Key</title>
|
||||
<path
|
||||
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
|
||||
stroke="#737373"
|
||||
strokeWidth="0.833333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className={cn(dmSansClassName())}>K</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
onClick={handleFeedback}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircleIcon className="size-4" />
|
||||
Feedback
|
||||
</div>
|
||||
</Button>
|
||||
<Plus className="size-3.5 shrink-0 lg:size-4" />
|
||||
<span className="max-lg:sr-only">Add memory</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Add memory (C)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onOpenSearch}
|
||||
aria-label="Search"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Search (⌘K)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<UserProfileMenu />
|
||||
<UserProfileMenu onOpenFeedback={handleFeedback} />
|
||||
</div>
|
||||
<FeedbackModal
|
||||
isOpen={feedbackOpen}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Link2,
|
||||
} from "lucide-react"
|
||||
|
|
@ -103,14 +102,24 @@ export function HighlightsCard({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] items-center justify-center",
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin text-[#4BA0FA]" />
|
||||
<span className="text-[10px] text-[#737373]">
|
||||
Loading highlights...
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-[14px] rounded-full bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-2 w-20 rounded bg-[#1A2030] animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-2.5 w-2/5 rounded bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-2 w-full rounded bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-2 w-[85%] rounded bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-2 w-[65%] rounded bg-[#1A2030] animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[26px] w-14 rounded-lg bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-[26px] w-16 rounded-lg bg-[#1A2030] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
type IntegrationParamValue,
|
||||
} from "@/lib/search-params"
|
||||
import Image from "next/image"
|
||||
import { IntegrationGridCard } from "@/components/integrations/integration-grid-card"
|
||||
|
||||
type CardId =
|
||||
| "mcp"
|
||||
|
|
@ -238,39 +239,14 @@ export function IntegrationsView() {
|
|||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{cards.map((card) => (
|
||||
<button
|
||||
<IntegrationGridCard
|
||||
key={card.id}
|
||||
type="button"
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
icon={card.icon}
|
||||
pro={card.pro}
|
||||
onClick={() => setSelectedCard(card.id)}
|
||||
className={cn(
|
||||
"bg-[#080B0F] relative rounded-xl p-4 pt-14",
|
||||
"border border-[#0D121A]",
|
||||
"hover:border-[#3374FF]/50",
|
||||
"transition-all duration-300 cursor-pointer text-left w-full",
|
||||
"hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:200%_auto] hover:bg-[center_top_1rem] hover:bg-no-repeat",
|
||||
"group",
|
||||
)}
|
||||
>
|
||||
{card.pro && (
|
||||
<span className="absolute top-3 left-3 bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.3px] px-1.5 py-0.5 rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
{card.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white text-sm font-medium">{card.title}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-xs leading-relaxed mt-0.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
54
apps/web/components/integrations/integration-grid-card.tsx
Normal file
54
apps/web/components/integrations/integration-grid-card.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
||||
export function IntegrationGridCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
pro,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: ReactNode
|
||||
pro?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"bg-[#080B0F] relative rounded-xl p-4 pt-14",
|
||||
"border border-[#0D121A]",
|
||||
"hover:border-[#3374FF]/50",
|
||||
"transition-all duration-300 cursor-pointer text-left w-full",
|
||||
"hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:200%_auto] hover:bg-[center_top_1rem] hover:bg-no-repeat",
|
||||
"group",
|
||||
)}
|
||||
>
|
||||
{pro ? (
|
||||
<span className="absolute top-3 left-3 bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.3px] px-1.5 py-0.5 rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
) : null}
|
||||
<div className="absolute top-2 right-2 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white text-sm font-medium">{title}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-xs leading-relaxed mt-0.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -26,8 +26,7 @@ import { McpPreview } from "./document-cards/mcp-preview"
|
|||
import { NotionPreview } from "./document-cards/notion-preview"
|
||||
import { getFaviconUrl } from "@/lib/url-helpers"
|
||||
import { QuickNoteCard } from "./quick-note-card"
|
||||
import { HighlightsCard, type HighlightItem } from "./highlights-card"
|
||||
import { GraphCard } from "./memory-graph"
|
||||
import type { HighlightItem } from "./highlights-card"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
categoriesParam,
|
||||
|
|
@ -44,7 +43,16 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@ui/components/alert-dialog"
|
||||
import { CheckIcon, Loader, Trash2Icon, XIcon } from "lucide-react"
|
||||
import {
|
||||
AlignLeft,
|
||||
CheckIcon,
|
||||
LayoutGrid,
|
||||
Loader,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { useProcessingDocuments } from "@/hooks/use-processing-documents"
|
||||
import { TimelineView } from "./timeline-view"
|
||||
|
||||
// Document category type
|
||||
type DocumentCategory =
|
||||
|
|
@ -171,7 +179,9 @@ function MemoriesGridLoading() {
|
|||
}
|
||||
|
||||
// Discriminated union for masonry items
|
||||
type MasonryItem = { type: "document"; id: string; data: DocumentWithMemories }
|
||||
type MasonryItem =
|
||||
| { type: "document"; id: string; data: DocumentWithMemories }
|
||||
| { type: "quick-note"; id: "quick-note" }
|
||||
|
||||
interface QuickNoteProps {
|
||||
onSave: (content: string) => void
|
||||
|
|
@ -226,8 +236,18 @@ export function MemoriesGrid({
|
|||
emptyStateProps,
|
||||
}: MemoriesGridProps) {
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const [localViewMode, setLocalViewMode] = useState<"grid" | "timeline">(
|
||||
() => {
|
||||
if (typeof window === "undefined") return "grid"
|
||||
return (
|
||||
(localStorage.getItem("memories-view-mode") as "grid" | "timeline") ??
|
||||
"grid"
|
||||
)
|
||||
},
|
||||
)
|
||||
const { user, isSessionPending } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const processingStatusMap = useProcessingDocuments()
|
||||
const isMobile = useIsMobile()
|
||||
const [selectedCategories, setSelectedCategories] = useQueryState(
|
||||
"categories",
|
||||
|
|
@ -309,6 +329,11 @@ export function MemoriesGrid({
|
|||
enabled: !!user,
|
||||
})
|
||||
|
||||
const handleSetViewMode = useCallback((mode: "grid" | "timeline") => {
|
||||
setLocalViewMode(mode)
|
||||
localStorage.setItem("memories-view-mode", mode)
|
||||
}, [])
|
||||
|
||||
const handleCategoryToggle = useCallback(
|
||||
(category: DocumentCategory) => {
|
||||
setSelectedCategories((prev) => {
|
||||
|
|
@ -334,23 +359,27 @@ export function MemoriesGrid({
|
|||
}, [data])
|
||||
|
||||
const hasQuickNote = !!quickNoteProps
|
||||
const hasHighlights = !!highlightsProps
|
||||
const _hasHighlights = !!highlightsProps
|
||||
|
||||
const masonryItems: MasonryItem[] = useMemo(() => {
|
||||
const items: MasonryItem[] = []
|
||||
|
||||
if (!isMobile && hasQuickNote) {
|
||||
items.push({ type: "quick-note", id: "quick-note" })
|
||||
}
|
||||
|
||||
for (const doc of documents) {
|
||||
items.push({ type: "document", id: doc.id, data: doc })
|
||||
}
|
||||
|
||||
return items
|
||||
}, [documents])
|
||||
}, [documents, isMobile, hasQuickNote])
|
||||
|
||||
// Stable key for Masonry based on document IDs, not item values
|
||||
const masonryKey = useMemo(() => {
|
||||
const docIds = documents.map((d) => d.id).join(",")
|
||||
return `masonry-${documents.length}-${docIds}-${isChatOpen}`
|
||||
}, [documents, isChatOpen])
|
||||
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}`
|
||||
}, [documents, isChatOpen, hasQuickNote])
|
||||
|
||||
const isLoadingMore = isFetchingNextPage
|
||||
|
||||
|
|
@ -402,6 +431,26 @@ export function MemoriesGrid({
|
|||
onBulkDelete?.()
|
||||
}, [onBulkDelete])
|
||||
|
||||
// All mutable values the render function needs — kept in a ref so the
|
||||
// function identity never changes (masonic uses render as a React component
|
||||
// type, so a new reference unmounts every item and kills textarea focus).
|
||||
const renderRef = useRef({
|
||||
quickNoteProps,
|
||||
handleCardClick,
|
||||
isSelectionMode,
|
||||
selectedDocumentIds,
|
||||
onToggleSelection,
|
||||
processingStatusMap,
|
||||
})
|
||||
renderRef.current = {
|
||||
quickNoteProps,
|
||||
handleCardClick,
|
||||
isSelectionMode,
|
||||
selectedDocumentIds,
|
||||
onToggleSelection,
|
||||
processingStatusMap,
|
||||
}
|
||||
|
||||
const renderMasonryItem = useCallback(
|
||||
({
|
||||
index,
|
||||
|
|
@ -412,6 +461,16 @@ export function MemoriesGrid({
|
|||
data: MasonryItem
|
||||
width: number
|
||||
}) => {
|
||||
const r = renderRef.current
|
||||
|
||||
if (data.type === "quick-note") {
|
||||
return r.quickNoteProps ? (
|
||||
<div style={{ width }} className="p-2">
|
||||
<QuickNoteCard {...r.quickNoteProps} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
if (data.type === "document") {
|
||||
const doc = data.data
|
||||
return (
|
||||
|
|
@ -420,14 +479,17 @@ export function MemoriesGrid({
|
|||
index={index}
|
||||
data={doc}
|
||||
width={width}
|
||||
onClick={handleCardClick}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
|
||||
onClick={r.handleCardClick}
|
||||
isSelectionMode={r.isSelectionMode}
|
||||
isSelected={doc.id ? r.selectedDocumentIds.has(doc.id) : false}
|
||||
onToggleSelection={
|
||||
doc.id && onToggleSelection
|
||||
? () => onToggleSelection(doc.id as string)
|
||||
doc.id && r.onToggleSelection
|
||||
? () => r.onToggleSelection?.(doc.id as string)
|
||||
: undefined
|
||||
}
|
||||
processingStatus={
|
||||
doc.id ? r.processingStatusMap.get(doc.id) : undefined
|
||||
}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
|
@ -435,7 +497,8 @@ export function MemoriesGrid({
|
|||
|
||||
return null
|
||||
},
|
||||
[handleCardClick, isSelectionMode, selectedDocumentIds, onToggleSelection],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
)
|
||||
|
||||
if (isSessionPending) {
|
||||
|
|
@ -496,6 +559,35 @@ export function MemoriesGrid({
|
|||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* View mode toggle */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Grid view"
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-full border transition-colors cursor-pointer",
|
||||
localViewMode === "grid"
|
||||
? "bg-[#00173C] border-[#2261CA33]"
|
||||
: "bg-[#0D121A] border-[#161F2C] hover:bg-[#00173C]",
|
||||
)}
|
||||
onClick={() => handleSetViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4 text-[#737373]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Timeline view"
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-full border transition-colors cursor-pointer",
|
||||
localViewMode === "timeline"
|
||||
? "bg-[#00173C] border-[#2261CA33]"
|
||||
: "bg-[#0D121A] border-[#161F2C] hover:bg-[#00173C]",
|
||||
)}
|
||||
onClick={() => handleSetViewMode("timeline")}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4 text-[#737373]" />
|
||||
</button>
|
||||
</div>
|
||||
{isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
|
|
@ -620,41 +712,30 @@ export function MemoriesGrid({
|
|||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto scrollbar-thin">
|
||||
{!isMobile && (hasQuickNote || hasHighlights) && (
|
||||
<div className="flex gap-2 mb-2 px-2">
|
||||
{hasQuickNote && quickNoteProps && (
|
||||
<div className="w-[216px] shrink-0">
|
||||
<QuickNoteCard {...quickNoteProps} />
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && highlightsProps && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<HighlightsCard {...highlightsProps} />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-[216px] shrink-0">
|
||||
<GraphCard
|
||||
containerTags={effectiveContainerTags}
|
||||
width={200}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{localViewMode === "timeline" ? (
|
||||
<TimelineView
|
||||
documents={documents}
|
||||
onOpenDocument={onOpenDocument}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={loadMoreDocuments}
|
||||
/>
|
||||
) : (
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={260}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
/>
|
||||
)}
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={216}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
/>
|
||||
|
||||
{isLoadingMore && (
|
||||
{isLoadingMore && localViewMode === "grid" && (
|
||||
<div className="py-10 flex items-center justify-center">
|
||||
<Loader className="size-10 animate-spin text-sky-400" />
|
||||
</div>
|
||||
|
|
@ -676,7 +757,7 @@ function DocumentUrlDisplay({ url }: { url: string }) {
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#737373] line-clamp-1",
|
||||
"text-[11px] text-[#737373] line-clamp-1",
|
||||
)}
|
||||
>
|
||||
{isLoading ? "YouTube" : channelName || "YouTube"}
|
||||
|
|
@ -688,7 +769,7 @@ function DocumentUrlDisplay({ url }: { url: string }) {
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#737373] line-clamp-1",
|
||||
"text-[11px] text-[#737373] line-clamp-1",
|
||||
)}
|
||||
>
|
||||
{getAbsoluteUrl(url)}
|
||||
|
|
@ -701,6 +782,74 @@ function isTemporaryId(id: string | null | undefined): boolean {
|
|||
return id.startsWith("temp-") || id.startsWith("temp-file-")
|
||||
}
|
||||
|
||||
const PROCESSING_WORDS = [
|
||||
"Reading",
|
||||
"Absorbing",
|
||||
"Scanning",
|
||||
"Thinking",
|
||||
"Connecting",
|
||||
"Pondering",
|
||||
"Synthesizing",
|
||||
"Reflecting",
|
||||
"Understanding",
|
||||
"Organizing",
|
||||
"Memorizing",
|
||||
"Filing",
|
||||
"Saving",
|
||||
"Learning",
|
||||
"Cataloguing",
|
||||
"Weaving",
|
||||
]
|
||||
|
||||
function ProcessingBadge() {
|
||||
const [wordIndex, setWordIndex] = useState(() =>
|
||||
Math.floor(Math.random() * PROCESSING_WORDS.length),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setWordIndex((i) => (i + 1) % PROCESSING_WORDS.length)
|
||||
}, 1800)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-sky-400" />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-sky-400 font-medium",
|
||||
)}
|
||||
>
|
||||
{PROCESSING_WORDS[wordIndex]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DoneBadge() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckIcon
|
||||
className="w-2.5 h-2.5 text-emerald-400 shrink-0"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-emerald-400 font-medium",
|
||||
)}
|
||||
>
|
||||
Done
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DocumentCard = memo(
|
||||
({
|
||||
index: _index,
|
||||
|
|
@ -710,6 +859,7 @@ const DocumentCard = memo(
|
|||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
processingStatus,
|
||||
}: {
|
||||
index: number
|
||||
data: DocumentWithMemories
|
||||
|
|
@ -718,12 +868,26 @@ const DocumentCard = memo(
|
|||
isSelectionMode?: boolean
|
||||
isSelected?: boolean
|
||||
onToggleSelection?: () => void
|
||||
processingStatus?: string
|
||||
}) => {
|
||||
const canSelect =
|
||||
!isTemporaryId(document.id) && !isTemporaryId(document.customId)
|
||||
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
|
||||
const cardRef = useRef<HTMLButtonElement>(null)
|
||||
const [ogData, setOgData] = useState<OgData | null>(null)
|
||||
const [showDone, setShowDone] = useState(false)
|
||||
const prevStatusRef = useRef<string | undefined>(processingStatus)
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = processingStatus
|
||||
// Show the "done" checkmark briefly when the card leaves the processing map
|
||||
if (prev && !processingStatus) {
|
||||
setShowDone(true)
|
||||
const id = setTimeout(() => setShowDone(false), 2000)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
}, [processingStatus])
|
||||
|
||||
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
|
||||
.ogImage
|
||||
|
|
@ -847,15 +1011,16 @@ const DocumentCard = memo(
|
|||
) && (
|
||||
<div className="pb-[10px] space-y-1">
|
||||
{document.url &&
|
||||
!document.url.includes("x.com") &&
|
||||
!document.url.includes("twitter.com") &&
|
||||
!document.url.includes("files.supermemory.ai") && (
|
||||
!document.url.includes("files.supermemory.ai") &&
|
||||
(document.title ||
|
||||
(!document.url.includes("x.com") &&
|
||||
!document.url.includes("twitter.com"))) && (
|
||||
<div className="px-3">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
|
||||
"text-[13px] text-[#E5E5E5] line-clamp-1 font-semibold",
|
||||
)}
|
||||
>
|
||||
{document.title || ogData?.title || "Untitled Document"}
|
||||
|
|
@ -878,16 +1043,22 @@ const DocumentCard = memo(
|
|||
<div
|
||||
className={cn(
|
||||
"flex items-center px-3",
|
||||
document.memoryEntries.length > 0
|
||||
processingStatus ||
|
||||
showDone ||
|
||||
document.memoryEntries.length > 0
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
)}
|
||||
>
|
||||
{document.memoryEntries.length > 0 && (
|
||||
{processingStatus ? (
|
||||
<ProcessingBadge />
|
||||
) : showDone ? (
|
||||
<DoneBadge />
|
||||
) : document.memoryEntries.length > 0 ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#369BFD] font-semibold flex items-center gap-1",
|
||||
"text-[11px] text-[#369BFD] font-semibold flex items-center gap-1",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
|
|
@ -900,11 +1071,11 @@ const DocumentCard = memo(
|
|||
<SyncLogoIcon className="w-[12.33px] h-[10px]" />
|
||||
{document.memoryEntries.length}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#737373] line-clamp-1",
|
||||
"text-[11px] text-[#737373] line-clamp-1",
|
||||
)}
|
||||
>
|
||||
{new Date(document.createdAt).toLocaleDateString("en-US", {
|
||||
|
|
@ -939,10 +1110,7 @@ function ContentPreview({
|
|||
return <GoogleDocsPreview document={document} />
|
||||
}
|
||||
|
||||
if (
|
||||
document.url?.includes("x.com/") &&
|
||||
document.metadata?.sm_internal_twitter_metadata
|
||||
) {
|
||||
if (document.metadata?.sm_internal_twitter_metadata) {
|
||||
return (
|
||||
<TweetPreview
|
||||
data={
|
||||
|
|
@ -952,6 +1120,13 @@ function ContentPreview({
|
|||
)
|
||||
}
|
||||
|
||||
if (
|
||||
document.url?.includes("x.com/") ||
|
||||
document.url?.includes("twitter.com/")
|
||||
) {
|
||||
return <NotePreview document={document} />
|
||||
}
|
||||
|
||||
if (document.source === "mcp") {
|
||||
return <McpPreview document={document} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function seededRandom(seed: number) {
|
|||
}
|
||||
}
|
||||
|
||||
function StaticGraphPreview({
|
||||
export function StaticGraphPreview({
|
||||
documentCount,
|
||||
memoryCount,
|
||||
width,
|
||||
|
|
@ -70,10 +70,10 @@ function StaticGraphPreview({
|
|||
let b = Math.floor(rand() * nodes.length)
|
||||
if (b === a) b = (a + 1) % nodes.length
|
||||
result.push({
|
||||
x1: nodes[a]!.x,
|
||||
y1: nodes[a]!.y,
|
||||
x2: nodes[b]!.x,
|
||||
y2: nodes[b]!.y,
|
||||
x1: nodes[a]?.x,
|
||||
y1: nodes[a]?.y,
|
||||
x2: nodes[b]?.x,
|
||||
y2: nodes[b]?.y,
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -171,14 +171,7 @@ export function IntegrationsStep() {
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between w-full max-w-4xl">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
|
||||
onClick={() => router.push("/onboarding/setup?step=relatable")}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
<div className="flex justify-end w-full max-w-4xl">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
const relatableOptions = [
|
||||
{
|
||||
emoji: "😔",
|
||||
text: "I always forget what I save in my twitter bookmarks",
|
||||
},
|
||||
{
|
||||
emoji: "😭",
|
||||
text: "Going through e-books manually is so tedious",
|
||||
},
|
||||
{
|
||||
emoji: "🥲",
|
||||
text: "I always have to feed every AI app with my data",
|
||||
},
|
||||
{
|
||||
emoji: "😵💫",
|
||||
text: "Referring meeting notes makes my AI chat hallucinate",
|
||||
},
|
||||
{
|
||||
emoji: "🫤",
|
||||
text: "I save nothing on my browser, it's just useless",
|
||||
},
|
||||
]
|
||||
|
||||
export function RelatableQuestion() {
|
||||
const router = useRouter()
|
||||
const [selectedOptions, setSelectedOptions] = useState<number[]>([])
|
||||
|
||||
const handleContinueOrSkip = () => {
|
||||
const selectedTexts = selectedOptions.map(
|
||||
(idx) => relatableOptions[idx]?.text || "",
|
||||
)
|
||||
analytics.onboardingRelatableSelected({ options: selectedTexts })
|
||||
router.push("/onboarding/setup?step=integrations")
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center h-full"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<motion.h1
|
||||
className="text-white text-[32px] font-medium mb-6 text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
Which of these sound most relatable?
|
||||
</motion.h1>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap justify-center gap-4 max-w-3xl",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{relatableOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.text}
|
||||
className={cn(
|
||||
"rounded-lg max-w-[140px] min-h-[159px] transition-all duration-300",
|
||||
selectedOptions.includes(index)
|
||||
? "p-px bg-linear-to-b from-[#3374FF] to-[#1A63FF00]"
|
||||
: "p-0 border border-[#0D121A] hover:border-[#4C608B66]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={`
|
||||
group relative w-full h-full rounded-lg p-2 cursor-pointer transition-all duration-300 overflow-hidden
|
||||
bg-[#080B0F] hover:bg-no-repeat
|
||||
`}
|
||||
onClick={() => {
|
||||
setSelectedOptions((prev) =>
|
||||
prev.includes(index)
|
||||
? prev.filter((i) => i !== index)
|
||||
: [...prev, index],
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
setSelectedOptions((prev) =>
|
||||
prev.includes(index)
|
||||
? prev.filter((i) => i !== index)
|
||||
: [...prev, index],
|
||||
)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{selectedOptions.includes(index) && (
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[550%_auto] bg-top bg-no-repeat"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="relative flex flex-col items-start justify-between h-full">
|
||||
<span
|
||||
className={`text-2xl ${
|
||||
selectedOptions.includes(index)
|
||||
? "opacity-100"
|
||||
: "opacity-70 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{option.emoji}
|
||||
</span>
|
||||
<p
|
||||
className={`text-white text-sm leading-[135%] align-bottom text-left transition-opacity duration-300 ${
|
||||
selectedOptions.includes(index)
|
||||
? "opacity-100"
|
||||
: "opacity-50 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{option.text}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 my-8">
|
||||
<div key={selectedOptions.length === 0 ? "skip" : "continue"}>
|
||||
<Button
|
||||
className={cn(
|
||||
"font-medium text-white hover:no-underline cursor-pointer",
|
||||
selectedOptions.length !== 0 ? "rounded-xl" : "",
|
||||
)}
|
||||
variant={selectedOptions.length !== 0 ? "onboarding" : "link"}
|
||||
size="lg"
|
||||
onClick={handleContinueOrSkip}
|
||||
style={
|
||||
selectedOptions.length !== 0
|
||||
? {
|
||||
background:
|
||||
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{selectedOptions.length === 0
|
||||
? "Skip for now →"
|
||||
: "Remember this →"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -42,11 +42,11 @@ export function OnboardingContentStep({
|
|||
const router = useRouter()
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/onboarding/welcome?step=features")
|
||||
router.push("/old/onboarding/welcome?step=features")
|
||||
}
|
||||
|
||||
const handleAddMemories = () => {
|
||||
router.push("/onboarding/welcome?step=memories")
|
||||
router.push("/old/onboarding/welcome?step=memories")
|
||||
}
|
||||
|
||||
const isContinue = currentView === "continue"
|
||||
|
|
|
|||
|
|
@ -23,28 +23,10 @@ export function InputStep({
|
|||
isSubmitting && "pointer-events-none",
|
||||
)}
|
||||
style={{ gap: "24px" }}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 10,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeOut",
|
||||
bounce: 0,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
delay: 1,
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.3, ease: "easeOut" } }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||
layout
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium leading-[110%]">
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
|
|||
description_length: description.trim().length,
|
||||
})
|
||||
onSubmit(formData)
|
||||
router.push("/onboarding/setup?step=relatable")
|
||||
router.push("/old/onboarding/setup?step=integrations")
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Fetching..." : "Remember this →"}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,7 @@ import { useState, useMemo } from "react"
|
|||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import {
|
||||
ChevronsLeftRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
XIcon,
|
||||
Loader2,
|
||||
Globe,
|
||||
Layers,
|
||||
} from "lucide-react"
|
||||
import { ChevronDown, Plus, Trash2, XIcon, Loader2, Layers } from "lucide-react"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
import { AddSpaceModal } from "./add-space-modal"
|
||||
import { SelectSpacesModal } from "./select-spaces-modal"
|
||||
|
|
@ -57,8 +49,13 @@ export interface SpaceSelectorProps {
|
|||
}
|
||||
|
||||
const triggerVariants = {
|
||||
default: "px-3 py-2 rounded-md hover:bg-white/5",
|
||||
insideOut: "px-3 py-2 rounded-full bg-[#0D121A] shadow-inside-out",
|
||||
default:
|
||||
"h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
|
||||
"hover:bg-white/5 " +
|
||||
"data-[state=open]:border-[#2261CA33] data-[state=open]:bg-[#00173C]/35 " +
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2261CA33]/35",
|
||||
insideOut:
|
||||
"h-10 min-h-10 gap-2 px-3 rounded-full bg-[#0D121A] shadow-inside-out hover:bg-[#121820]",
|
||||
}
|
||||
|
||||
export function SpaceSelector({
|
||||
|
|
@ -90,15 +87,9 @@ export function SpaceSelector({
|
|||
|
||||
const { deleteProjectMutation } = useProjectMutations()
|
||||
|
||||
const { allProjects, novaProjects, isLoading } = useContainerTags()
|
||||
|
||||
const isNovaSpaces = selectedProjects.length === 0
|
||||
const { allProjects, isLoading } = useContainerTags()
|
||||
|
||||
const displayInfo = useMemo(() => {
|
||||
if (isNovaSpaces) {
|
||||
return { name: "Nova Spaces", emoji: null, isMultiple: false }
|
||||
}
|
||||
|
||||
if (selectedProjects.length === 1) {
|
||||
const containerTag = selectedProjects[0]
|
||||
if (containerTag === DEFAULT_PROJECT_ID) {
|
||||
|
|
@ -114,18 +105,17 @@ export function SpaceSelector({
|
|||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${selectedProjects.length} spaces`,
|
||||
emoji: null,
|
||||
isMultiple: true,
|
||||
if (selectedProjects.length > 1) {
|
||||
return {
|
||||
name: `${selectedProjects.length} spaces`,
|
||||
emoji: null,
|
||||
isMultiple: true,
|
||||
}
|
||||
}
|
||||
}, [allProjects, selectedProjects, isNovaSpaces])
|
||||
|
||||
const handleSelectNovaSpaces = () => {
|
||||
analytics.spaceSwitched({ space_id: "nova_spaces" })
|
||||
onValueChange([]) // Empty array = "Nova Spaces" (all nova)
|
||||
setIsOpen(false)
|
||||
}
|
||||
// Nothing selected — default to "My Space"
|
||||
return { name: "My Space", emoji: "📁", isMultiple: false }
|
||||
}, [allProjects, selectedProjects])
|
||||
|
||||
const handleSelectSingleSpace = (containerTag: string) => {
|
||||
analytics.spaceSwitched({ space_id: containerTag })
|
||||
|
|
@ -204,13 +194,13 @@ export function SpaceSelector({
|
|||
}
|
||||
|
||||
const availableTargetProjects = useMemo(() => {
|
||||
const filtered = novaProjects.filter(
|
||||
const filtered = allProjects.filter(
|
||||
(p: ContainerTagListType) =>
|
||||
p.id !== deleteDialog.project?.id &&
|
||||
p.containerTag !== deleteDialog.project?.containerTag,
|
||||
)
|
||||
|
||||
const defaultProject = novaProjects.find(
|
||||
const defaultProject = allProjects.find(
|
||||
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)
|
||||
|
||||
|
|
@ -227,7 +217,7 @@ export function SpaceSelector({
|
|||
}
|
||||
|
||||
return filtered
|
||||
}, [novaProjects, deleteDialog.project])
|
||||
}, [allProjects, deleteDialog.project])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -235,29 +225,60 @@ export function SpaceSelector({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
isLoading
|
||||
? "Loading spaces"
|
||||
: `Space: ${displayInfo.name}. Open menu to switch.`
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 cursor-pointer transition-colors focus:outline-none focus-visible:outline-none",
|
||||
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
|
||||
triggerVariants[variant],
|
||||
variant === "default" && compact && "h-9 min-h-9 gap-1.5 px-2.5",
|
||||
dmSansClassName(),
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
{isNovaSpaces ? (
|
||||
<Globe className="size-4 text-white" />
|
||||
) : displayInfo.isMultiple ? (
|
||||
<Layers className="size-4 text-white" />
|
||||
{displayInfo.isMultiple ? (
|
||||
<Layers
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
variant === "insideOut" ? "text-white" : "text-[#737373]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-bold tracking-[-0.98px]">
|
||||
<span
|
||||
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
|
||||
aria-hidden
|
||||
>
|
||||
{displayInfo.emoji}
|
||||
</span>
|
||||
)}
|
||||
{!compact && (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{isLoading ? "..." : displayInfo.name}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
"max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{compact && (
|
||||
<span className="sr-only">
|
||||
{isLoading ? "Loading" : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{showChevron && (
|
||||
<ChevronsLeftRight className="size-4 rotate-90 text-white/70" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"shrink-0 opacity-90",
|
||||
variant === "insideOut" ? "text-white/80" : "text-[#737373]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -274,25 +295,6 @@ export function SpaceSelector({
|
|||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col">
|
||||
{!singleSelect && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={handleSelectNovaSpaces}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
|
||||
isNovaSpaces
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
<span className="flex-1">Nova Spaces</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="bg-[#2E3033] my-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
|
||||
My Spaces
|
||||
|
|
@ -313,7 +315,7 @@ export function SpaceSelector({
|
|||
<span className="flex-1">My Space</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{novaProjects
|
||||
{allProjects
|
||||
.filter(
|
||||
(p: ContainerTagListType) =>
|
||||
p.containerTag !== DEFAULT_PROJECT_ID,
|
||||
|
|
|
|||
429
apps/web/components/timeline-view.tsx
Normal file
429
apps/web/components/timeline-view.tsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
// ─── Time period helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function getTimePeriodLabel(date: Date, now: Date): string {
|
||||
const docDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffDays = Math.round(
|
||||
(todayDay.getTime() - docDay.getTime()) / 86400000,
|
||||
)
|
||||
|
||||
if (diffDays === 0) return "Today"
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return date.toLocaleDateString("en-US", { weekday: "long" })
|
||||
if (date.getFullYear() === now.getFullYear())
|
||||
return date.toLocaleDateString("en-US", { month: "long", day: "numeric" })
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Document type helpers ────────────────────────────────────────────────────
|
||||
|
||||
type CategoryInfo = { label: string; singularLabel: string; key: string }
|
||||
|
||||
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
|
||||
if (doc.source === "mcp")
|
||||
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
|
||||
if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
|
||||
return {
|
||||
label: "YouTube Videos",
|
||||
singularLabel: "YouTube Video",
|
||||
key: "youtube",
|
||||
}
|
||||
switch (doc.type) {
|
||||
case "tweet":
|
||||
return { label: "Tweets", singularLabel: "Tweet", key: "tweet" }
|
||||
case "google_doc":
|
||||
return {
|
||||
label: "Google Docs",
|
||||
singularLabel: "Google Doc",
|
||||
key: "google_doc",
|
||||
}
|
||||
case "google_slide":
|
||||
return {
|
||||
label: "Google Slides",
|
||||
singularLabel: "Google Slide",
|
||||
key: "google_slide",
|
||||
}
|
||||
case "google_sheet":
|
||||
return {
|
||||
label: "Google Sheets",
|
||||
singularLabel: "Google Sheet",
|
||||
key: "google_sheet",
|
||||
}
|
||||
case "notion_doc":
|
||||
return {
|
||||
label: "Notion Docs",
|
||||
singularLabel: "Notion Doc",
|
||||
key: "notion_doc",
|
||||
}
|
||||
case "text":
|
||||
return { label: "Notes", singularLabel: "Note", key: "text" }
|
||||
case "pdf":
|
||||
return { label: "PDFs", singularLabel: "PDF", key: "pdf" }
|
||||
case "image":
|
||||
return { label: "Images", singularLabel: "Image", key: "image" }
|
||||
case "video":
|
||||
return { label: "Videos", singularLabel: "Video", key: "video" }
|
||||
case "onedrive":
|
||||
return {
|
||||
label: "OneDrive Files",
|
||||
singularLabel: "OneDrive File",
|
||||
key: "onedrive",
|
||||
}
|
||||
case "webpage":
|
||||
return { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
|
||||
default:
|
||||
return doc.url?.startsWith("https://")
|
||||
? { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
|
||||
: { label: "Notes", singularLabel: "Note", key: "text" }
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewText(doc: DocumentWithMemories): string {
|
||||
return doc.summary || doc.content || doc.title || ""
|
||||
}
|
||||
|
||||
// ─── Grouped data structures ─────────────────────────────────────────────────
|
||||
|
||||
type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] }
|
||||
type PeriodGroup = { label: string; typeGroups: TypeGroup[] }
|
||||
|
||||
function groupDocuments(
|
||||
documents: DocumentWithMemories[],
|
||||
now: Date,
|
||||
): PeriodGroup[] {
|
||||
const sorted = [...documents].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)
|
||||
|
||||
const periodMap = new Map<string, DocumentWithMemories[]>()
|
||||
const periodOrder: string[] = []
|
||||
|
||||
for (const doc of sorted) {
|
||||
const label = getTimePeriodLabel(new Date(doc.createdAt), now)
|
||||
if (!periodMap.has(label)) {
|
||||
periodMap.set(label, [])
|
||||
periodOrder.push(label)
|
||||
}
|
||||
periodMap.get(label)?.push(doc)
|
||||
}
|
||||
|
||||
return periodOrder.map((label) => {
|
||||
const docs = periodMap.get(label)!
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{ info: CategoryInfo; docs: DocumentWithMemories[] }
|
||||
>()
|
||||
const categoryOrder: string[] = []
|
||||
|
||||
for (const doc of docs) {
|
||||
const info = getDocumentTypeInfo(doc)
|
||||
if (!categoryMap.has(info.key)) {
|
||||
categoryMap.set(info.key, { info, docs: [] })
|
||||
categoryOrder.push(info.key)
|
||||
}
|
||||
categoryMap.get(info.key)?.docs.push(doc)
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
typeGroups: categoryOrder.map((key) => {
|
||||
const entry = categoryMap.get(key)!
|
||||
return { categoryInfo: entry.info, docs: entry.docs }
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Individual timeline card ─────────────────────────────────────────────────
|
||||
|
||||
function TimelineCard({
|
||||
doc,
|
||||
onOpenDocument,
|
||||
indent = false,
|
||||
}: {
|
||||
doc: DocumentWithMemories
|
||||
onOpenDocument: (doc: DocumentWithMemories) => void
|
||||
indent?: boolean
|
||||
}) {
|
||||
const preview = getPreviewText(doc)
|
||||
const typeLabel = doc.type
|
||||
? doc.type.charAt(0).toUpperCase() + doc.type.slice(1).replace(/_/g, " ")
|
||||
: "Document"
|
||||
const totalMemories = doc.memoryEntries.length
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left px-4 py-3 cursor-pointer transition-colors",
|
||||
indent
|
||||
? "bg-transparent hover:bg-white/[0.04]"
|
||||
: "rounded-2xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => onOpenDocument(doc)}
|
||||
>
|
||||
{/* Type label */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<DocumentIcon
|
||||
type={doc.type}
|
||||
source={doc.source ?? undefined}
|
||||
url={doc.url ?? undefined}
|
||||
className="w-3.5 h-3.5 shrink-0 opacity-60"
|
||||
/>
|
||||
<span className="text-[10px] text-white/40 uppercase tracking-widest">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{doc.title && (
|
||||
<p className="text-[13px] text-white/85 font-medium leading-snug line-clamp-2 mb-1.5">
|
||||
{doc.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview && (
|
||||
<p className="text-[12px] text-white/45 line-clamp-3 leading-relaxed">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{totalMemories > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2.5">
|
||||
<SyncLogoIcon
|
||||
className="w-[11px] h-[9px]"
|
||||
style={{
|
||||
filter:
|
||||
"brightness(0) saturate(100%) invert(58%) sepia(69%) saturate(535%) hue-rotate(181deg) brightness(101%) contrast(98%)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-[11px] font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{totalMemories}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Collapsed group card ─────────────────────────────────────────────────────
|
||||
|
||||
function GroupCard({
|
||||
group,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onOpenDocument,
|
||||
expandKey,
|
||||
}: {
|
||||
group: TypeGroup
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onOpenDocument: (doc: DocumentWithMemories) => void
|
||||
expandKey: string
|
||||
}) {
|
||||
const firstDoc = group.docs[0]!
|
||||
const preview = getPreviewText(firstDoc)
|
||||
const count = group.docs.length
|
||||
const { label, singularLabel } = group.categoryInfo
|
||||
const countLabel = count === 1 ? `1 ${singularLabel}` : `${count} ${label}`
|
||||
const totalMemories = group.docs.reduce(
|
||||
(sum, d) => sum + d.memoryEntries.length,
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left rounded-2xl px-4 py-3 cursor-pointer transition-colors",
|
||||
"border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
||||
"flex items-center justify-between gap-3",
|
||||
isExpanded && "rounded-b-none border-b-transparent",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<DocumentIcon
|
||||
type={firstDoc.type}
|
||||
source={firstDoc.source ?? undefined}
|
||||
url={firstDoc.url ?? undefined}
|
||||
className="w-3.5 h-3.5 shrink-0 opacity-60"
|
||||
/>
|
||||
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
|
||||
{countLabel}
|
||||
</span>
|
||||
{preview && (
|
||||
<span className="text-[12px] text-white/35 truncate">
|
||||
— {preview}
|
||||
</span>
|
||||
)}
|
||||
{totalMemories > 0 && (
|
||||
<span
|
||||
className="text-[11px] font-medium shrink-0 ml-auto"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{totalMemories}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
id={`group-${expandKey}`}
|
||||
className="border border-t-0 border-[#252B35] rounded-b-2xl overflow-hidden divide-y divide-[#252B35]"
|
||||
>
|
||||
{group.docs.map((doc) => (
|
||||
<TimelineCard
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onOpenDocument={onOpenDocument}
|
||||
indent
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main TimelineView ────────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineViewProps {
|
||||
documents: DocumentWithMemories[]
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
hasNextPage?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
onLoadMore?: () => void
|
||||
}
|
||||
|
||||
export function TimelineView({
|
||||
documents,
|
||||
onOpenDocument,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
}: TimelineViewProps) {
|
||||
const [now] = useState(() => new Date())
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sentinelRef.current || !onLoadMore) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(sentinelRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [hasNextPage, isFetchingNextPage, onLoadMore])
|
||||
|
||||
const toggleGroup = useCallback((key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const periodGroups = groupDocuments(documents, now)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-[780px] mx-auto py-4 pb-12 space-y-6",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{periodGroups.map((period) => (
|
||||
<div key={period.label} className="grid grid-cols-[88px_1fr] gap-x-4">
|
||||
<div className="pt-3 text-right shrink-0">
|
||||
<span className="text-[10px] text-white/30 font-medium uppercase tracking-[0.15em] leading-none">
|
||||
{period.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
{period.typeGroups.map((group) => {
|
||||
const expandKey = `${period.label}::${group.categoryInfo.key}`
|
||||
|
||||
if (group.docs.length === 1) {
|
||||
return (
|
||||
<TimelineCard
|
||||
key={expandKey}
|
||||
doc={group.docs[0]!}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={expandKey}
|
||||
group={group}
|
||||
expandKey={expandKey}
|
||||
isExpanded={expandedGroups.has(expandKey)}
|
||||
onToggle={() => toggleGroup(expandKey)}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
|
|
@ -11,25 +12,33 @@ import {
|
|||
} from "@ui/components/dropdown-menu"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { LogOut, Settings, RotateCcw, HelpCircle } from "lucide-react"
|
||||
import { LogOut, Settings, RotateCcw, HelpCircle, LifeBuoy } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
|
||||
export function UserProfileMenu({
|
||||
className,
|
||||
avatarClassName,
|
||||
onOpenFeedback,
|
||||
}: {
|
||||
className?: string
|
||||
avatarClassName?: string
|
||||
onOpenFeedback?: () => void
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const router = useRouter()
|
||||
const { resetOrgOnboarded } = useOrgOnboarding()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
|
||||
|
||||
const planBadgeLabel =
|
||||
currentPlan === "pro" ? "PRO" : currentPlan === "scale" ? "SCALE" : null
|
||||
|
||||
const handleTryOnboarding = () => {
|
||||
resetOrgOnboarded()
|
||||
router.push("/onboarding?step=input&flow=welcome")
|
||||
router.push("/onboarding")
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
|
|
@ -45,27 +54,72 @@ export function UserProfileMenu({
|
|||
|
||||
if (!user) return null
|
||||
|
||||
const initials = (() => {
|
||||
if (user.name) {
|
||||
const parts = user.name.trim().split(/\s+/)
|
||||
return parts.length >= 2
|
||||
? `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
|
||||
: parts[0].slice(0, 2).toUpperCase()
|
||||
}
|
||||
if (user.email) return user.email.slice(0, 2).toUpperCase()
|
||||
return "SM"
|
||||
})()
|
||||
|
||||
const avatarColor = (() => {
|
||||
const palette = [
|
||||
"#0e2244", // navy blue
|
||||
"#1a1a3e", // deep indigo
|
||||
"#1e1030", // dark violet
|
||||
"#0d2e2e", // dark teal
|
||||
"#2a1020", // dark rose
|
||||
"#1a2a10", // deep forest
|
||||
"#2e1a0a", // dark amber
|
||||
"#0a1e2e", // ocean
|
||||
]
|
||||
const seed = user.email ?? user.name ?? ""
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++)
|
||||
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
|
||||
return palette[((hash % palette.length) + palette.length) % palette.length]
|
||||
})()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
planBadgeLabel
|
||||
? `Account menu, ${planBadgeLabel} plan`
|
||||
: "Account menu"
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-transform hover:scale-105",
|
||||
"relative inline-flex shrink-0 rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
className={cn(
|
||||
"border border-[#2E3033] h-8 w-8 md:h-10 md:w-10",
|
||||
avatarClassName,
|
||||
)}
|
||||
className={cn("size-9 border border-[#161F2C]", avatarClassName)}
|
||||
>
|
||||
<AvatarImage src={user.image ?? ""} />
|
||||
<AvatarFallback className="bg-[#0D121A] text-white">
|
||||
{user.name?.charAt(0)}
|
||||
<AvatarFallback
|
||||
className="text-xs font-medium text-white"
|
||||
style={{ background: avatarColor }}
|
||||
>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!planLoading && planBadgeLabel ? (
|
||||
<span
|
||||
id="user-plan-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -bottom-0.5 left-1/2 z-10 -translate-x-1/2 rounded border px-1 py-px text-center text-[8px] font-bold uppercase leading-tight tracking-wide",
|
||||
"border-[#2261CA33] bg-[#00173C] text-[#6BB0FF]",
|
||||
)}
|
||||
>
|
||||
{planBadgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
|
@ -95,8 +149,17 @@ export function UserProfileMenu({
|
|||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 text-[#737373]" />
|
||||
Restart Onboarding
|
||||
Try onboarding
|
||||
</DropdownMenuItem>
|
||||
{onOpenFeedback ? (
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenFeedback}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<LifeBuoy className="h-4 w-4 text-[#737373]" />
|
||||
Feedback
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
|
||||
|
|
@ -18,20 +17,8 @@ export function useContainerTags() {
|
|||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const novaProjects = useMemo(
|
||||
() => allProjects.filter((p) => p.isNova),
|
||||
[allProjects],
|
||||
)
|
||||
|
||||
const novaContainerTags = useMemo(
|
||||
() => novaProjects.map((p) => p.containerTag),
|
||||
[novaProjects],
|
||||
)
|
||||
|
||||
return {
|
||||
allProjects,
|
||||
novaProjects,
|
||||
novaContainerTags,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ export function useDocumentMutations({
|
|||
description: "Your note is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
|
|
@ -356,6 +357,7 @@ export function useDocumentMutations({
|
|||
description: "Your link is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
|
|
@ -499,6 +501,7 @@ export function useDocumentMutations({
|
|||
analytics.documentAdded({ type: "file", project_id: variables.project })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
|
||||
if (data.failures.length === 0) {
|
||||
toast.success(
|
||||
data.successCount === 1
|
||||
|
|
|
|||
301
apps/web/hooks/use-personalization.ts
Normal file
301
apps/web/hooks/use-personalization.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { SearchResult } from "@repo/lib/api"
|
||||
|
||||
const CACHE_KEY = "sm_profession_v1"
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export type Profession =
|
||||
| "developer"
|
||||
| "finance"
|
||||
| "research"
|
||||
| "design"
|
||||
| "legal"
|
||||
| "marketing"
|
||||
| "medical"
|
||||
| "default"
|
||||
|
||||
export interface PersonalizedCopy {
|
||||
saveLink: string
|
||||
writeNote: string
|
||||
chatPlaceholder: string
|
||||
}
|
||||
|
||||
const COPY: Record<Profession, PersonalizedCopy> = {
|
||||
developer: {
|
||||
saveLink: "Save a repo",
|
||||
writeNote: "Write dev notes",
|
||||
chatPlaceholder: "Ask about your code, docs, or notes…",
|
||||
},
|
||||
finance: {
|
||||
saveLink: "Save an article",
|
||||
writeNote: "Log a thesis",
|
||||
chatPlaceholder: "Ask about your research or portfolio…",
|
||||
},
|
||||
research: {
|
||||
saveLink: "Save a paper",
|
||||
writeNote: "Write notes",
|
||||
chatPlaceholder: "Ask about your reading or research…",
|
||||
},
|
||||
design: {
|
||||
saveLink: "Save inspiration",
|
||||
writeNote: "Write a brief",
|
||||
chatPlaceholder: "What are you working on today?",
|
||||
},
|
||||
legal: {
|
||||
saveLink: "Save a document",
|
||||
writeNote: "Write a memo",
|
||||
chatPlaceholder: "Ask about your cases or contracts…",
|
||||
},
|
||||
marketing: {
|
||||
saveLink: "Save a resource",
|
||||
writeNote: "Write campaign notes",
|
||||
chatPlaceholder: "Ask about your campaigns or research…",
|
||||
},
|
||||
medical: {
|
||||
saveLink: "Save a study",
|
||||
writeNote: "Write clinical notes",
|
||||
chatPlaceholder: "Ask about your research or cases…",
|
||||
},
|
||||
default: {
|
||||
saveLink: "Save link",
|
||||
writeNote: "Write note",
|
||||
chatPlaceholder: "Ask your supermemory…",
|
||||
},
|
||||
}
|
||||
|
||||
const KEYWORDS: Record<Exclude<Profession, "default">, string[]> = {
|
||||
developer: [
|
||||
"software",
|
||||
"engineer",
|
||||
"developer",
|
||||
"programming",
|
||||
"code",
|
||||
"github",
|
||||
"typescript",
|
||||
"javascript",
|
||||
"python",
|
||||
"backend",
|
||||
"frontend",
|
||||
"api",
|
||||
"repository",
|
||||
"startup",
|
||||
"swe",
|
||||
"tech",
|
||||
"devops",
|
||||
"cloud",
|
||||
],
|
||||
finance: [
|
||||
"finance",
|
||||
"investment",
|
||||
"portfolio",
|
||||
"trading",
|
||||
"stock",
|
||||
"fund",
|
||||
"equity",
|
||||
"crypto",
|
||||
"banking",
|
||||
"analyst",
|
||||
"fintech",
|
||||
"hedge",
|
||||
"venture",
|
||||
"capital",
|
||||
"asset",
|
||||
"valuation",
|
||||
"economics",
|
||||
],
|
||||
research: [
|
||||
"research",
|
||||
"academia",
|
||||
"phd",
|
||||
"paper",
|
||||
"journal",
|
||||
"study",
|
||||
"scholar",
|
||||
"university",
|
||||
"professor",
|
||||
"scientist",
|
||||
"thesis",
|
||||
"experiment",
|
||||
"hypothesis",
|
||||
"data analysis",
|
||||
"publication",
|
||||
],
|
||||
design: [
|
||||
"design",
|
||||
"ux",
|
||||
"ui",
|
||||
"figma",
|
||||
"creative",
|
||||
"visual",
|
||||
"brand",
|
||||
"illustrator",
|
||||
"adobe",
|
||||
"typography",
|
||||
"wireframe",
|
||||
"prototype",
|
||||
"product design",
|
||||
"graphic",
|
||||
"art director",
|
||||
],
|
||||
legal: [
|
||||
"lawyer",
|
||||
"attorney",
|
||||
"legal",
|
||||
"law",
|
||||
"contract",
|
||||
"compliance",
|
||||
"litigation",
|
||||
"counsel",
|
||||
"paralegal",
|
||||
"court",
|
||||
"regulatory",
|
||||
"intellectual property",
|
||||
"patent",
|
||||
"trademark",
|
||||
],
|
||||
marketing: [
|
||||
"marketing",
|
||||
"growth",
|
||||
"seo",
|
||||
"content",
|
||||
"campaign",
|
||||
"brand",
|
||||
"advertising",
|
||||
"social media",
|
||||
"pr",
|
||||
"communications",
|
||||
"copywriting",
|
||||
"conversion",
|
||||
"analytics",
|
||||
"inbound",
|
||||
],
|
||||
medical: [
|
||||
"doctor",
|
||||
"physician",
|
||||
"medical",
|
||||
"healthcare",
|
||||
"clinical",
|
||||
"hospital",
|
||||
"nursing",
|
||||
"surgery",
|
||||
"patient",
|
||||
"medicine",
|
||||
"diagnosis",
|
||||
"treatment",
|
||||
"pharmacology",
|
||||
"dentist",
|
||||
],
|
||||
}
|
||||
|
||||
function classifyProfession(results: SearchResult[]): Profession {
|
||||
const text = results
|
||||
.flatMap((r) => [
|
||||
r.title ?? "",
|
||||
r.summary ?? "",
|
||||
...(r.chunks?.slice(0, 2).map((c) => c.content) ?? []),
|
||||
])
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
|
||||
const scores: Partial<Record<Profession, number>> = {}
|
||||
for (const [prof, words] of Object.entries(KEYWORDS)) {
|
||||
scores[prof as Profession] = words.filter((w) => text.includes(w)).length
|
||||
}
|
||||
|
||||
const best = (Object.entries(scores) as [Profession, number][]).sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
)[0]
|
||||
return best && best[1] > 0 ? best[0] : "default"
|
||||
}
|
||||
|
||||
let inflightPromise: Promise<void> | null = null
|
||||
|
||||
export function usePersonalization(): {
|
||||
copy: PersonalizedCopy
|
||||
profession: Profession
|
||||
setProfession: (p: Profession) => void
|
||||
} {
|
||||
const [copy, setCopy] = useState<PersonalizedCopy>(COPY.default)
|
||||
const [profession, setProfessionState] = useState<Profession>("default")
|
||||
|
||||
const setProfession = useCallback((p: Profession) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ profession: p, ts: Date.now() }),
|
||||
)
|
||||
} catch {}
|
||||
setCopy(COPY[p])
|
||||
setProfessionState(p)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
if (raw) {
|
||||
const { profession: cached, ts } = JSON.parse(raw) as {
|
||||
profession: Profession
|
||||
ts: number
|
||||
}
|
||||
if (Date.now() - ts < CACHE_TTL_MS && COPY[cached]) {
|
||||
setCopy(COPY[cached])
|
||||
setProfessionState(cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (inflightPromise) {
|
||||
inflightPromise.then(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
if (raw) {
|
||||
const { profession: cached } = JSON.parse(raw) as {
|
||||
profession: Profession
|
||||
}
|
||||
if (COPY[cached]) {
|
||||
setCopy(COPY[cached])
|
||||
setProfessionState(cached)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
inflightPromise = $fetch("@post/search", {
|
||||
body: {
|
||||
q: "career profession field industry background work role",
|
||||
limit: 8,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
const results = res.data?.results
|
||||
if (!results?.length) return
|
||||
const detected = classifyProfession(results)
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ profession: detected, ts: Date.now() }),
|
||||
)
|
||||
} catch {}
|
||||
setCopy(COPY[detected])
|
||||
setProfessionState(detected)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
inflightPromise = null
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { copy, profession, setProfession }
|
||||
}
|
||||
|
||||
export function clearPersonalizationCache() {
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
87
apps/web/hooks/use-processing-documents.ts
Normal file
87
apps/web/hooks/use-processing-documents.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useProject } from "@/stores"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const MAX_POLLS = 60
|
||||
const POLL_INTERVAL_MS = 5_000
|
||||
|
||||
export function useProcessingDocuments() {
|
||||
const { user } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const queryClient = useQueryClient()
|
||||
const prevIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["processing-documents", effectiveContainerTags],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/documents/processing", {
|
||||
query: { containerTags: effectiveContainerTags },
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) return { documents: [], totalCount: 0 }
|
||||
return response.data ?? { documents: [], totalCount: 0 }
|
||||
},
|
||||
enabled: !!user,
|
||||
refetchInterval: (query) => {
|
||||
const count =
|
||||
(query.state.data as { totalCount?: number } | undefined)?.totalCount ??
|
||||
0
|
||||
const polls = query.state.dataUpdateCount
|
||||
if (count === 0 || polls >= MAX_POLLS) return false
|
||||
return POLL_INTERVAL_MS
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const docs =
|
||||
(
|
||||
data as
|
||||
| { documents?: Array<{ id?: string | null; status?: string | null }> }
|
||||
| undefined
|
||||
)?.documents ?? []
|
||||
|
||||
const processingMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const doc of docs) {
|
||||
if (doc.id && doc.status) {
|
||||
map.set(doc.id, doc.status)
|
||||
}
|
||||
}
|
||||
return map
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docs])
|
||||
|
||||
// Detect docs that just finished (present in previous poll, absent now).
|
||||
// Done here — not in the card — because card remounts reset per-card refs
|
||||
// and lose the transition signal.
|
||||
useEffect(() => {
|
||||
const prev = prevIdsRef.current
|
||||
const current = new Set(processingMap.keys())
|
||||
prevIdsRef.current = current
|
||||
|
||||
const justFinished = [...prev].filter((id) => !current.has(id))
|
||||
if (justFinished.length === 0) return
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.refetchQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.refetchQueries({ queryKey: ["dashboard-recents"] })
|
||||
}
|
||||
|
||||
// First pass: give the backend ~1s to finish writing memory entries
|
||||
const t1 = setTimeout(refresh, 1000)
|
||||
// Second pass: insurance in case the first fetch still beat the writes
|
||||
const t2 = setTimeout(refresh, 4000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [processingMap.keys, queryClient.refetchQueries])
|
||||
|
||||
return processingMap
|
||||
}
|
||||
43
apps/web/hooks/use-reset-organization.ts
Normal file
43
apps/web/hooks/use-reset-organization.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
export function useResetOrganization() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: { confirmation: string }) => {
|
||||
const res = await $fetch("@post/settings/reset", {
|
||||
body,
|
||||
retry: { attempts: 0 },
|
||||
})
|
||||
if (res.error) {
|
||||
const e = res.error as Record<string, unknown>
|
||||
const msg =
|
||||
typeof e.error === "string"
|
||||
? e.error
|
||||
: typeof e.message === "string"
|
||||
? e.message
|
||||
: "Reset failed"
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!res.data?.success) throw new Error("Reset failed")
|
||||
return res.data
|
||||
},
|
||||
onSuccess: async () => {
|
||||
queryClient.invalidateQueries()
|
||||
// Clear the daily brief Cache API entry so stale highlights don't survive the reset
|
||||
try {
|
||||
await caches.delete("space-highlights-v1")
|
||||
} catch {
|
||||
// Cache API not available in all environments
|
||||
}
|
||||
toast.success("Organization data has been reset.")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to reset organization.")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -29,8 +29,9 @@ export const analytics = {
|
|||
chatHistoryViewed: () => safeCapture("chat_history_viewed"),
|
||||
chatDeleted: () => safeCapture("chat_deleted"),
|
||||
|
||||
viewModeChanged: (mode: "graph" | "list" | "integrations") =>
|
||||
safeCapture("view_mode_changed", { mode }),
|
||||
viewModeChanged: (
|
||||
mode: "dashboard" | "graph" | "list" | "integrations" | "chat",
|
||||
) => safeCapture("view_mode_changed", { mode }),
|
||||
|
||||
documentCardClicked: () => safeCapture("document_card_clicked"),
|
||||
|
||||
|
|
@ -117,8 +118,9 @@ export const analytics = {
|
|||
}) => safeCapture("highlight_clicked", props),
|
||||
|
||||
// chat analytics
|
||||
chatMessageSent: (props: { source: "typed" | "suggested" | "highlight" }) =>
|
||||
safeCapture("chat_message_sent", props),
|
||||
chatMessageSent: (props: {
|
||||
source: "typed" | "suggested" | "highlight" | "home"
|
||||
}) => safeCapture("chat_message_sent", props),
|
||||
|
||||
chatSuggestedQuestionClicked: () =>
|
||||
safeCapture("chat_suggested_question_clicked"),
|
||||
|
|
|
|||
214
apps/web/lib/chat-highlight-documents.ts
Normal file
214
apps/web/lib/chat-highlight-documents.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { memoryResultsFromSearchToolOutput } from "@/lib/chat-search-memory-results"
|
||||
|
||||
const UUID_IN_STRING =
|
||||
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
||||
|
||||
const UUID_STRICT =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
// Matches [doc:<id>] annotations emitted by sgrep when includeDocIds is enabled.
|
||||
// Supermemory uses NanoIDs (alphanumeric + _ -), not UUIDs.
|
||||
const DOC_ANNOTATION = /\[doc:([A-Za-z0-9_-]{10,40})\]/g
|
||||
|
||||
function collectIdsFromDynamicTool(part: Record<string, unknown>): string[] {
|
||||
const toolName = part.toolName
|
||||
if (!part.output) return []
|
||||
|
||||
if (toolName === "searchMemories") {
|
||||
return memoryResultsFromSearchToolOutput(part.output)
|
||||
.map((r) => r.documentId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
}
|
||||
|
||||
if (toolName === "bash") {
|
||||
return documentIdsFromBashText(
|
||||
extractBashOutputString(part.output as Record<string, unknown>),
|
||||
)
|
||||
}
|
||||
|
||||
const fromWalk: string[] = []
|
||||
collectDocumentIdsFromUnknown(part.output, fromWalk)
|
||||
return fromWalk
|
||||
}
|
||||
|
||||
function extractBashOutputString(output: Record<string, unknown>): string {
|
||||
const stdout = output.stdout
|
||||
return typeof stdout === "string" ? stdout : ""
|
||||
}
|
||||
|
||||
/** Heuristic: pull document IDs from bash stdout. Handles [doc:<id>] annotations, UUID patterns, and JSON documentId fields. */
|
||||
export function documentIdsFromBashText(text: string): string[] {
|
||||
const found = new Set<string>()
|
||||
// [doc:<nanoid>] annotations from sgrep --include-doc-ids (highest confidence)
|
||||
for (const m of text.matchAll(DOC_ANNOTATION)) {
|
||||
found.add(m[1])
|
||||
}
|
||||
// Standard UUID format
|
||||
for (const m of text.matchAll(UUID_IN_STRING)) {
|
||||
found.add(m[0].toLowerCase())
|
||||
}
|
||||
// JSON "documentId": "..." fields
|
||||
const quoted = /"documentId"\s*:\s*"([^"]+)"/g
|
||||
let q = quoted.exec(text)
|
||||
while (q !== null) {
|
||||
found.add(q[1])
|
||||
q = quoted.exec(text)
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
function collectDocumentIdsFromUnknown(value: unknown, out: string[]): void {
|
||||
const seen = new Set<string>()
|
||||
const walk = (v: unknown, depth: number) => {
|
||||
if (depth > 18) return
|
||||
if (v === null || v === undefined) return
|
||||
if (typeof v === "string") {
|
||||
if (v.length > 0 && v.length < 400000) {
|
||||
for (const id of documentIdsFromBashText(v)) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
for (const x of v) walk(x, depth + 1)
|
||||
return
|
||||
}
|
||||
if (typeof v !== "object") return
|
||||
const o = v as Record<string, unknown>
|
||||
|
||||
const docId = o.documentId
|
||||
if (
|
||||
typeof docId === "string" &&
|
||||
UUID_STRICT.test(docId) &&
|
||||
!seen.has(docId)
|
||||
) {
|
||||
seen.add(docId)
|
||||
out.push(docId)
|
||||
}
|
||||
|
||||
if (Array.isArray(o.documents)) {
|
||||
for (const d of o.documents) {
|
||||
if (!d || typeof d !== "object") continue
|
||||
const doc = d as Record<string, unknown>
|
||||
const id = doc.id
|
||||
if (typeof id === "string" && UUID_STRICT.test(id) && !seen.has(id)) {
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
"results",
|
||||
"memories",
|
||||
"chunks",
|
||||
"hits",
|
||||
"items",
|
||||
"data",
|
||||
]) {
|
||||
if (key in o) walk(o[key], depth + 1)
|
||||
}
|
||||
}
|
||||
walk(value, 0)
|
||||
}
|
||||
|
||||
function toolOutputReady(p: Record<string, unknown>): boolean {
|
||||
const s = p.state
|
||||
return (
|
||||
s === "output-available" ||
|
||||
s === "done" ||
|
||||
(s === undefined && p.output !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
/** Document IDs referenced by retrieval tools / sources in this thread. */
|
||||
export function extractHighlightDocumentIdsFromMessages(
|
||||
messages: UIMessage[],
|
||||
): string[] {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant") continue
|
||||
const parts = message.parts
|
||||
if (!parts) continue
|
||||
|
||||
for (const part of parts) {
|
||||
const p = part as Record<string, unknown>
|
||||
|
||||
if (p.type === "source-document") {
|
||||
const sid = (p as { sourceId?: unknown }).sourceId
|
||||
if (typeof sid === "string" && UUID_STRICT.test(sid)) {
|
||||
ids.add(sid)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (p.type === "tool-searchMemories" && toolOutputReady(p)) {
|
||||
for (const id of memoryResultsFromSearchToolOutput(p.output)
|
||||
.map((r) => r.documentId)
|
||||
.filter(Boolean)) {
|
||||
ids.add(id as string)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (p.type === "dynamic-tool" && toolOutputReady(p)) {
|
||||
for (const id of collectIdsFromDynamicTool(p)) {
|
||||
ids.add(id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
typeof p.type === "string" &&
|
||||
p.type.startsWith("tool-") &&
|
||||
toolOutputReady(p)
|
||||
) {
|
||||
const name = p.type.slice("tool-".length)
|
||||
if (name === "searchMemories") {
|
||||
for (const id of memoryResultsFromSearchToolOutput(p.output)
|
||||
.map((r) => r.documentId)
|
||||
.filter(Boolean)) {
|
||||
ids.add(id as string)
|
||||
}
|
||||
} else if (name === "bash") {
|
||||
const out = p.output as Record<string, unknown> | undefined
|
||||
const stdout = out && typeof out.stdout === "string" ? out.stdout : ""
|
||||
for (const id of documentIdsFromBashText(stdout)) {
|
||||
ids.add(id)
|
||||
}
|
||||
} else if (p.output) {
|
||||
const buf: string[] = []
|
||||
collectDocumentIdsFromUnknown(p.output, buf)
|
||||
for (const id of buf) ids.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.size === 0) {
|
||||
const lastAssistant = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant")
|
||||
const parts = lastAssistant?.parts
|
||||
if (parts) {
|
||||
const texts = parts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
let n = 0
|
||||
for (const m of texts.matchAll(UUID_IN_STRING)) {
|
||||
if (n >= 16) break
|
||||
ids.add(m[0].toLowerCase())
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids]
|
||||
}
|
||||
|
|
@ -23,12 +23,25 @@ export const shareParam = parseAsBoolean.withDefault(false)
|
|||
export const feedbackParam = parseAsBoolean.withDefault(false)
|
||||
|
||||
// View & filter states
|
||||
const viewLiterals = ["graph", "list", "integrations"] as const
|
||||
const integrationLiterals = ["import", "chrome", "connections"] as const
|
||||
const viewLiterals = [
|
||||
"dashboard",
|
||||
"graph",
|
||||
"list",
|
||||
"integrations",
|
||||
"chat",
|
||||
] as const
|
||||
const integrationLiterals = [
|
||||
"import",
|
||||
"chrome",
|
||||
"connections",
|
||||
"notion",
|
||||
"google-drive",
|
||||
] as const
|
||||
export type IntegrationParamValue = (typeof integrationLiterals)[number]
|
||||
export const integrationParam = parseAsStringLiteral(integrationLiterals)
|
||||
export type ViewParamValue = (typeof viewLiterals)[number]
|
||||
export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("list")
|
||||
export const viewParam =
|
||||
parseAsStringLiteral(viewLiterals).withDefault("dashboard")
|
||||
|
||||
export const pluginsPanelParam = parseAsBoolean
|
||||
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5 MiB |
|
|
@ -2,30 +2,18 @@
|
|||
|
||||
import { useQueryState } from "nuqs"
|
||||
import { projectParam } from "@/lib/search-params"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useCallback } from "react"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
|
||||
export function useProject() {
|
||||
const [selectedProjects, _setSelectedProjects] = useQueryState(
|
||||
"project",
|
||||
projectParam,
|
||||
)
|
||||
const { novaContainerTags } = useContainerTags()
|
||||
|
||||
const isNovaSpaces = selectedProjects.length === 0
|
||||
const selectedProject = selectedProjects[0] ?? DEFAULT_PROJECT_ID
|
||||
|
||||
const selectedProject = isNovaSpaces
|
||||
? DEFAULT_PROJECT_ID
|
||||
: (selectedProjects[0] ?? DEFAULT_PROJECT_ID)
|
||||
|
||||
// Get effective container tags for API calls
|
||||
// When "Nova Spaces" is selected, use all nova container tags
|
||||
// Otherwise, use the selected projects
|
||||
const effectiveContainerTags = useMemo(
|
||||
() => (isNovaSpaces ? novaContainerTags : selectedProjects),
|
||||
[isNovaSpaces, novaContainerTags, selectedProjects],
|
||||
)
|
||||
const effectiveContainerTags = selectedProjects
|
||||
|
||||
const setSelectedProjects = useCallback(
|
||||
(projects: string[]) => {
|
||||
|
|
@ -46,9 +34,7 @@ export function useProject() {
|
|||
selectedProject,
|
||||
setSelectedProjects,
|
||||
setSelectedProject,
|
||||
isNovaSpaces,
|
||||
effectiveContainerTags,
|
||||
novaContainerTags,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
MemoryResponseSchema,
|
||||
MigrateMCPRequestSchema,
|
||||
MigrateMCPResponseSchema,
|
||||
ProcessingDocumentsResponseSchema,
|
||||
ProjectSchema,
|
||||
SearchRequestSchema,
|
||||
SearchResponseSchema,
|
||||
|
|
@ -132,6 +133,19 @@ export const apiSchema = createSchema({
|
|||
input: SettingsRequestSchema,
|
||||
output: SettingsResponseSchema,
|
||||
},
|
||||
"@post/settings/reset": {
|
||||
input: z.object({ confirmation: z.string() }),
|
||||
output: z.object({
|
||||
success: z.boolean(),
|
||||
deletedConnections: z.number(),
|
||||
deletedDocumentBatches: z.number(),
|
||||
deletedDocumentsApprox: z.number(),
|
||||
deletedMemoryRows: z.number(),
|
||||
deletedExtraSpaces: z.number(),
|
||||
clearedDefaultSpaceContext: z.boolean(),
|
||||
settingsReset: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Memory operations
|
||||
"@post/documents": {
|
||||
input: MemoryAddSchema,
|
||||
|
|
@ -165,6 +179,15 @@ export const apiSchema = createSchema({
|
|||
output: MigrateMCPResponseSchema,
|
||||
},
|
||||
|
||||
"@get/documents/processing": {
|
||||
output: ProcessingDocumentsResponseSchema,
|
||||
query: z
|
||||
.object({
|
||||
containerTags: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
},
|
||||
|
||||
"@get/documents/:id": {
|
||||
output: z.any(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ describe("generateMockGraphData", () => {
|
|||
const data2 = generateMockGraphData({ documentCount: 10, seed: 42 })
|
||||
|
||||
expect(data1.documents.length).toBe(data2.documents.length)
|
||||
expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id)
|
||||
expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title)
|
||||
expect(data1.documents[0]?.id).toBe(data2.documents[0]?.id)
|
||||
expect(data1.documents[0]?.title).toBe(data2.documents[0]?.title)
|
||||
})
|
||||
|
||||
it("produces different output with different seeds", () => {
|
||||
|
|
@ -43,8 +43,9 @@ describe("generateMockGraphData", () => {
|
|||
const data = generateMockGraphData({ documentCount: 5, seed: 1 })
|
||||
const doc = data.documents.find((d) => d.memories.length > 0)
|
||||
expect(doc).toBeDefined()
|
||||
if (!doc) return
|
||||
|
||||
for (const mem of doc!.memories) {
|
||||
for (const mem of doc.memories) {
|
||||
expect(mem.id).toBeDefined()
|
||||
expect(mem.memory).toBeDefined()
|
||||
expect(typeof mem.isStatic).toBe("boolean")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ export interface RenderState {
|
|||
// Module-level reusable batch map – cleared each frame instead of reallocating
|
||||
const edgeBatches = new Map<string, PreparedEdge[]>()
|
||||
|
||||
function nodeMatchesDocumentHighlights(
|
||||
node: GraphNode,
|
||||
highlightIds: Set<string>,
|
||||
): boolean {
|
||||
if (highlightIds.size === 0) return false
|
||||
if (node.type === "document") return highlightIds.has(node.id)
|
||||
return highlightIds.has((node.data as MemoryNodeData).documentId)
|
||||
}
|
||||
|
||||
/** Group items by their `color` property into batches for efficient canvas drawing */
|
||||
function groupByColor<T extends { color: string }>(
|
||||
items: T[],
|
||||
|
|
@ -296,7 +305,13 @@ function drawNodes(
|
|||
|
||||
const isSelected = node.id === state.selectedNodeId
|
||||
const isHovered = node.id === state.hoveredNodeId
|
||||
const isHighlighted = state.highlightIds.has(node.id)
|
||||
const isHighlighted = nodeMatchesDocumentHighlights(
|
||||
node,
|
||||
state.highlightIds,
|
||||
)
|
||||
const highlightFocus = state.highlightIds.size > 0
|
||||
const fadeNonHighlights =
|
||||
highlightFocus && !isSelected && !isHovered && !isHighlighted
|
||||
|
||||
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
|
||||
if (node.type === "document") {
|
||||
|
|
@ -318,6 +333,9 @@ function drawNodes(
|
|||
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
|
||||
alpha = 1 - state.dimProgress * 0.7
|
||||
}
|
||||
if (fadeNonHighlights) {
|
||||
alpha *= 0.35
|
||||
}
|
||||
ctx.globalAlpha = alpha
|
||||
|
||||
if (node.type === "document") {
|
||||
|
|
@ -363,12 +381,13 @@ function drawNodes(
|
|||
state.selectedNodeId && state.dimProgress > 0
|
||||
? 1 - state.dimProgress * 0.7
|
||||
: 1
|
||||
const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1
|
||||
|
||||
if (docDots.length > 0) {
|
||||
ctx.fillStyle = colors.docFill
|
||||
ctx.strokeStyle = colors.docStroke
|
||||
ctx.lineWidth = 1
|
||||
ctx.globalAlpha = dimAlpha
|
||||
ctx.globalAlpha = dimAlpha * hlBatchMult
|
||||
for (const d of docDots) {
|
||||
const h = d.s * 0.5
|
||||
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
|
||||
|
|
@ -383,7 +402,7 @@ function drawNodes(
|
|||
|
||||
if (normalDots.length > 0) {
|
||||
// Subtle glow behind memory dots for luminous effect
|
||||
ctx.globalAlpha = dimAlpha * 0.25
|
||||
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.25
|
||||
for (const [color, batch] of groupByColor(normalDots)) {
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
|
|
@ -395,7 +414,7 @@ function drawNodes(
|
|||
}
|
||||
|
||||
// Filled dot
|
||||
ctx.globalAlpha = dimAlpha
|
||||
ctx.globalAlpha = dimAlpha * hlBatchMult
|
||||
ctx.fillStyle = colors.memFill
|
||||
ctx.beginPath()
|
||||
for (const d of normalDots) {
|
||||
|
|
@ -419,7 +438,7 @@ function drawNodes(
|
|||
|
||||
// Draw dimmed (superseded) memory dots at reduced opacity
|
||||
if (dimmedDots.length > 0) {
|
||||
ctx.globalAlpha = dimAlpha * 0.5
|
||||
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.5
|
||||
ctx.fillStyle = colors.memFill
|
||||
ctx.beginPath()
|
||||
for (const d of dimmedDots) {
|
||||
|
|
|
|||
|
|
@ -104,8 +104,24 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
|
|||
}, [nodes])
|
||||
|
||||
useEffect(() => {
|
||||
s.current.highlightIds = new Set(highlightDocumentIds ?? [])
|
||||
const ids = new Set(highlightDocumentIds ?? [])
|
||||
s.current.highlightIds = ids
|
||||
renderNeeded.current = true
|
||||
|
||||
if (ids.size === 0) return
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
const highlighted = s.current.nodes.filter((n) => {
|
||||
if (n.type === "document") return ids.has(n.id)
|
||||
const d = n.data as { documentId?: string }
|
||||
return typeof d.documentId === "string" && ids.has(d.documentId)
|
||||
})
|
||||
if (highlighted.length === 0) return
|
||||
vp.fitToNodes(
|
||||
highlighted.map((n) => ({ x: n.x, y: n.y, size: n.size ?? 24 })),
|
||||
s.current.width,
|
||||
s.current.height,
|
||||
)
|
||||
}, [highlightDocumentIds])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue