"use client" import { $fetch } from "@lib/api" import { fetchMemoriesFeature } from "@repo/lib/queries" import { Button } from "@repo/ui/components/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@repo/ui/components/dialog" import { Tabs, TabsList, TabsTrigger, TabsContent, } from "@repo/ui/components/tabs" import { Input } from "@repo/ui/components/input" import { Label } from "@repo/ui/components/label" import { Textarea } from "@repo/ui/components/textarea" import { useForm } from "@tanstack/react-form" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Dropzone, DropzoneContent, DropzoneEmptyState, } from "@ui/components/shadcn-io/dropzone" import { useCustomer } from "autumn-js/react" import { Brain, FileIcon, Link as LinkIcon, Loader2, PlugIcon, Plus, UploadIcon, } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import dynamic from "next/dynamic" import { useEffect, useState } from "react" import { toast } from "sonner" import { z } from "zod" import { analytics } from "@/lib/analytics" import { useProject } from "@/stores" import { ConnectionsTabContent } from "../connections-tab-content" import { ActionButtons } from "./action-buttons" import { MemoryUsageRing } from "./memory-usage-ring" import { ProjectSelection } from "./project-selection" const TextEditor = dynamic( () => import("./text-editor").then((mod) => ({ default: mod.TextEditor })), { loading: () => (
Loading editor...
), ssr: false, }, ) export function AddMemoryView({ onClose, initialTab = "note", }: { onClose?: () => void initialTab?: "note" | "link" | "file" | "connect" }) { const queryClient = useQueryClient() const { selectedProject, setSelectedProject } = useProject() const [showAddDialog, setShowAddDialog] = useState(true) const [selectedFiles, setSelectedFiles] = useState([]) const [activeTab, setActiveTab] = useState< "note" | "link" | "file" | "connect" >(initialTab) const autumn = useCustomer() const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false) const [newProjectName, setNewProjectName] = useState("") // Check memory limits const { data: memoriesCheck } = fetchMemoriesFeature( autumn, !autumn.isLoading, ) const memoriesUsed = memoriesCheck?.usage ?? 0 const memoriesLimit = memoriesCheck?.included_usage ?? 0 // Fetch projects for the dropdown const { data: projects = [], isLoading: isLoadingProjects } = useQuery({ queryKey: ["projects"], queryFn: async () => { const response = await $fetch("@get/projects") if (response.error) { throw new Error(response.error?.message || "Failed to load projects") } return response.data?.projects || [] }, staleTime: 30 * 1000, }) // Create project mutation const createProjectMutation = useMutation({ mutationFn: async (name: string) => { const response = await $fetch("@post/projects", { body: { name }, }) if (response.error) { throw new Error(response.error?.message || "Failed to create project") } return response.data }, onSuccess: (data) => { analytics.projectCreated() toast.success("Project created successfully!") setShowCreateProjectDialog(false) setNewProjectName("") queryClient.invalidateQueries({ queryKey: ["projects"] }) // Set the newly created project as selected if (data?.containerTag) { setSelectedProject(data.containerTag) // Update form values addContentForm.setFieldValue("project", data.containerTag) fileUploadForm.setFieldValue("project", data.containerTag) } }, onError: (error) => { toast.error("Failed to create project", { description: error instanceof Error ? error.message : "Unknown error", }) }, }) const addContentForm = useForm({ defaultValues: { content: "", project: selectedProject || "sm_project_default", }, onSubmit: async ({ value, formApi }) => { addContentMutation.mutate({ content: value.content, project: value.project, contentType: activeTab as "note" | "link", }) formApi.reset() }, validators: { onChange: z.object({ content: z.string().min(1, "Content is required"), project: z.string(), }), }, }) // Re-validate content field when tab changes between note/link // biome-ignore lint/correctness/useExhaustiveDependencies: It is what it is useEffect(() => { // Trigger validation of the content field when switching between note/link if (activeTab === "note" || activeTab === "link") { const currentValue = addContentForm.getFieldValue("content") if (currentValue) { addContentForm.validateField("content", "change") } } }, [activeTab]) // Form for file upload metadata const fileUploadForm = useForm({ defaultValues: { title: "", description: "", project: selectedProject || "sm_project_default", }, onSubmit: async ({ value, formApi }) => { if (selectedFiles.length === 0) { toast.error("Please select a file to upload") return } for (const file of selectedFiles) { fileUploadMutation.mutate({ file, title: value.title || undefined, description: value.description || undefined, project: value.project, }) } formApi.reset() setSelectedFiles([]) }, }) const addContentMutation = useMutation({ mutationFn: async ({ content, project, contentType, }: { content: string project: string contentType: "note" | "link" }) => { // close the modal onClose?.() const processingPromise = (async () => { // First, create the memory const response = await $fetch("@post/documents", { body: { content: content, containerTags: [project], metadata: { sm_source: "consumer", // Use "consumer" source to bypass limits }, }, }) if (response.error) { throw new Error( response.error?.message || `Failed to add ${contentType}`, ) } const memoryId = response.data.id // Polling function to check status const pollForCompletion = async (): Promise => { let attempts = 0 const maxAttempts = 60 // Maximum 5 minutes (60 attempts * 5 seconds) while (attempts < maxAttempts) { try { const memory = await $fetch<{ status: string; content: string }>( `@get/documents/${memoryId}`, ) if (memory.error) { throw new Error( memory.error?.message || "Failed to fetch memory status", ) } // Check if processing is complete // Adjust this condition based on your API response structure if ( memory.data?.status === "done" || // Sometimes the memory might be ready when it has content and no processing status memory.data?.content ) { return memory.data } // If still processing, wait and try again await new Promise((resolve) => setTimeout(resolve, 5000)) // Wait 5 seconds attempts++ } catch (error) { console.error("Error polling memory status:", error) // Don't throw immediately, retry a few times if (attempts >= 3) { throw new Error("Failed to check processing status") } await new Promise((resolve) => setTimeout(resolve, 5000)) attempts++ } } // If we've exceeded max attempts, throw an error throw new Error( "Memory processing timed out. Please check back later.", ) } // Wait for completion const completedMemory = await pollForCompletion() return completedMemory })() toast.promise(processingPromise, { loading: "Processing...", success: `${contentType === "link" ? "Link" : "Note"} created successfully!`, error: (err) => `Failed to add ${contentType}: ${err instanceof Error ? err.message : "Unknown error"}`, }) return processingPromise }, onMutate: async ({ content, project, contentType }) => { console.log("🚀 onMutate starting...") // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project], }) console.log("✅ Cancelled queries") // Snapshot the previous value const previousMemories = queryClient.getQueryData([ "documents-with-memories", project, ]) console.log("📸 Previous memories:", previousMemories) // Create optimistic memory const optimisticMemory = { id: `temp-${Date.now()}`, content: contentType === "link" ? "" : content, url: contentType === "link" ? content : null, title: contentType === "link" ? "Processing..." : content.substring(0, 100), description: contentType === "link" ? "Extracting content..." : "Processing content...", containerTags: [project], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), status: "queued", type: contentType, metadata: { processingStage: "queued", processingMessage: "Added to processing queue", }, memoryEntries: [], isOptimistic: true, } console.log("🎯 Created optimistic memory:", optimisticMemory) // Optimistically update to include the new memory queryClient.setQueryData( ["documents-with-memories", project], (old: any) => { console.log("🔄 Old data:", old) const newData = old ? { ...old, documents: [optimisticMemory, ...(old.documents || [])], totalCount: (old.totalCount || 0) + 1, } : { documents: [optimisticMemory], totalCount: 1 } console.log("✨ New data:", newData) return newData }, ) console.log("✅ onMutate completed") return { previousMemories, optimisticId: optimisticMemory.id } }, // If the mutation fails, roll back to the previous value onError: (_error, variables, context) => { if (context?.previousMemories) { queryClient.setQueryData( ["documents-with-memories", variables.project], context.previousMemories, ) } }, onSuccess: (_data, variables) => { analytics.memoryAdded({ type: variables.contentType === "link" ? "link" : "note", project_id: variables.project, content_length: variables.content.length, }) queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project], }) setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project], }) }, 30000) // 30 seconds setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project], }) }, 120000) // 2 minutes setShowAddDialog(false) onClose?.() }, }) const fileUploadMutation = useMutation({ mutationFn: async ({ file, title, description, project, }: { file: File title?: string description?: string project: string }) => { // TEMPORARILY DISABLED: Limit check disabled // Check if user can add more memories // if (!canAddMemory && !isProUser) { // throw new Error( // `Free plan limit reached (${memoriesLimit} memories). Upgrade to Pro for up to 500 memories.`, // ); // } const formData = new FormData() formData.append("file", file) formData.append("containerTags", JSON.stringify([project])) formData.append( "metadata", JSON.stringify({ sm_source: "consumer", }), ) const response = await fetch( `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`, { method: "POST", body: formData, credentials: "include", }, ) if (!response.ok) { const error = await response.json() throw new Error(error.error || "Failed to upload file") } const data = await response.json() // If we have metadata, we can update the document after creation if (title || description) { await $fetch(`@patch/documents/${data.id}`, { body: { metadata: { ...(title && { title }), ...(description && { description }), sm_source: "consumer", // Use "consumer" source to bypass limits }, }, }) } return data }, // Optimistic update onMutate: async ({ file, title, description, project }) => { // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project], }) // Snapshot the previous value const previousMemories = queryClient.getQueryData([ "documents-with-memories", project, ]) // Create optimistic memory for the file const optimisticMemory = { id: `temp-file-${Date.now()}`, content: "", url: null, title: title || file.name, description: description || `Uploading ${file.name}...`, containerTags: [project], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), status: "processing", type: "file", metadata: { fileName: file.name, fileSize: file.size, mimeType: file.type, }, memoryEntries: [], } // Optimistically update to include the new memory queryClient.setQueryData( ["documents-with-memories", project], (old: any) => { if (!old) return { documents: [optimisticMemory], totalCount: 1 } return { ...old, documents: [optimisticMemory, ...(old.documents || [])], totalCount: (old.totalCount || 0) + 1, } }, ) // Return a context object with the snapshotted value return { previousMemories } }, // If the mutation fails, roll back to the previous value onError: (error, variables, context) => { if (context?.previousMemories) { queryClient.setQueryData( ["documents-with-memories", variables.project], context.previousMemories, ) } toast.error("Failed to upload file", { description: error instanceof Error ? error.message : "Unknown error", }) }, onSuccess: (_data, variables) => { analytics.memoryAdded({ type: "file", project_id: variables.project, file_size: variables.file.size, file_type: variables.file.type, }) toast.success("File uploaded successfully!", { description: "Your file is being processed", }) setShowAddDialog(false) onClose?.() }, // Always refetch after error or success onSettled: () => { queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] }) }, }) return ( {showAddDialog && ( { setShowAddDialog(open) if (!open) onClose?.() }} open={showAddDialog} > Memory Dialog Add Memory setActiveTab(value as typeof activeTab)} className="flex flex-row gap-4" orientation="vertical" >
Note
Write down your thoughts
Link
Save any webpage
File
Upload any file
Connect
Connect to your favorite apps
{ e.preventDefault() e.stopPropagation() addContentForm.handleSubmit() }} className="h-full flex flex-col" >
{/* Note Input */} { if (!value || value.trim() === "") { return "Note is required" } return undefined }, }} > {({ state, handleChange, handleBlur }) => ( <>
{state.meta.errors.length > 0 && ( {state.meta.errors .map((error) => typeof error === "string" ? error : (error?.message ?? `Error: ${JSON.stringify(error)}`), ) .join(", ")} )} )}
{/* Project Selection */} {({ state, handleChange }) => ( setShowCreateProjectDialog(true) } onProjectChange={handleChange} projects={projects} selectedProject={state.value} /> )}
{ setShowAddDialog(false) onClose?.() addContentForm.reset() }} submitIcon={Plus} submitText="Add Note" />
{ e.preventDefault() e.stopPropagation() addContentForm.handleSubmit() }} className="h-full flex flex-col" >
{/* Link Input */} { if (!value || value.trim() === "") { return "Link is required" } try { new URL(value) return undefined } catch { return "Please enter a valid link" } }, }} > {({ state, handleChange, handleBlur }) => ( <> handleChange(e.target.value)} placeholder="https://example.com/article" value={state.value} /> {state.meta.errors.length > 0 && ( {state.meta.errors .map((error) => typeof error === "string" ? error : (error?.message ?? `Error: ${JSON.stringify(error)}`), ) .join(", ")} )} )}
{/* Left side - Project Selection */} {({ state, handleChange }) => ( setShowCreateProjectDialog(true) } onProjectChange={handleChange} projects={projects} selectedProject={state.value} /> )}
{ setShowAddDialog(false) onClose?.() addContentForm.reset() }} submitIcon={Plus} submitText="Add Link" />
{ e.preventDefault() e.stopPropagation() fileUploadForm.handleSubmit() }} className="h-full flex flex-col" >
setSelectedFiles(acceptedFiles) } src={selectedFiles} > {({ state, handleChange, handleBlur }) => ( handleChange(e.target.value)} placeholder="Give this file a title" value={state.value} /> )} {({ state, handleChange, handleBlur }) => (