Merge remote-tracking branch 'origin/main' into codex-pr-973

# Conflicts:
#	packages/memory-graph/src/components/memory-graph.tsx
This commit is contained in:
Ishaan Gupta 2026-05-29 19:52:51 +05:30
commit 73f9c16df1
72 changed files with 9434 additions and 3066 deletions

View file

@ -4,7 +4,7 @@ description: "Connect Amazon S3 or S3-compatible storage to sync files into your
icon: "aws"
---
Connect Amazon S3 buckets or S3-compatible storage services (MinIO, DigitalOcean Spaces, Cloudflare R2) to sync files into your Supermemory knowledge base.
Connect Amazon S3 buckets or S3-compatible storage services (MinIO, DigitalOcean Spaces, Cloudflare R2, Tigris) to sync files into your Supermemory knowledge base.
<Note>
The S3 connector requires a **Scale Plan** or higher. You can also create S3 connections directly from the [Supermemory Console](https://console.supermemory.ai).
@ -78,7 +78,7 @@ For S3, provider-specific connection fields are passed inside the top-level `met
| `accessKeyId` | `metadata.accessKeyId` | Yes | AWS access key ID or S3-compatible service key |
| `secretAccessKey` | `metadata.secretAccessKey` | Yes | AWS secret access key |
| `bucket` | `metadata.bucket` | Yes | S3 bucket name |
| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for Cloudflare R2. |
| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for S3-compatible providers that don't expose AWS-style regions (MinIO, R2, Tigris). |
| `endpoint` | `metadata.endpoint` | No | Custom endpoint for S3-compatible services |
| `prefix` | `metadata.prefix` | No | Key prefix filter (e.g., `documents/`) |
| `containerTagRegex` | `metadata.containerTagRegex` | No | Regex to extract container tags from file paths |
@ -91,7 +91,7 @@ In the Python SDK, use `container_tags` for the top-level option, but keep S3 me
## S3-Compatible Services
Use `metadata.endpoint` to connect to S3-compatible storage:
Use `metadata.endpoint` to connect to S3-compatible storage. These services don't use AWS-style regions, so set `metadata.region` to `auto` — the value is still required for request signing but the service ignores it.
```typescript
// MinIO
@ -100,7 +100,7 @@ const connection = await client.connections.create('s3', {
accessKeyId: 'minio-key',
secretAccessKey: 'minio-secret',
bucket: 'my-bucket',
region: 'us-east-1',
region: 'auto',
endpoint: 'https://minio.example.com'
},
containerTags: ['minio-sync']
@ -113,6 +113,7 @@ Common S3-compatible endpoint values:
|---------|----------------------|-------------------|
| DigitalOcean Spaces | `https://nyc3.digitaloceanspaces.com` | `nyc3` |
| Cloudflare R2 | `https://<account-id>.r2.cloudflarestorage.com` | `auto` |
| Tigris | `https://t3.storage.dev` | `auto` |
Cloudflare R2 example:

View file

@ -46,7 +46,10 @@ export SUPERMEMORY_API_KEY="sm_..."
// PATCH https://api.supermemory.ai/v3/settings
fetch('https://api.supermemory.ai/v3/settings', {
method: 'PATCH',
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
headers: {
'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
shouldLLMFilter: true,
filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].`
@ -210,17 +213,20 @@ client.add(content=f"user: {user_message}\\nassistant: {response}", container_ta
```bash
# Add memory
curl -X POST https://api.supermemory.ai/v3/documents \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"content": "conversation", "containerTag": "userId"}'
# Get profile
curl -X POST https://api.supermemory.ai/v4/profile \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"containerTag": "userId", "q": "search query"}'
# Search
curl -X POST https://api.supermemory.ai/v4/search \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}'
```
@ -234,7 +240,10 @@ formData.append('containerTag', userId)
await fetch('https://api.supermemory.ai/v3/documents/file', {
method: 'POST',
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
headers: {
'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`,
'Content-Type': 'application/json',
},
body: formData
})
@ -282,17 +291,20 @@ await client.search({
```bash
# 1. Configure settings
curl -X PATCH https://api.supermemory.ai/v3/settings \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"shouldLLMFilter": true, "filterPrompt": "..."}'
# 2. Add test memory
curl -X POST https://api.supermemory.ai/v3/documents \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"content": "Test", "containerTag": "test_user"}'
# 3. Get profile
curl -X POST https://api.supermemory.ai/v4/profile \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"containerTag": "test_user"}'
```

View file

@ -52,6 +52,39 @@ You are integrating Supermemory into my application. Supermemory provides user m
Note: You can always reference the documentation by using the **SearchSupermemoryDocs MCP** or running a web search tool for content on **supermemory.ai/docs**.
CANONICAL API SURFACE (use these, nothing else):
- Auth header: `Authorization: Bearer $SUPERMEMORY_API_KEY` — the only supported auth header
- Write content: POST https://api.supermemory.ai/v3/documents
- Search: POST https://api.supermemory.ai/v4/search
- Profile + search: POST https://api.supermemory.ai/v4/profile
- Settings: PATCH https://api.supermemory.ai/v3/settings
- Scoping: `containerTag` (singular string) in the JSON body — never in a header
- SDK: `client.documents.add()`, `client.search.memories()`, `client.profile()`
DO NOT USE — these are deprecated, undocumented, or fabricated by previous AI codegen:
- Endpoints: /v1/anything, /v3/memories, /v3/search (use /v3/documents and /v4/search)
- Headers: x-supermemory-api-key, x-api-key, x-sm-user-id, x-sm-project,
x-project-id, X-Workspace-Id (always use Authorization: Bearer)
- Body keys: containerTags (plural array), userId, spaces, schema, container,
tags (top-level), filter (singular) (use containerTag + filters)
- SDK calls: client.search.execute, client.documents.add (use client.add),
client.documents.deleteBulk, client.documents.batch_add,
client.memories.updateMemory (the real method is client.memories.update)
- Kwargs: chunk_threshold (use `threshold`), sort, order, include_content,
include_full_docs, timeout (as an SDK kwarg)
NOTE on memory mutation: `client.memories.update`, `client.memories.delete`, and
`client.memories.forget` ARE real and supported — but most apps don't need them.
Memories are auto-extracted from documents. Only reach for these if you're exposing
a "manage my memories" UI to end users or agents.
- Mixing: `rerank` and `rewriteQuery` are valid on /v4/search ONLY — never on /v3/search
SCOPING IS LOAD-BEARING. Every write and every search MUST include `containerTag`.
If you omit it, every user's data collapses into the API key's default bucket — this
is the single most common bug in AI-generated Supermemory integrations.
STEP 1: ASK ME THESE QUESTIONS
1. What are you building?
@ -91,29 +124,34 @@ export SUPERMEMORY_API_KEY="sm_..."
STEP 3: CONFIGURE SETTINGS (DO THIS FIRST)
typescript
```typescript
// PATCH https://api.supermemory.ai/v3/settings
fetch('https://api.supermemory.ai/v3/settings', {
method: 'PATCH',
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
headers: {
'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
shouldLLMFilter: true,
filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].`
})
})
```
STEP 4: CONTAINER TAG STRATEGY
Based on their data model answer:
USER-ONLY APP:
typescript
```typescript
containerTag: userId // Each user's memories are isolated
```
ORG-ONLY APP:
typescript
```typescript
containerTag: orgId // Org members share memories
```
BOTH (ask which):
- Option A: `containerTag: \`\${userId}-\${orgId}\``
@ -126,7 +164,7 @@ Based on their integration choice:
--- VERCEL AI SDK ---
typescript
```typescript
import { streamText } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'
import { supermemoryTools } from '@supermemory/tools/ai-sdk'
@ -136,7 +174,7 @@ const result = await streamText({
model: anthropic('claude-3-5-sonnet-20241022'),
prompt: userMessage,
tools: supermemoryTools(process.env.SUPERMEMORY_API_KEY, {
containerTags: [userId]
containerTag: userId // singular string — never an array
})
})
// Agent gets searchMemories, addMemory, fetchMemory tools
@ -153,11 +191,12 @@ const result = await generateText({
messages: [{ role: 'user', content: userMessage }]
})
// Profile is automatically injected into context
```
--- DIRECT SDK (WITH PROFILES) ---
typescript
```typescript
import Supermemory from 'supermemory'
const client = new Supermemory()
@ -187,7 +226,7 @@ await client.add({
content: `user: ${userMessage}\nassistant: ${response}`,
containerTag: userId
})
```
--- DIRECT SDK (NO PROFILES) ---
@ -218,9 +257,11 @@ await client.add({
content: `user: ${userMessage}\nassistant: ${response}`,
containerTag: userId
})
```
--- PYTHON VERSION ---
python
```python
from supermemory import Supermemory
client = Supermemory()
@ -238,28 +279,33 @@ Dynamic: {chr(10).join(profile_data.profile.dynamic)}
# Store conversation
client.add(content=f"user: {user_message}\\nassistant: {response}", container_tag=user_id)
```
--- DIRECT API ---
bash
```bash
# Add memory
curl -X POST https://api.supermemory.ai/v3/documents \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"content": "conversation", "containerTag": "userId"}'
# Get profile
curl -X POST https://api.supermemory.ai/v4/profile \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"containerTag": "userId", "q": "search query"}'
# Search
curl -X POST https://api.supermemory.ai/v4/search \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}'
```
STEP 6: FILE UPLOADS (if they need it)
typescript
```typescript
// Files are automatically extracted (PDFs, images with OCR, videos with transcription)
const formData = new FormData()
formData.append('file', fileBlob)
@ -267,28 +313,29 @@ formData.append('containerTag', userId)
await fetch('https://api.supermemory.ai/v3/documents/file', {
method: 'POST',
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
headers: { 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}` },
body: formData
})
// Processing is async - check status before assuming searchable
// GET /v3/documents/{documentId}
```
STEP 7: SEARCH MODES
typescript
```typescript
// HYBRID (recommended) - searches memories + document chunks
searchMode: 'hybrid'
// MEMORIES ONLY - just extracted memories, no original text
searchMode: 'memories'
```
STEP 8: METADATA FILTERS (if they need secondary filtering)
typescript
await client.search({
```typescript
// Always against /v4/search — rerank/rewriteQuery/filters are v4-only
await client.search.memories({
q: query,
containerTag: userId,
filters: {
@ -298,6 +345,7 @@ await client.search({
]
}
})
```
KEY POINTS:
@ -311,21 +359,25 @@ KEY POINTS:
TESTING:
bash
```bash
# 1. Configure settings
curl -X PATCH https://api.supermemory.ai/v3/settings \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"shouldLLMFilter": true, "filterPrompt": "..."}'
# 2. Add test memory
curl -X POST https://api.supermemory.ai/v3/documents \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"content": "Test", "containerTag": "test_user"}'
# 3. Get profile
curl -X POST https://api.supermemory.ai/v4/profile \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"containerTag": "test_user"}'
```
NOW:

View file

@ -1,14 +1,12 @@
"use client"
import { EnsureWorkspace } from "@/components/ensure-workspace"
import { NextAppResearchCta } from "@/components/next-app-research-cta"
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<EnsureWorkspace>{children}</EnsureWorkspace>
<NextAppResearchCta />
<PWAInstallPrompt />
</>
)

View file

@ -555,6 +555,7 @@ export default function OnboardingPage() {
const fileRef = useRef<HTMLInputElement>(null)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const skippingRef = useRef(false)
const [isSkipping, setIsSkipping] = useState(false)
const [spotlightCategory, setSpotlightCategory] =
useState<SpotlightCategoryId>("productivity")
@ -617,6 +618,7 @@ export default function OnboardingPage() {
const handleSkip = useCallback(async () => {
if (skippingRef.current) return
skippingRef.current = true
setIsSkipping(true)
try {
await ensureOrg()
const pendingPath = consumePendingConnectUrl()
@ -624,6 +626,7 @@ export default function OnboardingPage() {
} catch (err) {
console.error(err)
skippingRef.current = false
setIsSkipping(false)
}
}, [ensureOrg, router])
@ -798,9 +801,22 @@ export default function OnboardingPage() {
<button
type="button"
onClick={handleSkip}
className="text-[#525966] text-sm hover:text-white transition-colors cursor-pointer"
disabled={isSkipping}
className={cn(
"text-sm transition-colors cursor-pointer flex items-center gap-1.5",
isSkipping
? "text-[#525966] cursor-not-allowed"
: "text-[#525966] hover:text-white",
)}
>
Skip for now →
{isSkipping ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Skipping…
</>
) : (
"Skip for now →"
)}
</button>
</div>
@ -870,34 +886,36 @@ export default function OnboardingPage() {
/>
<AnimatePresence>
{isCheckingAccount && (
{hasDetectedAccount && detected !== "resume" && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
className="absolute right-3 flex items-center pointer-events-none"
className="absolute right-1 flex size-8 items-center justify-center"
>
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
{isCheckingAccount ? (
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
) : canSubmit ? (
<motion.button
type="button"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={() =>
handleSubmit(detected as "x" | "linkedin")
}
className="rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer"
style={{
background:
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
>
<SubmitArrow />
</motion.button>
) : null}
</motion.span>
)}
</AnimatePresence>
{canSubmit && (
<motion.button
type="button"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={() => handleSubmit(detected as "x" | "linkedin")}
className="absolute right-1 rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer"
style={{
background:
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
>
<SubmitArrow />
</motion.button>
)}
</div>
<AnimatePresence>
@ -929,9 +947,6 @@ export default function OnboardingPage() {
{currentAccountLookup?.status === "error" && (
<AlertCircle className="size-3.5 shrink-0" />
)}
{isCheckingAccount && (
<Loader2 className="size-3.5 shrink-0 animate-spin" />
)}
<span>
{currentAccountLookup?.message ??
SOURCE_LABEL[detected as "x" | "linkedin"]}

View file

@ -11,6 +11,7 @@ import {
import { AnimatePresence, motion } from "motion/react"
import { useQueryState } from "nuqs"
import { Header, PublicHeader } from "@/components/header"
import { MobileBottomNav } from "@/components/bottom-nav"
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import { DashboardView } from "@/components/dashboard-view"
import { MemoriesGrid } from "@/components/memories-grid"
@ -60,6 +61,7 @@ import {
type IntegrationParamValue,
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { getToolDocumentSpace } from "@/lib/plugin-space"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -106,7 +108,7 @@ export default function NewPage() {
const isMobile = useIsMobile()
const { user, session } = useAuth()
const { selectedProject, selectedProjects } = useProject()
const { selectedProject, selectedProjects, setSelectedProject } = useProject()
const selectedProjectTag = selectedProjects[0]
const { allProjects } = useContainerTags()
const dashboardSpaceLabel = useMemo(
@ -408,6 +410,18 @@ export default function NewPage() {
[setDocId],
)
const handleOpenToolDocument = useCallback(
(document: DocumentWithMemories, pluginClientId: string) => {
const documentSpace = getToolDocumentSpace(document, pluginClientId)
if (documentSpace) {
setSelectedProject(documentSpace)
}
handleOpenDocument(document)
void setViewMode("list")
},
[handleOpenDocument, setSelectedProject, setViewMode],
)
// Separate from handleOpenDocument because the graph view only has a document ID,
// not the full document object. The modal will fetch the document via the docId
// query param, so there may be a brief loading state (unlike handleOpenDocument
@ -514,9 +528,13 @@ export default function NewPage() {
const handleOpenIntegrations = useCallback(
(integration?: IntegrationParamValue) => {
if (integration === "notion" || integration === "google-drive") {
void setAddDoc("connect")
return
}
void setViewMode(integration ?? "integrations")
},
[setViewMode],
[setViewMode, setAddDoc],
)
const handleOpenPlugins = useCallback(() => {
@ -544,6 +562,7 @@ export default function NewPage() {
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
const isGraphMode = viewMode === "graph"
const showBottomNav = isMobile && !isChatView && !!session
return (
<HotkeysProvider>
@ -551,23 +570,23 @@ export default function NewPage() {
className={cn(
"relative flex min-h-dvh flex-col bg-[#05080D]",
isGraphMode && "h-dvh overflow-hidden",
showBottomNav &&
!isGraphMode &&
"pb-[calc(5.5rem+env(safe-area-inset-bottom))]",
)}
>
{showNovaBackdrop && (
<>
<div className="pointer-events-none fixed inset-0 z-0">
<AnimatedGradientBackground
animateFromBottom={false}
topPosition={gradientTopPosition}
/>
<div
className="pointer-events-none absolute inset-0 z-0 bg-[#05080D]/50"
aria-hidden
/>
<div className="absolute inset-0 bg-[#05080D]/50" aria-hidden />
<div
id="graph-dotted-grid"
className="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%)]"
className="absolute inset-0 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%)]"
/>
</>
</div>
)}
{!session && viewMode === "mcp" ? (
<PublicHeader />
@ -711,6 +730,7 @@ export default function NewPage() {
onNavigateToMemories={() => void setViewMode("list")}
onNavigateToGraph={() => void setViewMode("graph")}
onOpenDocument={handleOpenDocument}
onOpenToolDocument={handleOpenToolDocument}
onHighlightsChat={handleHighlightsChat}
onHighlightsShowRelated={handleHighlightsShowRelated}
onResetHighlights={handleResetHighlights}
@ -722,14 +742,37 @@ export default function NewPage() {
</motion.main>
</AnimatePresence>
{isDashboardShell && showBottomNav && (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
)}
{isDashboardShell && (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30 bg-gradient-to-t from-black via-black/40 to-transparent pt-12">
<div
className={cn(
"pointer-events-none fixed inset-x-0 z-30",
showBottomNav
? "bottom-[4.25rem]"
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
)}
>
<div className="pointer-events-auto">
<HomeChatComposer onStartChat={handleHomeChatStart} />
</div>
</div>
)}
{showBottomNav && (
<MobileBottomNav
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
/>
)}
<AddDocumentModal
isOpen={addDoc !== null}
onClose={() => setAddDoc(null)}

View file

@ -8,8 +8,9 @@ export default function SettingsIntegrationsPage() {
const searchParams = useSearchParams()
useEffect(() => {
const qs = searchParams.toString()
router.replace(`/settings${qs ? `?${qs}` : ""}#integrations`)
const params = new URLSearchParams(searchParams.toString())
params.set("view", "integrations")
router.replace(`/?${params.toString()}`)
}, [router, searchParams])
return null

View file

@ -2,12 +2,12 @@
import { Logo } from "@ui/assets/Logo"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { useAuth } from "@lib/auth-context"
import { motion } from "motion/react"
import NovaOrb from "@/components/nova/nova-orb"
import { useState, useEffect, useRef } from "react"
import { useState, useEffect, useRef, useMemo } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import Account from "@/components/settings/account"
import Billing from "@/components/settings/billing"
import Integrations from "@/components/settings/integrations"
import ConnectionsMCP from "@/components/settings/connections-mcp"
import Support from "@/components/settings/support"
@ -16,13 +16,42 @@ 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 { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react"
import {
LogOut,
RotateCcw,
Trash2,
Sun,
LoaderIcon,
User as UserIcon,
Zap,
HelpCircle,
CreditCard,
ShieldAlert,
ChevronRight,
ChevronsUpDown,
Check,
Building2,
} from "lucide-react"
import { authClient } from "@lib/auth"
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { useResetOrganization } from "@/hooks/use-reset-organization"
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
import { useCustomer } from "autumn-js/react"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import {
PLAN_DISPLAY_NAMES,
useTokenUsage,
type PlanType,
} from "@/hooks/use-token-usage"
const TABS = ["account", "integrations", "connections", "support"] as const
const TABS = [
"account",
"billing",
"integrations",
"connections",
"support",
] as const
type SettingsTab = (typeof TABS)[number]
type NavItem = {
@ -32,134 +61,39 @@ 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",
label: "Account & Billing",
description: "Manage your profile, plan, usage and payments",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
label: "Account",
description: "Your profile and organization",
icon: <UserIcon className="size-[18px]" />,
},
{
id: "billing",
label: "Billing",
description: "Plan, usage and payments",
icon: <CreditCard className="size-[18px]" />,
},
{
id: "integrations",
label: "Integrations",
description: "Save, sync and search memories across tools",
icon: <Sun className="size-5" />,
description: "Save, sync and search across tools",
icon: <Sun className="size-[18px]" />,
},
{
id: "connections",
label: "Connections & MCP",
description: "Sync with Google Drive, Notion, OneDrive and MCP client",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
),
description: "Drive, Notion, OneDrive, MCP",
icon: <Zap className="size-[18px]" />,
},
{
id: "support",
label: "Support & Help",
description: "Find answers or share feedback. We're here to help.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
),
description: "Get help or share feedback",
icon: <HelpCircle className="size-[18px]" />,
},
]
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)
@ -167,30 +101,75 @@ function parseHashToTab(hash: string): SettingsTab {
: "account"
}
export function UserSupermemory({ name }: { name: string }) {
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
}
function OrgPlanBadge({ plan }: { plan: PlanType }) {
return (
<motion.div
className="absolute inset-0 top-[-40px] flex items-center justify-center z-10"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
<span
className={cn(
dmSans125ClassName(),
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
ORG_PLAN_BADGE_STYLES[plan],
)}
>
<Logo className="h-7 text-white" />
<div className="flex flex-col items-start justify-center ml-4 space-y-1">
<p className="text-white text-[15px] font-medium leading-none">
{name.split(" ")[0]}'s
</p>
<p className="text-white font-bold text-xl leading-none -mt-2">
supermemory
</p>
{PLAN_DISPLAY_NAMES[plan]}
</span>
)
}
function resolveOrgPlan(
orgId: string,
isCurrent: boolean,
currentPlan: PlanType,
planByOrgId: Map<string, PlanType>,
): PlanType {
const fromSummary = planByOrgId.get(orgId)
if (fromSummary) return fromSummary
if (isCurrent) return currentPlan
return "free"
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 pt-3 pb-1.5 text-[10.5px] font-semibold tracking-[0.14em] text-white/30 uppercase">
{children}
</div>
)
}
function IdentityCard({ displayName }: { displayName: string }) {
const firstName = displayName?.split(" ")[0] || ""
return (
<div className="relative flex items-center justify-center h-[140px]">
<NovaOrb size={150} className="blur-[3px]!" />
<div className="absolute inset-0 flex items-center justify-center z-10">
<Logo className="h-7 shrink-0 text-white" />
<div
className={cn(
"flex flex-col items-start justify-center ml-3.5",
dmSansClassName(),
)}
>
<p className="text-white text-[14px] font-medium leading-none">
{firstName ? `${firstName}'s` : "Your"}
</p>
<p className="text-white font-bold text-[20px] leading-none mt-1">
supermemory
</p>
</div>
</div>
</motion.div>
</div>
)
}
export default function SettingsPage() {
const { user, org } = useAuth()
const { user, org, organizations, setActiveOrg } = useAuth()
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
const hasInitialized = useRef(false)
const router = useRouter()
@ -205,6 +184,40 @@ export default function SettingsPage() {
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
const deleteUserAccount = useDeleteUserAccount()
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false)
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
const canSwitchOrg = (organizations?.length ?? 0) > 1
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const planByOrgId = useMemo(() => {
const map = new Map<string, PlanType>()
for (const summary of orgSummaries ?? []) {
map.set(summary.orgId, summary.plan)
}
return map
}, [orgSummaries])
const activeOrgPlan = org?.id
? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
: currentPlan
const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
if (orgId === org?.id) {
setOrgSwitcherOpen(false)
return
}
setSwitchingOrgId(orgId)
try {
await setActiveOrg(orgSlug)
window.location.reload()
} catch (error) {
console.error("Failed to switch organization:", error)
setSwitchingOrgId(null)
}
}
const handleLogout = async () => {
await authClient.signOut()
router.push("/login")
@ -233,7 +246,6 @@ export default function SettingsPage() {
setActiveTab(tab)
analytics.settingsTabChanged({ tab })
// If no hash or invalid hash, push #account
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
window.history.pushState(null, "", "#account")
}
@ -256,13 +268,10 @@ export default function SettingsPage() {
user?.name ||
user?.email?.split("@")[0] ||
""
const headerPossessive = headerDisplayName
? `${headerDisplayName.split(" ")[0]}'s`
: "Your"
return (
<div className="h-screen flex flex-col overflow-hidden">
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-6 py-3 shrink-0">
<div className="h-screen flex flex-col overflow-hidden bg-[#08090C]">
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-8 py-3 shrink-0">
<nav
className={cn(
"flex items-center gap-2 sm:gap-3 min-w-0 text-sm",
@ -279,144 +288,271 @@ export default function SettingsPage() {
)}
>
<Logo className="h-7 shrink-0" />
{headerDisplayName ? (
<div className="flex flex-col items-start justify-center ml-2 min-w-0">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
{headerPossessive}
</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
supermemory
</p>
</div>
) : (
<span className="ml-2 font-medium text-white/90">
supermemory
</span>
)}
<span className="ml-2 font-semibold text-white/90 tracking-tight">
supermemory
</span>
</button>
<span className="text-white/35 shrink-0" aria-hidden>
<span className="text-white/30 shrink-0" aria-hidden>
/
</span>
<span className="text-white/50 font-medium shrink-0">Settings</span>
<span className="text-white/55 font-medium shrink-0">Settings</span>
</nav>
<UserProfileMenu />
</header>
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
<div className="flex flex-col md:flex-row md:justify-center gap-4 md:gap-8 lg:gap-12 px-4 md:px-6 pt-4 pb-6 md:h-full">
<div className="md:flex md:h-full md:min-h-0 md:w-auto md:max-w-[380px] md:flex-col md:overflow-hidden shrink-0">
{!isMobile && (
<motion.div
animate={{
scale: 1,
padding: 28,
paddingTop: 0,
}}
transition={{
duration: 0.8,
ease: "easeOut",
delay: 0.2,
}}
className="relative flex items-center justify-center"
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
{!isMobile &&
(canSwitchOrg ? (
<Popover open={orgSwitcherOpen} onOpenChange={setOrgSwitcherOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
"bg-white/[0.03] border border-white/[0.06]",
"cursor-pointer hover:bg-white/[0.06]",
dmSansClassName(),
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={8}
className={cn(
"w-[260px] max-h-80 overflow-y-auto p-1.5",
"bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
{[...(organizations ?? [])]
.sort((a, b) => a.name.localeCompare(b.name))
.map((organization) => {
const isCurrent = organization.id === org?.id
const isSwitching = switchingOrgId === organization.id
const plan = resolveOrgPlan(
organization.id,
isCurrent,
currentPlan,
planByOrgId,
)
return (
<button
key={organization.id}
type="button"
disabled={isCurrent || isSwitching}
onClick={() =>
handleOrgSwitch(organization.slug, organization.id)
}
className={cn(
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
isCurrent
? "bg-white/5"
: "hover:bg-white/5 cursor-pointer",
"disabled:cursor-default",
)}
>
<Building2 className="size-4 shrink-0 text-white/40" />
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
{organization.name}
</span>
{isSwitching ? (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
) : isCurrent ? (
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
) : null}
<OrgPlanBadge plan={plan} />
</button>
)
})}
</PopoverContent>
</Popover>
) : (
<div
className={cn(
"flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5",
"bg-white/[0.03] border border-white/[0.06]",
dmSansClassName(),
)}
>
<NovaOrb size={140} className="blur-[3px]!" />
<UserSupermemory name={headerDisplayName} />
</motion.div>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
</div>
))}
<UserProfileMenu />
</div>
</header>
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-10 px-4 md:px-8 pt-5 md:pt-7 pb-6 md:h-full md:w-full md:max-w-[1240px] md:mx-auto">
{/* Left rail */}
<aside className="md:flex md:h-full md:min-h-0 md:w-[280px] md:flex-col shrink-0">
{!isMobile && (
<div className="mb-4">
<IdentityCard displayName={headerDisplayName} />
</div>
)}
<nav
className={cn(
"flex",
isMobile
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
: "min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
dmSansClassName(),
)}
>
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
window.location.hash = item.id
setActiveTab(item.id)
analytics.settingsTabChanged({ tab: item.id })
}}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0",
isMobile ? "px-3 py-2 text-sm" : "text-left px-3 py-2.5",
activeTab === item.id
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
: "text-white/60 hover:text-white hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
<span className={cn("shrink-0", !isMobile && "mt-0.5")}>
{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-xs leading-snug text-white/50">
{item.description}
</span>
</div>
)}
</button>
))}
{!isMobile && <SectionLabel>Organisation</SectionLabel>}
{/* Divider */}
{!isMobile && <div className="my-0.5 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)
}
{NAV_ITEMS.map((item) => {
const isActive = activeTab === item.id
return (
<button
key={item.id}
type="button"
onClick={handleClick}
onClick={() => {
window.location.hash = item.id
setActiveTab(item.id)
analytics.settingsTabChanged({ tab: item.id })
}}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0 group",
isMobile ? "px-3 py-2 text-sm" : "text-left px-3 py-2.5",
"hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
colors.idle,
colors.hover,
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
isMobile
? "px-3 py-2 text-sm border border-white/[0.06]"
: "text-left px-3 py-2",
isActive
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
)}
>
{!isMobile && (
<span
aria-hidden
className={cn(
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
isActive
? "h-5 bg-[#4BA0FA]"
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
)}
/>
)}
<span
className={cn(
"shrink-0",
!isMobile && "mt-0.5",
colors.icon,
`group-hover:${colors.hover.replace("hover:", "")}`,
"shrink-0 transition-colors",
isActive ? "text-white" : "text-white/45",
)}
>
{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-xs leading-snug opacity-60">
{item.description}
</span>
</div>
)}
<span className="font-medium text-[14px] whitespace-nowrap">
{item.label}
</span>
</button>
)
})}
{!isMobile && <div className="flex-1" />}
<button
type="button"
onClick={handleLogout}
className={cn(
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
isMobile
? "px-3 py-2 text-sm border border-white/[0.06]"
: "text-left px-3 py-2",
)}
>
<LogOut className="size-[18px] shrink-0 text-white/45" />
<span className="font-medium text-[14px] whitespace-nowrap">
Log out
</span>
</button>
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
isMobile
? "px-3 py-2 text-sm border border-[#3A211C]"
: "text-left px-3 py-2.5 mt-1",
dangerMenuOpen
? "bg-[#1A0F0C] text-[#C73B1B]"
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
)}
>
<ShieldAlert className="size-[18px] shrink-0" />
<span className="font-medium text-[14px] whitespace-nowrap">
Danger zone
</span>
<ChevronRight
className={cn(
"size-4 ml-auto shrink-0 transition-transform opacity-60",
dangerMenuOpen ? "-rotate-90" : "rotate-0",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
sideOffset={8}
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsResetDialogOpen(true)
}}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
>
<RotateCcw className="size-[16px] shrink-0" />
<span className="font-medium text-[13.5px]">
Reset data
</span>
</button>
<div className="my-1 h-px bg-white/[0.06]" />
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsDeleteDialogOpen(true)
}}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
>
<Trash2 className="size-[16px] shrink-0" />
<span className="font-semibold text-[13.5px]">
Delete account
</span>
</button>
</PopoverContent>
</Popover>
</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]">
</aside>
{/* Content */}
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
<ErrorBoundary
key={activeTab}
fallback={
@ -433,11 +569,12 @@ export default function SettingsPage() {
}
>
{activeTab === "account" && <Account />}
{activeTab === "billing" && <Billing />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "support" && <Support />}
</ErrorBoundary>
</div>
</section>
</div>
</main>
@ -509,6 +646,11 @@ export default function SettingsPage() {
resetConfirmation !== confirmText ||
resetOrganization.isPending
}
title={
!confirmText || resetConfirmation !== confirmText
? `Type "${confirmText || "your name"}" exactly to enable`
: undefined
}
onClick={() =>
resetOrganization.mutate(
{ confirmation: confirmText },
@ -589,6 +731,11 @@ export default function SettingsPage() {
deleteEmailConfirm !== user?.email ||
deleteUserAccount.isPending
}
title={
deleteEmailConfirm !== user?.email
? `Type ${user?.email ?? "your email"} exactly to confirm`
: undefined
}
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"
>

View file

@ -1,524 +1,26 @@
"use client"
import { redirect } from "next/navigation"
import { signIn, useSession } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { TextSeparator } from "@ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"
import { LabeledInput } from "@ui/input/labeled-input"
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import { InitialHeader } from "@/components/initial-header"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
}
function buildMcpAuthorizeResumeUrl(
sp: Pick<URLSearchParams, "toString">,
function serializeSearchParams(
sp: Record<string, string | string[] | undefined>,
): string {
const backend =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const p = new URLSearchParams(sp.toString())
p.delete("redirect")
p.delete("error")
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
}
function AnimatedGradientBackground() {
return (
<div className="fixed inset-0 z-0 overflow-hidden">
<motion.div
className="absolute top-[20%] left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: [1, 0, 1],
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
}}
/>
<motion.div
className="absolute top-[20%] left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: [0, 1, 0],
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
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"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",
opacity: 0.4,
}}
/>
</div>
)
}
function LoginCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
className="flex py-8 px-11 flex-col items-start gap-2 rounded-[22px] bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
>
{children}
</motion.div>
)
}
export default function LoginPage() {
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
const router = useRouter()
const posthog = usePostHog()
const params = useSearchParams()
const { data: sessionData, isPending: sessionPending } = useSession()
const oauthQueryForResume = params.toString()
useEffect(() => {
if (sessionPending) return
if (!sessionData?.session) return
const sp = new URLSearchParams(oauthQueryForResume)
if (!isMcpOAuthAuthorizeContext(sp)) return
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
}, [sessionPending, sessionData?.session, oauthQueryForResume])
// Get redirect URL from query params
const redirectUrl = params.get("redirect")
// Create callback URL that includes redirect parameter if provided
const getCallbackURL = () => {
const origin = window.location.origin
if (isMcpOAuthAuthorizeContext(params)) {
return buildMcpAuthorizeResumeUrl(params)
}
let finalUrl: URL
if (redirectUrl) {
try {
finalUrl = new URL(redirectUrl, origin)
} catch {
finalUrl = new URL(origin)
}
const q = new URLSearchParams()
for (const [key, value] of Object.entries(sp)) {
if (value === undefined) continue
if (Array.isArray(value)) {
for (const v of value) q.append(key, v)
} else {
finalUrl = new URL(origin)
q.set(key, value)
}
finalUrl.searchParams.set("extension-auth-success", "true")
return finalUrl.toString()
}
// Load last used method from localStorage on mount
useEffect(() => {
const savedMethod = localStorage.getItem("supermemory-last-login-method")
setLastUsedMethod(savedMethod)
}, [])
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {
try {
localStorage.setItem("supermemory-pending-login-method", method)
localStorage.setItem(
"supermemory-pending-login-timestamp",
String(Date.now()),
)
} catch {}
}
function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return (
message.includes("load failed") ||
message.includes("networkerror") ||
message.includes("failed to fetch") ||
message.includes("network request failed")
)
}
function getErrorMessage(error: unknown): string {
if (isNetworkError(error)) {
return "Network error. Please check your connection and try again."
}
if (error instanceof Error) {
return error.message
}
return "An unexpected error occurred. Please try again."
}
// If we land back on this page with an error, clear any pending marker
useEffect(() => {
if (params.get("error")) {
try {
localStorage.removeItem("supermemory-pending-login-method")
localStorage.removeItem("supermemory-pending-login-timestamp")
} catch {}
}
}, [params])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setIsLoadingEmail(true)
setError(null)
// Track login attempt
posthog.capture("login_attempt", {
method: "magic_link",
email_domain: email.split("@")[1] || "unknown",
})
const { error } = await signIn.magicLink({
callbackURL: getCallbackURL(),
email,
})
if (error) {
console.error(error)
// Track login failure
posthog.capture("login_failed", {
method: "magic_link",
error: error instanceof Error ? error.message : "Unknown error",
email_domain: email.split("@")[1] || "unknown",
is_network_error: isNetworkError(error),
})
setError(getErrorMessage(error))
setIsLoading(false)
setIsLoadingEmail(false)
return
}
setSubmittedEmail(email)
setPendingLoginMethod("magic_link")
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
setIsLoading(false)
setIsLoadingEmail(false)
}
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
const formData = new FormData(event.currentTarget)
const token = formData.get("token") as string
const callbackURL = getCallbackURL()
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
)
}
return (
<main className="relative h-screen overflow-hidden">
<AnimatedGradientBackground />
<div className="relative z-10">
<InitialHeader />
<section className="flex flex-col items-center justify-center p-4 space-y-12 sm:p-6 md:p-8 lg:px-20 lg:py-12.5 min-h-[calc(100vh-80px)]">
<div className="text-center">
<div className="text-5xl font-medium">
Never forget anything, anywhere
</div>
<div className="text-5xl font-medium">with supermemory</div>
</div>
{submittedEmail ? (
<LoginCard>
<div className="w-[360px] flex flex-col gap-4 lg:gap-6 min-h-2/3">
<div className="flex flex-col gap-2 text-center lg:text-left">
<Title1Bold className="text-foreground">
Almost there!
</Title1Bold>
<HeadingH3Medium className="text-muted-foreground">
Click the magic link we've sent to{" "}
<span className="text-foreground">{submittedEmail}</span>.
</HeadingH3Medium>
</div>
<TextSeparator text="OR" className={cn(dmSansClassName())} />
<form
className="flex flex-col gap-4 lg:gap-6"
onSubmit={handleSubmitToken}
>
<LabeledInput
inputPlaceholder="your temporary login code"
inputProps={{
name: "token",
required: true,
disabled: isLoading,
"aria-invalid": error ? "true" : "false",
}}
inputType="text"
label="Enter code"
/>
<Button disabled={isLoading} id="verify-token" type="submit">
Verify Token
</Button>
</form>
</div>
</LoginCard>
) : (
<LoginCard>
<div className="w-[360px] flex flex-col" style={{ gap: "12px" }}>
{params.get("error") && (
<div className="text-red-500">
Error: {params.get("error")}. Please try again!
</div>
)}
<div className="flex flex-col gap-3">
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<div className="relative grow">
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google</title>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#FFC107"
/>
<path
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
fill="#FF3D00"
/>
<path
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
fill="#4CAF50"
/>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#1976D2"
/>
</svg>
}
authProvider="Google"
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "google",
})
setPendingLoginMethod("google")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "google",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
})
.finally(() => {
setIsLoading(false)
})
}}
/>
{lastUsedMethod === "google" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<div className="relative grow">
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 26 25"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<title>Github</title>
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
fill="currentColor"
fillRule="evenodd"
/>
</g>
<defs>
<clipPath id="clip0_2579_3356">
<rect
fill="currentColor"
height="24"
transform="translate(0.75 0.21)"
width="24.5"
/>
</clipPath>
</defs>
</svg>
}
authProvider="Github"
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "github",
})
setPendingLoginMethod("github")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "github",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
})
.finally(() => {
setIsLoading(false)
})
}}
/>
{lastUsedMethod === "github" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
) : null}
</div>
<TextSeparator text="OR" className={cn(dmSansClassName())} />
<div className="flex flex-col gap-6">
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<LabeledInput
error={error}
inputPlaceholder="your@email.com"
inputProps={{
"aria-invalid": error ? "true" : "false",
disabled: isLoading,
id: "email",
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
error && setError(null)
},
required: true,
value: email,
}}
inputType="email"
/>
<div className="relative">
<Button
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
disabled={isLoading}
type="submit"
>
<Logo className="size-4" />
{isLoadingEmail
? "Sending login link..."
: "Log in with Supermemory"}
</Button>
{lastUsedMethod === "magic_link" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
</form>
<Label1Regular
className={cn(
"text-center text-xs! text-[#737373B2]",
dmSansClassName(),
)}
>
By continuing, you agree to our{" "}
<span className="inline-block">
<a
className="underline"
href="https://supermemory.ai/terms/"
>
Terms
</a>{" "}
and{" "}
<a
className="underline"
href="https://supermemory.ai/privacy/"
>
Privacy Policy
</a>
.
</span>
</Label1Regular>
</div>
</div>
</LoginCard>
)}
</section>
</div>
</main>
)
return q.toString()
}
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const sp = await searchParams
const query = serializeSearchParams(sp)
redirect(query ? `/login?${query}` : "/login")
}

View file

@ -1,26 +1,548 @@
import { redirect } from "next/navigation"
"use client"
function serializeSearchParams(
sp: Record<string, string | string[] | undefined>,
import { signIn, useSession } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { TextSeparator } from "@ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"
import { LabeledInput } from "@ui/input/labeled-input"
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import { InitialHeader } from "@/components/initial-header"
import { LoginToolsPanel } from "@/components/login-tools-panel"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
import { Loader2 } from "lucide-react"
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
}
function buildMcpAuthorizeResumeUrl(
sp: Pick<URLSearchParams, "toString">,
): string {
const q = new URLSearchParams()
for (const [key, value] of Object.entries(sp)) {
if (value === undefined) continue
if (Array.isArray(value)) {
for (const v of value) q.append(key, v)
} else {
q.set(key, value)
}
}
return q.toString()
const backend =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const p = new URLSearchParams(sp.toString())
p.delete("redirect")
p.delete("error")
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
}
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const sp = await searchParams
const query = serializeSearchParams(sp)
redirect(query ? `/login/new?${query}` : "/login/new")
function LoginHeadline({ className }: { className?: string }) {
return (
<div className={cn("max-w-sm text-center", className)}>
<h1 className="text-balance text-lg font-medium leading-tight tracking-tight sm:text-xl lg:text-[1.375rem] xl:text-2xl">
Never forget anything, anywhere
<span className="block text-brand-accent">with supermemory</span>
</h1>
<p
className={cn(
"mt-1.5 text-xs leading-snug text-muted-foreground/50 lg:mt-2",
dmSansClassName(),
)}
>
Save from Chrome, Notion, X — search it all in one place.
</p>
</div>
)
}
function LastUsedBadge({ show }: { show: boolean }) {
if (!show) return null
return (
<div className="mb-1 flex justify-end">
<Badge variant="default" className="h-5 px-1.5 text-[10px]">
Last used
</Badge>
</div>
)
}
function LoginCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
className="flex w-full max-w-[400px] shrink-0 flex-col items-center gap-2 rounded-[22px] py-5 px-5 sm:w-auto sm:max-w-none sm:items-start sm:py-8 sm:px-11 bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
>
{children}
</motion.div>
)
}
function LoginCardBody({
children,
loadingMessage,
}: {
children: React.ReactNode
loadingMessage?: string | null
}) {
return (
<div className="relative w-full max-w-[360px] sm:w-[360px]">
<div className={loadingMessage ? "invisible" : undefined}>{children}</div>
{loadingMessage ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-[14px] bg-[#030912]/90 backdrop-blur-sm">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
</div>
) : null}
</div>
)
}
export default function LoginPage() {
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
const router = useRouter()
const posthog = usePostHog()
const params = useSearchParams()
const { data: sessionData, isPending: sessionPending } = useSession()
const oauthQueryForResume = params.toString()
const isRedirecting = !sessionPending && Boolean(sessionData?.session)
const isAuthResolving = sessionPending || isRedirecting
const loadingMessage = isAuthResolving
? sessionPending
? "Checking session…"
: "Redirecting…"
: isLoading
? isLoadingEmail
? "Sending login link…"
: "Redirecting…"
: null
useEffect(() => {
if (sessionPending) return
if (!sessionData?.session) return
const sp = new URLSearchParams(oauthQueryForResume)
if (isMcpOAuthAuthorizeContext(sp)) {
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
return
}
const redirectUrl = params.get("redirect")
if (redirectUrl) {
window.location.assign(
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
)
return
}
router.replace("/")
}, [
sessionPending,
sessionData?.session,
oauthQueryForResume,
params,
router,
])
// Get redirect URL from query params
const redirectUrl = params.get("redirect")
// Create callback URL that includes redirect parameter if provided
const getCallbackURL = () => {
const origin = window.location.origin
if (isMcpOAuthAuthorizeContext(params)) {
return buildMcpAuthorizeResumeUrl(params)
}
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
finalUrl.searchParams.set("extension-auth-success", "true")
return finalUrl.toString()
}
// Load last used method from localStorage on mount
useEffect(() => {
const savedMethod = localStorage.getItem("supermemory-last-login-method")
setLastUsedMethod(savedMethod)
}, [])
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {
try {
localStorage.setItem("supermemory-pending-login-method", method)
localStorage.setItem(
"supermemory-pending-login-timestamp",
String(Date.now()),
)
} catch {}
}
function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return (
message.includes("load failed") ||
message.includes("networkerror") ||
message.includes("failed to fetch") ||
message.includes("network request failed")
)
}
function getErrorMessage(error: unknown): string {
if (isNetworkError(error)) {
return "Network error. Please check your connection and try again."
}
if (error instanceof Error) {
return error.message
}
return "An unexpected error occurred. Please try again."
}
// If we land back on this page with an error, clear any pending marker
useEffect(() => {
if (params.get("error")) {
try {
localStorage.removeItem("supermemory-pending-login-method")
localStorage.removeItem("supermemory-pending-login-timestamp")
} catch {}
}
}, [params])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setIsLoadingEmail(true)
setError(null)
// Track login attempt
posthog.capture("login_attempt", {
method: "magic_link",
email_domain: email.split("@")[1] || "unknown",
})
const { error } = await signIn.magicLink({
callbackURL: getCallbackURL(),
email,
})
if (error) {
console.error(error)
// Track login failure
posthog.capture("login_failed", {
method: "magic_link",
error: error instanceof Error ? error.message : "Unknown error",
email_domain: email.split("@")[1] || "unknown",
is_network_error: isNetworkError(error),
})
setError(getErrorMessage(error))
setIsLoading(false)
setIsLoadingEmail(false)
return
}
setSubmittedEmail(email)
setPendingLoginMethod("magic_link")
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
setIsLoading(false)
setIsLoadingEmail(false)
}
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
const formData = new FormData(event.currentTarget)
const token = formData.get("token") as string
const callbackURL = getCallbackURL()
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
)
}
return (
<main className="login-page-grid relative bg-[#030912]">
<div className="login-page-mobile-bg" aria-hidden>
<div className="login-panel-orb h-full" />
<div className="login-panel-orb-image h-full" />
</div>
<div className="relative z-20 lg:hidden">
<InitialHeader />
</div>
<LoginToolsPanel />
<div className="relative z-10 flex min-h-0 min-w-0 flex-col lg:col-start-2 lg:row-start-1 lg:h-full">
<div className="hidden lg:block">
<InitialHeader />
</div>
<section className="flex min-h-0 flex-1 flex-col items-center px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-1 lg:justify-center lg:px-10 lg:py-10">
<div className="mt-auto flex w-full flex-col items-center lg:mt-0">
<LoginHeadline className="mb-3 sm:mb-4 lg:mb-8" />
<LoginCard>
<LoginCardBody loadingMessage={loadingMessage}>
{submittedEmail ? (
<div className="flex flex-col gap-4 lg:gap-6">
<div className="flex flex-col gap-2 text-center lg:text-left">
<Title1Bold className="text-foreground">
Almost there!
</Title1Bold>
<HeadingH3Medium className="text-muted-foreground">
Click the magic link we've sent to{" "}
<span className="text-foreground">
{submittedEmail}
</span>
.
</HeadingH3Medium>
</div>
<TextSeparator
text="OR"
className={cn(dmSansClassName())}
/>
<form
className="flex flex-col gap-4 lg:gap-6"
onSubmit={handleSubmitToken}
>
<LabeledInput
inputPlaceholder="your temporary login code"
inputProps={{
name: "token",
required: true,
disabled: isLoading,
"aria-invalid": error ? "true" : "false",
}}
inputType="text"
label="Enter code"
/>
<Button
disabled={isLoading}
id="verify-token"
type="submit"
>
Verify Token
</Button>
</form>
</div>
) : (
<div className="flex flex-col" style={{ gap: "12px" }}>
{params.get("error") && (
<div className="text-red-500">
Error: {params.get("error")}. Please try again!
</div>
)}
<div className="flex flex-col gap-3">
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<div className="w-full">
<LastUsedBadge show={lastUsedMethod === "google"} />
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google</title>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#FFC107"
/>
<path
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
fill="#FF3D00"
/>
<path
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
fill="#4CAF50"
/>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#1976D2"
/>
</svg>
}
authProvider="Google"
className="w-full"
disabled={Boolean(loadingMessage)}
onClick={() => {
if (loadingMessage) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "google",
})
setPendingLoginMethod("google")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "google",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
setIsLoading(false)
})
}}
/>
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<div className="w-full">
<LastUsedBadge show={lastUsedMethod === "github"} />
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 26 25"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<title>Github</title>
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
fill="currentColor"
fillRule="evenodd"
/>
</g>
<defs>
<clipPath id="clip0_2579_3356">
<rect
fill="currentColor"
height="24"
transform="translate(0.75 0.21)"
width="24.5"
/>
</clipPath>
</defs>
</svg>
}
authProvider="Github"
className="w-full"
disabled={Boolean(loadingMessage)}
onClick={() => {
if (loadingMessage) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "github",
})
setPendingLoginMethod("github")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "github",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
setIsLoading(false)
})
}}
/>
</div>
) : null}
</div>
<TextSeparator
text="OR"
className={cn(dmSansClassName())}
/>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit}
className="flex flex-col gap-6"
>
<LabeledInput
error={error}
inputPlaceholder="your@email.com"
inputProps={{
"aria-invalid": error ? "true" : "false",
disabled: Boolean(loadingMessage),
id: "email",
onChange: (
e: React.ChangeEvent<HTMLInputElement>,
) => {
setEmail(e.target.value)
error && setError(null)
},
required: true,
value: email,
}}
inputType="email"
/>
<div className="w-full">
<LastUsedBadge
show={lastUsedMethod === "magic_link"}
/>
<Button
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
disabled={Boolean(loadingMessage)}
type="submit"
>
<Logo className="size-4" />
Log in with Supermemory
</Button>
</div>
</form>
<Label1Regular
className={cn(
"text-center text-xs! text-[#737373B2]",
dmSansClassName(),
)}
>
By continuing, you agree to our{" "}
<span className="inline-block">
<a
className="underline"
href="https://supermemory.ai/terms/"
>
Terms
</a>{" "}
and{" "}
<a
className="underline"
href="https://supermemory.ai/privacy/"
>
Privacy Policy
</a>
.
</span>
</Label1Regular>
</div>
</div>
)}
</LoginCardBody>
</LoginCard>
</div>
</section>
</div>
</main>
)
}

View file

@ -9,8 +9,22 @@ interface ExaApiResponse {
results: ExaContentResult[]
}
const exaApiKey = process.env.EXA_API_KEY
if (!exaApiKey) {
console.error(
"EXA_API_KEY is not configured; /api/onboarding/extract-content will return 503",
)
}
export async function POST(request: Request) {
try {
if (!exaApiKey) {
return Response.json(
{ error: "Content extraction is unavailable" },
{ status: 503 },
)
}
const { urls } = await request.json()
if (!Array.isArray(urls) || urls.length === 0) {
@ -30,7 +44,7 @@ export async function POST(request: Request) {
const response = await fetch("https://api.exa.ai/contents", {
method: "POST",
headers: {
"x-api-key": process.env.EXA_API_KEY ?? "",
"x-api-key": exaApiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({

View file

@ -90,6 +90,17 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
],
icon: "/images/plugins/cursor.svg",
},
codex: {
name: "OpenAI Codex",
description:
"Persistent memory for OpenAI Codex CLI. Remembers your coding context, patterns, and decisions across sessions.",
features: [
"Auto-recalls relevant context before each prompt",
"Captures coding decisions and patterns automatically",
"Builds persistent user profile across projects",
],
icon: "/images/plugins/codex.svg",
},
}
function getPluginName(client: string): string {

View file

@ -194,9 +194,12 @@ export function FileContent({
)}
>
<div className="flex flex-col gap-2">
<p className="text-[16px] font-medium pl-2">
Upload files (images, PDF, documents, sheets, markdown)
</p>
<div className="flex flex-col gap-0.5 pl-2">
<p className="text-[16px] font-medium">Upload files</p>
<p className="text-[#737373] text-xs">
Images, PDF, documents, sheets, markdown
</p>
</div>
<label
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -242,12 +245,12 @@ export function FileContent({
{hasItems ? (
<ul
id="file-upload-queue"
className="flex flex-col gap-2 max-h-[220px] overflow-y-auto scrollbar-thin pr-1"
className="flex flex-col gap-2 max-h-[360px] overflow-y-auto scrollbar-thin pr-1"
>
{data.items.map((item) => (
<li
key={item.id}
className="relative overflow-hidden rounded-[12px] bg-[#14161A] shadow-inside-out text-sm"
className="relative shrink-0 overflow-hidden rounded-[12px] bg-[#14161A] shadow-inside-out text-sm"
>
{item.status === "uploading" ? (
<div

View file

@ -105,7 +105,7 @@ const tabs = [
id: "connect" as const,
icon: ZapIcon,
title: "Connect knowledge bases",
compactLabel: "Connections",
compactLabel: "Connect",
description: "Sync with Google Drive, Notion and OneDrive and import data",
isPro: true,
},
@ -284,6 +284,18 @@ export function AddDocument({
const fileTabSubmitDisabled =
activeTab === "file" && (!fileTabHasPending || isSubmitting)
const spaceSelector = (
<SpaceSelector
selectedProjects={[localSelectedProject]}
onValueChange={(projects) =>
setLocalSelectedProject(projects[0] ?? localSelectedProject)
}
variant="insideOut"
compact={isMobile}
triggerClassName={cn(isMobile && "h-12 shrink-0")}
/>
)
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden text-white md:flex-row md:space-x-5">
{isMobile && !hasPaidPlan && (
@ -445,6 +457,23 @@ export function AddDocument({
isMobile ? "w-full px-4 pt-1" : "w-2/3 px-1",
)}
>
{isMobile && (
<div className="mb-3 flex h-10 w-full shrink-0 items-center overflow-hidden rounded-full border border-[#1F2937] bg-[#0D121A] p-1">
{tabs.map((tab) => (
<TabButton
key={tab.id}
active={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
icon={tab.icon}
title={tab.title}
compactLabel={tab.compactLabel}
description={tab.description}
isPro={tab.isPro}
compact
/>
))}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin">
{activeTab === "note" && (
<NoteContent
@ -487,38 +516,14 @@ export function AddDocument({
: "justify-between gap-2 pt-3",
)}
>
{isMobile && (
<div className="flex h-10 w-full shrink-0 items-center overflow-hidden rounded-full border border-[#1F2937] bg-[#0D121A] p-1">
{tabs.map((tab) => (
<TabButton
key={tab.id}
active={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
icon={tab.icon}
title={tab.title}
compactLabel={tab.compactLabel}
description={tab.description}
isPro={tab.isPro}
compact
/>
))}
</div>
)}
{!isMobile && (
<SpaceSelector
selectedProjects={[localSelectedProject]}
onValueChange={(projects) =>
setLocalSelectedProject(projects[0] ?? localSelectedProject)
}
variant="insideOut"
/>
)}
{!isMobile && spaceSelector}
<div
className={cn(
"flex items-center gap-2",
isMobile ? "w-full" : "justify-end",
)}
>
{isMobile && spaceSelector}
{!isMobile && (
<Button
variant="ghost"
@ -536,7 +541,7 @@ export function AddDocument({
disabled={
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
}
className={cn(isMobile && "h-12 w-full px-5 text-[15px]")}
className={cn(isMobile && "h-12 flex-1 px-5 text-[15px]")}
>
{isSubmitting ? (
<>

View file

@ -0,0 +1,204 @@
"use client"
import {
Home,
LayoutGrid,
Plus,
MoreHorizontal,
SearchIcon,
Sun,
LifeBuoy,
Settings,
} from "lucide-react"
import { useRouter } from "next/navigation"
import { useQueryState } from "nuqs"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { GraphIcon } from "@/components/integration-icons"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
import { feedbackParam } from "@/lib/search-params"
import NovaOrb from "@/components/nova/nova-orb"
const INTEGRATION_VIEWS: ViewMode[] = [
"integrations",
"mcp",
"plugins",
"chrome",
"connections",
"shortcuts",
"raycast",
"import",
]
interface BottomNavProps {
onAddMemory?: () => void
onOpenSearch?: () => void
}
export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
const router = useRouter()
const { viewMode, setViewMode } = useViewMode()
const [, setFeedbackOpen] = useQueryState("feedback", feedbackParam)
const isHome = viewMode === "dashboard"
const isMemories = viewMode === "list" || viewMode === "graph"
const isMore = INTEGRATION_VIEWS.includes(viewMode)
return (
<nav
aria-label="Primary"
className={cn(
"fixed inset-x-0 bottom-[calc(0.75rem+env(safe-area-inset-bottom))] z-40 flex justify-center px-3 md:hidden",
dmSansClassName(),
)}
>
<div className="flex w-full items-center justify-around rounded-full border border-[#161F2C] bg-muted/95 px-2.5 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.55)] backdrop-blur-xl">
<NavTab
label="Home"
icon={Home}
active={isHome}
onClick={() => void setViewMode("dashboard")}
/>
<NavTab
label="Memories"
icon={LayoutGrid}
active={isMemories}
onClick={() => void setViewMode("list")}
/>
<button
type="button"
aria-label="Open chat"
onClick={() => void setViewMode("chat")}
className="group relative flex size-11 shrink-0 items-center justify-center self-center rounded-full outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/50"
>
<NovaOrb
size={42}
className="pointer-events-none blur-[1px]! transition-transform group-hover:scale-105"
/>
</button>
<NavTab
label="Add"
icon={Plus}
active={false}
onClick={() => onAddMemory?.()}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<NavTabButton label="More" active={isMore}>
<MoreHorizontal className="size-6" />
</NavTabButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
sideOffset={12}
className={cn(
"min-w-[200px] rounded-2xl border border-[#263348]/60 p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #101822 0%, #0A0E14 100%)",
}}
>
<MoreItem icon={SearchIcon} label="Search" onClick={onOpenSearch} />
<MoreItem
icon={GraphIcon}
label="Graph"
onClick={() => void setViewMode("graph")}
/>
<MoreItem
icon={Sun}
label="Integrations"
onClick={() => void setViewMode("integrations")}
/>
<DropdownMenuSeparator className="bg-[#263348]/50" />
<MoreItem
icon={LifeBuoy}
label="Feedback"
onClick={() => setFeedbackOpen(true)}
/>
<MoreItem
icon={Settings}
label="Settings"
onClick={() => router.push("/settings")}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</nav>
)
}
function NavTab({
label,
icon: Icon,
active,
onClick,
}: {
label: string
icon: React.ComponentType<{ className?: string }>
active: boolean
onClick: () => void
}) {
return (
<NavTabButton label={label} active={active} onClick={onClick}>
<Icon className="size-6" />
</NavTabButton>
)
}
function NavTabButton({
label,
active,
onClick,
children,
...props
}: {
label: string
active: boolean
onClick?: () => void
children: React.ReactNode
} & React.ComponentProps<"button">) {
return (
<button
type="button"
aria-current={active ? "page" : undefined}
onClick={onClick}
className={cn(
"flex shrink-0 flex-col items-center gap-1 rounded-full px-3 py-1.5 outline-none transition-colors",
active ? "text-white" : "text-[#737373] hover:text-white",
)}
{...props}
>
{children}
<span className="text-[10px] font-medium leading-none">{label}</span>
</button>
)
}
function MoreItem({
icon: Icon,
label,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
onClick?: () => void
}) {
return (
<DropdownMenuItem
onClick={onClick}
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white hover:bg-[#293952]/40"
>
<Icon className="size-4 text-[#737373]" />
{label}
</DropdownMenuItem>
)
}

View file

@ -7,6 +7,7 @@ import { useProject } from "@/stores"
import { cn } from "@lib/utils"
import type { ModelId } from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
export function HomeChatComposer({
onStartChat,
@ -19,7 +20,7 @@ export function HomeChatComposer({
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
selectedProject,
AUTO_CHAT_SPACE_ID,
])
const send = useCallback(() => {
@ -59,6 +60,7 @@ export function HomeChatComposer({
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
/>
</>

View file

@ -153,7 +153,7 @@ export function ChatSidebar({
const targetHighlightChatIdRef = useRef<string | null>(null)
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
initialChatProject ?? selectedProject,
initialChatProject ?? AUTO_CHAT_SPACE_ID,
])
const chatProject = chatSpaceProjects[0] ?? selectedProject
const { allProjects } = useContainerTags()
@ -248,6 +248,11 @@ export function ChatSidebar({
id: string
messages: UIMessage[]
} | null>(null)
const [loadedThreadScrollTarget, setLoadedThreadScrollTarget] = useState<{
id: string
messageCount: number
lastMessageId: string | null
} | null>(null)
// Adjust chat height based on scroll position (desktop only, grid mode only)
useEffect(() => {
@ -298,6 +303,13 @@ export function ChatSidebar({
useEffect(() => {
if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) {
setMessages(pendingThreadLoad.messages)
setLoadedThreadScrollTarget({
id: pendingThreadLoad.id,
messageCount: pendingThreadLoad.messages.length,
lastMessageId:
pendingThreadLoad.messages[pendingThreadLoad.messages.length - 1]
?.id ?? null,
})
setPendingThreadLoad(null)
}
}, [currentChatId, pendingThreadLoad, setMessages])
@ -653,15 +665,39 @@ export function ChatSidebar({
}
}, [queuedMessage])
// Scroll to bottom when a new user message is added
// Scroll to bottom when a new user message is added or a thread is loaded
useEffect(() => {
const lastMessageId = messages[messages.length - 1]?.id ?? null
const loadedThreadIsRendered =
loadedThreadScrollTarget &&
currentChatId === loadedThreadScrollTarget.id &&
messages.length === loadedThreadScrollTarget.messageCount &&
lastMessageId === loadedThreadScrollTarget.lastMessageId
if (loadedThreadIsRendered) {
// Trigger the same scroll behavior as the button after loaded messages render.
scrollToBottom()
setTimeout(scrollToBottom, 50)
setTimeout(scrollToBottom, 150)
setTimeout(() => {
scrollToBottom()
setLoadedThreadScrollTarget(null)
}, 300)
return
}
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === "user" && messagesContainerRef.current) {
scrollToBottom()
} else {
checkIfScrolledToBottom()
}
}, [messages, checkIfScrolledToBottom, scrollToBottom])
}, [
currentChatId,
loadedThreadScrollTarget,
messages,
checkIfScrolledToBottom,
scrollToBottom,
])
useEffect(() => {
const isStreaming = status === "streaming"
@ -944,6 +980,7 @@ export function ChatSidebar({
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-10 min-h-10 max-w-[min(192px,42vw)] border border-[#73737333] bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset]"
/>
</>
@ -952,100 +989,102 @@ export function ChatSidebar({
{chatToolbarActions}
</div>
) : null}
<div
ref={messagesContainerRef}
className={cn(
"relative flex-1 overflow-y-auto scrollbar-thin",
isPageDesktop && "min-h-0",
"px-4",
dmSansClassName(),
)}
>
{isInputExpanded && (
<div
className={cn(
"absolute inset-0 z-10! pointer-events-none",
isPageDesktop ? "rounded-none" : "rounded-2xl",
)}
style={{ backgroundColor: "#000000E5" }}
/>
)}
{messages.length === 0 && (
<ChatEmptyStatePlaceholder
onSuggestionClick={handleSuggestedQuestion}
suggestions={emptyStateSuggestions}
subtitle={emptyStateSubtitle}
/>
)}
<div className="relative flex-1 min-h-0">
<div
className={
messages.length > 0
? cn(
"flex flex-col space-y-3 min-h-full justify-end",
isPageDesktop ? "pt-2" : "pt-14",
)
: ""
}
>
{messages.map((message, index) => (
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
<div
key={message.id}
className={cn(
"flex gap-2 w-full",
message.role === "user" ? "justify-end" : "justify-start",
)}
onMouseEnter={() =>
message.role === "assistant" && setHoveredMessageId(message.id)
}
onMouseLeave={() =>
message.role === "assistant" && setHoveredMessageId(null)
}
>
{message.role === "user" ? (
<UserMessage
message={message}
copiedMessageId={copiedMessageId}
onCopy={handleCopyMessage}
/>
) : (
<AgentMessage
message={message}
index={index}
messagesLength={messages.length}
hoveredMessageId={hoveredMessageId}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
expandedMemories={expandedMemories}
onCopy={handleCopyMessage}
onLike={handleLikeMessage}
onDislike={handleDislikeMessage}
onToggleMemories={handleToggleMemories}
/>
)}
</div>
))}
{(status === "submitted" || status === "streaming") && (
<div className="flex gap-2">
<SuperLoader label="Thinking…" />
</div>
ref={messagesContainerRef}
className={cn(
"relative h-full overflow-y-auto scrollbar-thin",
"px-4",
dmSansClassName(),
)}
</div>
</div>
{!isScrolledToBottom && messages.length > 0 && (
<div className="absolute bottom-24 left-0 right-0 flex justify-center z-50 pointer-events-none">
<button
type="button"
className="cursor-pointer pointer-events-auto"
onClick={scrollToBottom}
>
{isInputExpanded && (
<div
className={cn(
"absolute inset-0 z-10! pointer-events-none",
isPageDesktop ? "rounded-none" : "rounded-2xl",
)}
style={{ backgroundColor: "#000000E5" }}
/>
)}
{messages.length === 0 && (
<ChatEmptyStatePlaceholder
onSuggestionClick={handleSuggestedQuestion}
suggestions={emptyStateSuggestions}
subtitle={emptyStateSubtitle}
/>
)}
<div
className={
messages.length > 0
? cn(
"flex flex-col space-y-3 min-h-full justify-end",
isPageDesktop ? "pt-2" : "pt-14",
)
: ""
}
>
<div className="rounded-full p-2 bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset] hover:bg-[#0F1620] transition-colors">
<ChevronDownIcon className="size-4 text-white" />
</div>
</button>
{messages.map((message, index) => (
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
<div
key={message.id}
className={cn(
"flex gap-2 w-full",
message.role === "user" ? "justify-end" : "justify-start",
)}
onMouseEnter={() =>
message.role === "assistant" &&
setHoveredMessageId(message.id)
}
onMouseLeave={() =>
message.role === "assistant" && setHoveredMessageId(null)
}
>
{message.role === "user" ? (
<UserMessage
message={message}
copiedMessageId={copiedMessageId}
onCopy={handleCopyMessage}
/>
) : (
<AgentMessage
message={message}
index={index}
messagesLength={messages.length}
hoveredMessageId={hoveredMessageId}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
expandedMemories={expandedMemories}
onCopy={handleCopyMessage}
onLike={handleLikeMessage}
onDislike={handleDislikeMessage}
onToggleMemories={handleToggleMemories}
/>
)}
</div>
))}
{(status === "submitted" || status === "streaming") && (
<div className="flex gap-2">
<SuperLoader label="Thinking…" />
</div>
)}
</div>
</div>
)}
{!isScrolledToBottom && messages.length > 0 && (
<div className="absolute bottom-3 left-0 right-0 flex justify-center z-50 pointer-events-none">
<button
type="button"
className="cursor-pointer pointer-events-auto"
onClick={scrollToBottom}
>
<div className="rounded-full p-2 bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset] hover:bg-[#0F1620] transition-colors">
<ChevronDownIcon className="size-4 text-white" />
</div>
</button>
</div>
)}
</div>
{chatStreamError && (
<div
@ -1103,7 +1142,7 @@ export function ChatSidebar({
className={cn(
"shrink-0",
isStackedInput &&
"px-4 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6",
"pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6",
)}
>
<ChatInput
@ -1138,6 +1177,7 @@ export function ChatSidebar({
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
/>
</>

View file

@ -1,5 +1,6 @@
import { cn } from "@lib/utils"
import { SquareIcon } from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
export function SendButton({
onClick,
@ -8,7 +9,7 @@ export function SendButton({
onClick: () => void
disabled: boolean
}) {
return (
const button = (
<button
type="button"
onClick={onClick}
@ -35,6 +36,19 @@ export function SendButton({
</svg>
</button>
)
if (disabled) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{button}</span>
</TooltipTrigger>
<TooltipContent side="top">Type a message to send</TooltipContent>
</Tooltip>
)
}
return button
}
export function StopButton({ onClick }: { onClick: () => void }) {

View file

@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useEffect, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/lib/fonts"
@ -23,6 +23,21 @@ export default function ChatModelSelector({
const [internalModel, setInternalModel] =
useState<ModelId>("claude-sonnet-4.6")
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [isOpen])
const selectedModel = selectedModelProp ?? internalModel
const currentModelData = modelNames[selectedModel]
@ -73,53 +88,44 @@ export default function ChatModelSelector({
)
return (
<div className="relative flex min-w-0 shrink items-center gap-2">
<div
ref={containerRef}
className="relative z-10 flex min-w-0 shrink items-center gap-2"
>
{trigger}
{isOpen && (
<>
<button
type="button"
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setIsOpen(false)}
aria-label="Close model selector"
/>
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface-card backdrop-blur-xl border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-2 space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]
return (
<button
key={model.id}
type="button"
className={cn(
"flex flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
selectedModel === model.id
? "bg-[#293952]/60"
: "hover:bg-[#293952]/40",
)}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="text-sm font-medium text-white">
{modelData.name}{" "}
<span className="text-fg-subtle">
{modelData.version}
</span>
</div>
<div className="text-xs text-fg-muted truncate w-full">
{model.description}
</div>
</button>
)
})}
</div>
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface-card backdrop-blur-xl border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-2 space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]
return (
<button
key={model.id}
type="button"
className={cn(
"flex flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
selectedModel === model.id
? "bg-[#293952]/60"
: "hover:bg-[#293952]/40",
)}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="text-sm font-medium text-white">
{modelData.name}{" "}
<span className="text-fg-subtle">{modelData.version}</span>
</div>
<div className="text-xs text-fg-muted truncate w-full">
{model.description}
</div>
</button>
)
})}
</div>
</>
</div>
)}
</div>
)

View file

@ -338,8 +338,8 @@ export function ConnectAIModal({
return (
<Dialog onOpenChange={setIsOpen} open={isOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogContent className="max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[calc(100vw-1rem)] overflow-y-auto overflow-x-hidden p-4 sm:max-w-4xl sm:p-6">
<DialogHeader className="pr-7 sm:pr-0">
<DialogTitle>Connect supermemory to Your AI</DialogTitle>
<DialogDescription>
Enable your AI assistant to create, search, and access your memories
@ -347,9 +347,9 @@ export function ConnectAIModal({
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="min-w-0 space-y-6">
{/* Step 1: Client Selection */}
<div className="space-y-4">
<div className="min-w-0 space-y-4">
<div className="flex items-center gap-3">
<div className="size-8 rounded-full flex items-center justify-center text-sm font-semibold bg-accent text-accent-foreground">
1
@ -357,14 +357,15 @@ export function ConnectAIModal({
<h3 className="text-sm font-medium">Select Your AI Client</h3>
</div>
<div className="space-x-2 space-y-2">
<div className="flex min-w-0 flex-wrap gap-2">
{Object.entries(clients).map(([key, clientName]) => (
<button
className={`pr-3 pl-1 rounded-full border cursor-pointer transition-all ${
className={cn(
"max-w-full rounded-full border py-0.5 pr-3 pl-1 cursor-pointer transition-all",
selectedClient === key
? "border-primary bg-primary/10"
: "border-border hover:border-border/60 hover:bg-muted/50"
}`}
: "border-border hover:border-border/60 hover:bg-muted/50",
)}
key={key}
onClick={() => {
setSelectedClient(key as keyof typeof clients)
@ -372,8 +373,8 @@ export function ConnectAIModal({
}}
type="button"
>
<div className="flex items-center gap-1">
<div className="size-8 flex items-center justify-center">
<div className="flex min-w-0 items-center gap-1">
<div className="size-7 shrink-0 flex items-center justify-center sm:size-8">
<Image
alt={clientName}
className="rounded object-contain"
@ -403,7 +404,7 @@ export function ConnectAIModal({
width={20}
/>
</div>
<span className="text-sm font-medium text-foreground/80">
<span className="truncate text-sm font-medium text-foreground/80">
{clientName}
</span>
</div>
@ -413,7 +414,7 @@ export function ConnectAIModal({
</div>
{selectedClient && (
<div className="space-y-4">
<div className="min-w-0 space-y-4">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-full bg-accent text-sm font-semibold text-accent-foreground">
2
@ -424,13 +425,13 @@ export function ConnectAIModal({
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
{clientSetup && mcpClientSetupShowsTabs(clientSetup) ? (
<div
className="flex w-full max-w-md flex-row gap-1 rounded-full border border-border bg-muted/50 p-1 sm:w-auto sm:min-w-[280px]"
className="flex w-full min-w-0 max-w-full flex-row gap-1 rounded-full border border-border bg-muted/50 p-1 sm:w-auto sm:min-w-[280px]"
role="tablist"
aria-label="Setup method"
>
<button
className={cn(
"min-h-9 flex-1 rounded-full px-3 py-2 text-center text-xs font-medium transition-all",
"min-h-9 flex-1 rounded-full px-2 py-2 text-center text-xs font-medium transition-all sm:px-3",
effectiveSetupTab === "manual"
? "border border-border bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
@ -444,7 +445,7 @@ export function ConnectAIModal({
</button>
<button
className={cn(
"min-h-9 flex-1 rounded-full px-3 py-2 text-center text-xs font-medium transition-all",
"min-h-9 flex-1 rounded-full px-2 py-2 text-center text-xs font-medium transition-all sm:px-3",
effectiveSetupTab === "oneClick"
? "border border-border bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
@ -461,7 +462,7 @@ export function ConnectAIModal({
{selectedClient !== "mcp-url" && (
<div
className={cn(
"flex justify-end",
"flex justify-start sm:justify-end",
!(
clientSetup && mcpClientSetupShowsTabs(clientSetup)
) && "w-full sm:justify-start",
@ -523,9 +524,9 @@ export function ConnectAIModal({
Paste this URL into clients that support remote MCP
over HTTPS (OAuth).
</p>
<div className="relative max-w-xl">
<div className="relative max-w-full sm:max-w-xl">
<Input
className="w-full pr-10 font-mono text-xs"
className="w-full min-w-0 pr-10 font-mono text-xs"
readOnly
value={CHATGPT_REMOTE_MCP_URL}
/>
@ -552,7 +553,7 @@ export function ConnectAIModal({
Optional: scope installs to a project. Then copy and
run the command in your terminal.
</p>
<div className="max-w-md">
<div className="max-w-full sm:max-w-md">
<Select
disabled={isLoadingProjects}
onValueChange={setSelectedProject}
@ -586,7 +587,7 @@ export function ConnectAIModal({
</div>
<div className="relative max-w-full">
<Input
className="w-full pr-10 font-mono text-xs"
className="w-full min-w-0 pr-10 font-mono text-xs"
readOnly
value={generateInstallCommand()}
/>
@ -622,9 +623,9 @@ export function ConnectAIModal({
</li>
<li>Complete OAuth in ChatGPT.</li>
</ol>
<div className="relative max-w-xl">
<div className="relative max-w-full sm:max-w-xl">
<Input
className="w-full pr-10 font-mono text-xs"
className="w-full min-w-0 pr-10 font-mono text-xs"
readOnly
value={CHATGPT_REMOTE_MCP_URL}
/>
@ -677,8 +678,8 @@ export function ConnectAIModal({
</div>
) : (
<>
<div className="relative max-w-full">
<pre className="max-h-80 overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-4 pr-12 text-xs">
<div className="relative min-w-0 max-w-full">
<pre className="max-h-80 max-w-full overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-3 pr-12 text-xs sm:p-4">
<code className="block font-mono whitespace-pre-wrap break-all">
{remoteSnippet}
</code>
@ -715,8 +716,8 @@ export function ConnectAIModal({
Merge the snippet with your existing config. Restart
the client and sign in with OAuth when prompted.
</p>
<div className="relative max-w-full">
<pre className="max-h-80 overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-4 pr-12 text-xs">
<div className="relative min-w-0 max-w-full">
<pre className="max-h-80 max-w-full overflow-x-auto overflow-y-auto rounded-lg border border-border bg-muted p-3 pr-12 text-xs sm:p-4">
<code className="block font-mono whitespace-pre-wrap break-all">
{manual.snippet}
</code>
@ -782,8 +783,8 @@ export function ConnectAIModal({
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="flex items-center justify-between mb-3">
<div className="rounded-lg border border-border bg-muted/50 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium">Connection Status</h3>
<div className="flex items-center gap-2 text-xs">
{isCheckingConnection ? (
@ -817,8 +818,8 @@ export function ConnectAIModal({
</ul>
</div>
<div className="flex justify-between items-center pt-4">
<div className="flex items-center gap-4">
<div className="flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
<Button
onClick={() =>
window.open(
@ -839,7 +840,12 @@ export function ConnectAIModal({
Migrate from v1
</Button>
</div>
<Button onClick={() => setIsOpen(false)}>Done</Button>
<Button
className="w-full sm:w-auto"
onClick={() => setIsOpen(false)}
>
Done
</Button>
</div>
</div>
</DialogContent>

View file

@ -7,12 +7,14 @@ import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useQuery } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import Image from "next/image"
import {
ArrowRight,
ExternalLink,
FileText,
Lightbulb,
Link2,
Plug,
RotateCcw,
SearchIcon,
Terminal,
@ -37,6 +39,7 @@ import {
usePersonalization,
type Profession,
} from "@/hooks/use-personalization"
import { normalizePluginClientId } from "@/lib/plugin-catalog"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -268,6 +271,22 @@ const PROFESSION_LABELS: {
{ value: "medical", label: "Medical" },
]
function getChangeProfessionPrompt(profession: Profession): string {
const prompts: Record<Exclude<Profession, "default">, string> = {
developer: "Not a developer?",
research: "Not a researcher?",
finance: "Not in finance?",
design: "Not a designer?",
legal: "Not in the legal field?",
marketing: "Not in marketing?",
medical: "Not in healthcare?",
}
if (profession === "default") {
return "Not your role?"
}
return prompts[profession] ?? "Not your role?"
}
// Static plugin metadata — shared between PluginPromoCard and RecommendedPluginsCard
const PLUGIN_STATIC = [
{
@ -313,6 +332,468 @@ const PLUGIN_STATIC = [
},
] as const
// Plugin catalog for tool usage display - maps plugin IDs to display names and icons
const PLUGIN_DISPLAY_CATALOG: Record<
string,
{ name: string; icon: string | null; type: "Plugin" }
> = {
claude_code: {
name: "Claude Code",
icon: "/images/plugins/claude-code.svg",
type: "Plugin",
},
opencode: {
name: "OpenCode",
icon: "/images/plugins/opencode.svg",
type: "Plugin",
},
openclaw: {
name: "OpenClaw",
icon: "/images/plugins/openclaw.svg",
type: "Plugin",
},
hermes: {
name: "Hermes",
icon: "/images/plugins/hermes.svg",
type: "Plugin",
},
codex: {
name: "OpenAI Codex",
icon: "/mcp-supported-tools/codex.png",
type: "Plugin",
},
}
// Types for tool usage
interface ToolUsageItem {
id: string
name: string
type: "Plugin" | "MCP"
icon: string | null
lastUsedAt: Date | null
hasBeenUsed: boolean
connectedAt: Date | null
lastDocumentTitle: string | null
lastDocumentId: string | null
lastDocumentPreview: string | null
lastDocument: DocumentWithMemories | null
}
type ToolUsageApiKey = {
id: string
name: string
createdAt: string
lastRequest: string | null
metadata: string
}
function toValidDate(value: string | Date | null | undefined): Date | null {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function compactText(value: string): string {
return value.replace(/\s+/g, " ").trim()
}
function getDocumentText(document: DocumentWithMemories): string {
return typeof document.content === "string" ? document.content : ""
}
function toMetadataRecord(value: unknown): Record<string, unknown> | null {
if (!value) return null
if (typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value !== "string") return null
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
}
function getDocumentMetadataRecords(
document: DocumentWithMemories,
): Record<string, unknown>[] {
const records = [toMetadataRecord(document.metadata)]
for (const entry of document.memoryEntries ?? []) {
records.push(
toMetadataRecord(entry.metadata),
toMetadataRecord(entry.sourceMetadata),
)
}
return records.filter((record): record is Record<string, unknown> => !!record)
}
function hasClaudeCodeContainer(document: DocumentWithMemories): boolean {
const containerTags =
(document as { containerTags?: string[] }).containerTags ?? []
if (containerTags.some((tag) => tag.startsWith("claudecode_"))) return true
return (document.memoryEntries ?? []).some((entry) =>
entry.spaceContainerTag?.startsWith("claudecode_"),
)
}
function getPluginClientFromDocument(
document: DocumentWithMemories,
): string | null {
for (const metadata of getDocumentMetadataRecords(document)) {
const metadataClient =
typeof metadata.sm_client === "string"
? metadata.sm_client
: typeof metadata.sm_internal_plugin_client === "string"
? metadata.sm_internal_plugin_client
: typeof metadata.sm_internal_mcp_client_name === "string"
? metadata.sm_internal_mcp_client_name
: null
if (metadataClient) return normalizePluginClientId(metadataClient)
if (metadata.sm_source === "claude-code-plugin") return "claude_code"
}
if (hasClaudeCodeContainer(document)) return "claude_code"
const content = getDocumentText(document)
const title = document.title ?? ""
if (
/\[Session\s+[^\]]+\]/i.test(content) ||
/\[SAVE:[^\]]+\]/i.test(content)
) {
return "codex"
}
if (/\bCodex\b/i.test(title)) return "codex"
return null
}
function getDocumentPreview(document: DocumentWithMemories): string | null {
const summary =
typeof document.summary === "string" ? compactText(document.summary) : ""
if (summary) return summary
const content = getDocumentText(document)
if (!content) return document.title?.trim() || null
const transcriptTurns = Array.from(
content.matchAll(
/\d+\.\s+\[(user|assistant)\]\s*([\s\S]*?)(?=\d+\.\s+\[(?:user|assistant|tool|system)\]|---|\[\/?[A-Za-z]|$)/gi,
),
)
.slice(0, 2)
.map((match) => {
const role = match[1] === "assistant" ? "Assistant" : "You"
const text = compactText(match[2] ?? "")
return text ? `${role}: ${text}` : null
})
.filter(Boolean)
if (transcriptTurns.length > 0) return transcriptTurns.join(" · ")
const cleaned = compactText(
content
.replace(/\[Session\s+[^\]]+\]/gi, "")
.replace(/\[SAVE:[^\]]+\]/gi, "")
.replace(/\[\/SAVE\]/gi, ""),
)
return cleaned || document.title?.trim() || null
}
function getToolDocumentTitle(document: DocumentWithMemories): string {
return document.title?.trim() || "Recent conversation"
}
// Parse API keys to extract tool usage data
function parseToolUsage(
apiKeys: ToolUsageApiKey[],
recentMcpDocuments: DocumentWithMemories[] = [],
): ToolUsageItem[] {
const toolMap = new Map<string, ToolUsageItem>()
let latestMcpClientName: string | null = null
let latestMcpDocumentAt: Date | null = null
const latestDocPerPlugin = new Map<
string,
{
title: string
id: string
at: Date
preview: string | null
document: DocumentWithMemories
}
>()
for (const key of apiKeys) {
let meta: Record<string, unknown> = {}
try {
meta = key.metadata ? JSON.parse(key.metadata) : {}
} catch {
continue
}
const smType = meta.sm_type as string | undefined
const smClient = meta.sm_client as string | undefined
const smSource = meta.sm_source as string | undefined
const smKind = meta.sm_kind as string | undefined
// Plugin keys
if (smType === "plugin_auth" && smClient) {
const normalizedClient = normalizePluginClientId(smClient)
const catalog = PLUGIN_DISPLAY_CATALOG[normalizedClient]
const existingItem = toolMap.get(`plugin_${normalizedClient}`)
const lastUsed =
toValidDate(key.lastRequest) ?? toValidDate(key.createdAt)
const existingLastUsed = existingItem?.lastUsedAt
// Keep the most recent usage
if (
!existingItem ||
(lastUsed &&
(!existingLastUsed ||
lastUsed.getTime() > existingLastUsed.getTime()))
) {
toolMap.set(`plugin_${normalizedClient}`, {
id: `plugin_${normalizedClient}`,
name: catalog?.name ?? smClient,
type: "Plugin",
icon: catalog?.icon ?? null,
lastUsedAt: lastUsed,
hasBeenUsed: !!key.lastRequest,
connectedAt: toValidDate(key.createdAt),
lastDocumentTitle: null,
lastDocumentId: null,
lastDocumentPreview: null,
lastDocument: null,
})
}
}
// MCP keys
if (smSource === "mcp" || smKind === "mcp_oauth_exchange") {
const existingItem = toolMap.get("mcp")
const lastUsed =
toValidDate(key.lastRequest) ?? toValidDate(key.createdAt)
const existingLastUsed = existingItem?.lastUsedAt
// Keep the most recent usage
if (
!existingItem ||
(lastUsed &&
(!existingLastUsed ||
lastUsed.getTime() > existingLastUsed.getTime()))
) {
toolMap.set("mcp", {
id: "mcp",
name: "Supermemory MCP",
type: "MCP",
icon: null,
lastUsedAt: lastUsed,
hasBeenUsed: !!key.lastRequest,
connectedAt: toValidDate(key.createdAt),
lastDocumentTitle: null,
lastDocumentId: null,
lastDocumentPreview: null,
lastDocument: null,
})
}
}
}
for (const doc of recentMcpDocuments) {
const metadataRecords = getDocumentMetadataRecords(doc)
const clientName =
metadataRecords
.map((record) => record.sm_internal_mcp_client_name)
.find((value): value is string => typeof value === "string") ?? null
const pluginClient = getPluginClientFromDocument(doc)
const isMcpDocument =
doc.source === "mcp" ||
metadataRecords.some(
(record) => record.sm_internal_event_from === "mcp",
) ||
!!clientName
const isPluginDocument = !!pluginClient && pluginClient !== "mcp"
if (!isMcpDocument && !isPluginDocument) continue
const createdAt = toValidDate(doc.createdAt)
if (
isMcpDocument &&
clientName &&
clientName !== "unknown" &&
(!latestMcpDocumentAt ||
(createdAt && createdAt.getTime() > latestMcpDocumentAt.getTime()))
) {
latestMcpClientName = clientName
latestMcpDocumentAt = createdAt
}
if (isMcpDocument) {
const existingItem = toolMap.get("mcp")
const existingLastUsed = existingItem?.lastUsedAt
if (
!existingItem ||
(createdAt &&
(!existingLastUsed ||
createdAt.getTime() > existingLastUsed.getTime()))
) {
toolMap.set("mcp", {
id: "mcp",
name:
clientName && clientName !== "unknown"
? clientName
: "Supermemory MCP",
type: "MCP",
icon: null,
lastUsedAt: createdAt,
hasBeenUsed: true,
connectedAt: toolMap.get("mcp")?.connectedAt ?? null,
lastDocumentTitle: doc.title?.trim() || null,
lastDocumentId: doc.id ?? null,
lastDocumentPreview: getDocumentPreview(doc),
lastDocument: doc,
})
}
}
if (pluginClient && createdAt && doc.id) {
const existing = latestDocPerPlugin.get(pluginClient)
if (!existing || createdAt.getTime() > existing.at.getTime()) {
latestDocPerPlugin.set(pluginClient, {
title: getToolDocumentTitle(doc),
id: doc.id,
at: createdAt,
preview: getDocumentPreview(doc),
document: doc,
})
}
}
}
if (latestMcpClientName) {
const existingItem = toolMap.get("mcp")
if (existingItem) {
toolMap.set("mcp", {
...existingItem,
name: latestMcpClientName,
})
}
}
// Attach latest document info to plugin items where available from MCP documents
for (const [, item] of toolMap) {
if (item.type === "Plugin" && !item.lastDocumentTitle) {
const pluginId = item.id.replace(/^plugin_/, "")
const docInfo = latestDocPerPlugin.get(pluginId)
if (docInfo) {
item.lastDocumentTitle = docInfo.title
item.lastDocumentId = docInfo.id
item.lastDocumentPreview = docInfo.preview
item.lastDocument = docInfo.document
}
}
}
// Sort by lastUsedAt (most recent first), then by hasBeenUsed
return Array.from(toolMap.values()).sort((a, b) => {
// Items that have been used come first
if (a.hasBeenUsed !== b.hasBeenUsed) {
return a.hasBeenUsed ? -1 : 1
}
// Then sort by recency
if (!a.lastUsedAt && !b.lastUsedAt) return 0
if (!a.lastUsedAt) return 1
if (!b.lastUsedAt) return -1
return b.lastUsedAt.getTime() - a.lastUsedAt.getTime()
})
}
// Format relative time for tool usage
function formatToolUsageTime(date: Date | null, hasBeenUsed: boolean): string {
if (!hasBeenUsed) return "Never used"
if (!date) return "Connected"
const diffMs = Date.now() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays === 1) return "Yesterday"
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function ToolUsageRecentRow({
item,
onOpenPlugins,
onOpenToolDocument,
}: {
item: ToolUsageItem
onOpenPlugins: () => void
onOpenToolDocument: (
document: DocumentWithMemories,
pluginClientId: string,
) => void
}) {
const pluginClientId = item.id.replace(/^plugin_/, "")
return (
<li>
<button
type="button"
onClick={() => {
if (item.lastDocument) {
onOpenToolDocument(item.lastDocument, pluginClientId)
return
}
onOpenPlugins()
}}
className="group flex w-full items-start gap-3 rounded-lg px-2.5 py-2 text-left transition-all hover:bg-surface-hover hover:py-2.5"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface-card ring-1 ring-surface-border group-hover:bg-[#182333] transition-colors">
{item.icon ? (
<Image
src={item.icon}
alt={item.name}
width={14}
height={14}
className="size-3.5"
/>
) : item.type === "MCP" ? (
<MCPIcon className="size-3.5" />
) : (
<Plug className="size-3.5 text-fg-subtle" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm text-fg-muted group-hover:text-white transition-colors">
{item.lastDocumentTitle ?? "No saved memory yet"}
</span>
<span className="shrink-0 text-[10px] text-fg-faint">
{formatToolUsageTime(item.lastUsedAt, item.hasBeenUsed)}
</span>
</div>
{item.lastDocumentPreview ? (
<p className="mt-0 max-h-0 overflow-hidden text-[11px] leading-snug text-fg-subtle opacity-0 transition-all duration-200 line-clamp-2 group-hover:mt-1 group-hover:max-h-9 group-hover:opacity-100">
{item.lastDocumentPreview}
</p>
) : null}
</div>
</button>
</li>
)
}
function RecommendedPluginsCard({
profession,
setProfession,
@ -353,7 +834,7 @@ function RecommendedPluginsCard({
return PLUGIN_STATIC.map((p) => ({
...p,
connected: connected[p.id] ?? false,
onClick: onClicks[p.id]!,
onClick: onClicks[p.id] ?? (() => {}),
}))
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
@ -446,11 +927,7 @@ function RecommendedPluginsCard({
onClick={() => setIsEditing(true)}
className="text-left px-2 pb-1 text-[10px] text-fg-faint hover:text-fg-muted transition-colors cursor-pointer"
>
Not a{" "}
{PROFESSION_LABELS.find(
(p) => p.value === profession,
)?.label.toLowerCase()}
? Change →
{getChangeProfessionPrompt(profession)} Change role →
</button>
</>
)}
@ -525,7 +1002,7 @@ function PluginPromoCard({
return PLUGIN_STATIC.map((p) => ({
...p,
connected: connected[p.id] ?? false,
onClick: onClicks[p.id]!,
onClick: onClicks[p.id] ?? (() => {}),
})).filter((p) => !p.connected)
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
@ -628,9 +1105,10 @@ export function DashboardView({
onOpenSearch,
onOpenIntegrations,
onOpenPlugins,
onNavigateToMemories,
onNavigateToMemories: _onNavigateToMemories,
onNavigateToGraph,
onOpenDocument,
onOpenToolDocument,
onHighlightsChat,
onHighlightsShowRelated,
onResetHighlights,
@ -647,12 +1125,16 @@ export function DashboardView({
onNavigateToMemories: () => void
onNavigateToGraph: () => void
onOpenDocument: (document: DocumentWithMemories) => void
onOpenToolDocument: (
document: DocumentWithMemories,
pluginClientId: string,
) => void
onHighlightsChat: (highlightContent: string, userReply: string) => void
onHighlightsShowRelated: (query: string) => void
onResetHighlights: () => void
memoryOfDay: MemoryOfDay | null
}) {
const { user } = useAuth()
const { user, org } = useAuth()
const { effectiveContainerTags } = useProject()
const _router = useRouter()
const { data: recentsData, isPending: isRecentsLoading } = useQuery({
@ -672,7 +1154,7 @@ export function DashboardView({
return response.data as DocumentsResponse
},
staleTime: 60 * 1000,
enabled: !!user,
enabled: !!user && !!org?.id,
})
const { data: connections = [] } = useQuery({
@ -698,6 +1180,58 @@ export function DashboardView({
enabled: !!user,
})
// Fetch API keys for tool usage tracking
const { data: apiKeysData } = useQuery({
queryKey: ["api-keys-tool-usage", org?.id],
queryFn: async () => {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const res = await fetch(`${API_URL}/v3/auth/keys`, {
credentials: "include",
})
if (!res.ok) return { keys: [] }
return (await res.json()) as {
keys: Array<{
id: string
name: string
createdAt: string
lastRequest: string | null
metadata: string
}>
}
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const { data: recentMcpDocumentsData } = useQuery({
queryKey: ["dashboard-tool-documents", org?.id],
queryFn: async (): Promise<DocumentsResponse> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 50,
sort: "createdAt",
order: "desc",
},
disableValidation: true,
})
if (response.error) throw new Error(response.error?.message)
return response.data as DocumentsResponse
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const toolUsageItems = useMemo(
() =>
parseToolUsage(
apiKeysData?.keys ?? [],
recentMcpDocumentsData?.documents ?? [],
),
[apiKeysData, recentMcpDocumentsData],
)
const {
copy: personalizedCopy,
profession,
@ -705,6 +1239,14 @@ export function DashboardView({
} = usePersonalization()
const recents = recentsData?.documents ?? []
const recentToolUsageItems = toolUsageItems
.filter((item) => item.type === "Plugin" && item.lastDocument)
.sort((a, b) => {
const aTime = toValidDate(a.lastDocument?.createdAt)?.getTime() ?? 0
const bTime = toValidDate(b.lastDocument?.createdAt)?.getTime() ?? 0
return bTime - aTime
})
.slice(0, 3)
const totalMemories = recentsData?.pagination?.totalItems ?? 0
const hasMcp = mcpData?.previousLogin ?? false
const connectedProviders = new Set(connections.map((c) => c.provider))
@ -755,7 +1297,7 @@ export function DashboardView({
Home
</p>
<h1
className="max-w-2xl text-xl font-medium tracking-tight text-white md:text-2xl"
className="max-w-2xl text-lg font-medium leading-snug tracking-tight text-white md:text-2xl md:leading-tight"
title={spaceLabel}
>
{homeHeadline}
@ -767,7 +1309,7 @@ export function DashboardView({
<button
type="button"
onClick={onNavigateToGraph}
className="group relative shrink-0 w-[140px] h-[56px] rounded-xl overflow-hidden border border-surface-border hover:border-[#3A4A63] transition-all bg-surface-card hover:scale-[1.02]"
className="group relative hidden h-[56px] w-[140px] shrink-0 overflow-hidden rounded-xl border border-surface-border bg-surface-card transition-all hover:scale-[1.02] hover:border-[#3A4A63] md:block"
aria-label="Open graph view"
>
<StaticGraphPreview
@ -819,6 +1361,7 @@ export function DashboardView({
onChat={onHighlightsChat}
onShowRelated={onHighlightsShowRelated}
isLoading={isLoadingHighlights}
onAddMemory={() => onAddMemory("note")}
/>
</div>
<div className="flex-[2] hidden sm:block min-w-0">
@ -895,9 +1438,9 @@ export function DashboardView({
className="space-y-2"
>
<div className="flex gap-4">
<div className="flex-[3] min-w-0">
<div className="flex-[4] min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Recently saved
Recents
</p>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
@ -908,7 +1451,7 @@ export function DashboardView({
</div>
<div className="flex gap-4 items-start">
<div className="flex-[3] min-w-0">
<div className="flex-[4] min-w-0">
{isRecentsLoading ? (
<ul
className="space-y-0.5"
@ -929,8 +1472,16 @@ export function DashboardView({
</li>
))}
</ul>
) : recents.length > 0 ? (
) : recents.length > 0 || recentToolUsageItems.length > 0 ? (
<ul className="space-y-0.5">
{recentToolUsageItems.map((item) => (
<ToolUsageRecentRow
key={item.id}
item={item}
onOpenPlugins={onOpenPlugins}
onOpenToolDocument={onOpenToolDocument}
/>
))}
{recents.map((doc) => {
const isLink = !!doc.url
return (

View file

@ -103,7 +103,7 @@ export function DocumentContent({
return <TextEditorContent {...textEditorProps} />
case "pdf":
return <PdfViewer url={document.url} />
return <PdfViewer url={document.url} documentId={document.id} />
case "notion":
return <NotionDoc content={document.content ?? ""} />

View file

@ -1,7 +1,7 @@
"use client"
import { Document, Page, pdfjs } from "react-pdf"
import { useCallback, useState } from "react"
import { useCallback, useMemo, useState } from "react"
import "react-pdf/dist/Page/AnnotationLayer.css"
import "react-pdf/dist/Page/TextLayer.css"
@ -13,9 +13,25 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
interface PdfViewerProps {
url: string | null | undefined
documentId?: string | null
}
export function PdfViewer({ url }: PdfViewerProps) {
export function PdfViewer({ url, documentId }: PdfViewerProps) {
const fileSource = useMemo(() => {
if (!url) return null
try {
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
const base =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
return {
url: `${base}/v3/drive-proxy/${documentId}`,
withCredentials: true,
}
}
} catch {}
return url
}, [url, documentId])
const [numPages, setNumPages] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -70,7 +86,7 @@ export function PdfViewer({ url }: PdfViewerProps) {
<Document
key={retryKey}
file={
url ||
fileSource ||
"https://corsproxy.io/?" +
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
}

View file

@ -3,6 +3,18 @@
import { useEffect } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useAuth } from "@lib/auth-context"
import { Loader2 } from "lucide-react"
function WorkspaceLoadingShell() {
return (
<div className="flex min-h-dvh items-center justify-center bg-[#05080D]">
<Loader2
className="size-6 animate-spin text-white/40"
aria-label="Loading workspace"
/>
</div>
)
}
export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
@ -11,6 +23,7 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const { session, organizations, isRestoring } = useAuth()
const isMcpPublicPage = searchParams.get("view") === "mcp"
const isOnboarding = pathname.startsWith("/onboarding")
useEffect(() => {
if (isMcpPublicPage) return
@ -23,9 +36,30 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
}
if (organizations === null) return
if (organizations.length > 0) return
if (pathname.startsWith("/onboarding")) return
if (isOnboarding) return
router.replace("/onboarding")
}, [session, organizations, isRestoring, pathname, router, isMcpPublicPage])
}, [
session,
organizations,
isRestoring,
isOnboarding,
router,
isMcpPublicPage,
])
const showLoading =
!isMcpPublicPage &&
(isRestoring ||
(!session && !isRestoring) ||
(session && organizations === null) ||
(session &&
organizations !== null &&
organizations.length === 0 &&
!isOnboarding))
if (showLoading) {
return <WorkspaceLoadingShell />
}
return children
}

View file

@ -46,7 +46,6 @@ export const GraphLayoutView = memo(function GraphLayoutView({
maxNodes={undefined}
canvasRef={canvasRef}
onOpenDocument={onOpenDocument}
autoFetchAll
/>
</div>

View file

@ -39,14 +39,39 @@ import { FeedbackModal } from "./feedback-modal"
import { useViewMode } from "@/lib/view-mode-context"
import { useQueryState } from "nuqs"
import { feedbackParam } from "@/lib/search-params"
import { useCustomer } from "autumn-js/react"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
interface HeaderProps {
onAddMemory?: () => void
onOpenSearch?: () => void
}
const brainItemClass = (active: boolean) =>
cn(
"gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium cursor-pointer transition-colors",
"hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white",
active ? "bg-white/[0.06] text-white" : "text-white/85",
)
const brainTileClass = (active: boolean) =>
cn(
"flex size-7 shrink-0 items-center justify-center rounded-lg border text-[11px] font-semibold",
active
? "border-[#2261CA66] bg-[#0B2A57] text-[#7EB0FF]"
: "border-white/[0.08] bg-white/[0.04] text-white/70",
)
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
const { user, isRestoring } = useAuth()
const { user, isRestoring, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const planByOrgId = new Map(
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
)
const { selectedProjects, setSelectedProjects } = useProject()
const router = useRouter()
const isMobile = useIsMobile()
@ -66,6 +91,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
user?.email?.split("@")[0] ||
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
const topLabel = orgLabel || userName
const hasOrgs = (organizations?.length ?? 0) > 0
const selectOrg = (slug: string, isActive: boolean) => {
if (isActive) return
void setActiveOrg(slug).then(() => window.location.reload())
}
return (
<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">
@ -73,34 +106,65 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
<DropdownMenuTrigger asChild>
<button
type="button"
className="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 md:-ml-2"
className="relative flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none md:-ml-2 before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']"
>
<Logo className="h-6 md:h-7" />
{!isMobile && userName && (
<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="-mt-0.5 text-base leading-none font-medium text-white/90 sm:text-lg">
supermemory
</p>
</div>
)}
<Logo className="h-6" />
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
<p className="max-w-[16ch] truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
{topLabel}
</p>
<p className="-mt-0.5 text-sm leading-none font-medium text-white/90 sm:text-lg">
supermemory
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
alignOffset={12}
className={cn(
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
"min-w-[244px] p-1.5 rounded-xl border border-white/[0.08] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
{hasOrgs && (
<>
<p className="px-2 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[#5B6675]">
Switch brain
</p>
<div className="flex max-h-[40vh] flex-col gap-0.5 overflow-y-auto overscroll-contain">
{organizations?.map((o) => {
const active = org?.id === o.id
const plan = resolveOrgPlan(
o.id,
active,
currentPlan,
planByOrgId,
)
return (
<DropdownMenuItem
key={o.id}
onClick={() => selectOrg(o.slug, active)}
className={cn(brainItemClass(active))}
>
<span className={cn(brainTileClass(active))}>
{o.name?.trim().charAt(0).toUpperCase() || "?"}
</span>
<span className="flex-1 truncate">{o.name}</span>
<OrgPlanBadge plan={plan} />
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
</>
)}
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Link href="/">
<Home className="size-4 text-[#737373]" />
@ -109,7 +173,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<a
href="https://console.supermemory.ai"
@ -122,7 +186,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
<ExternalLink className="size-4 text-[#737373]" />
@ -290,49 +354,49 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
>
<DropdownMenuItem
onClick={onAddMemory}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Plus className="size-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"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Home className="size-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"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
<DropdownMenuItem
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"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<GraphIcon className="size-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"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<SearchIcon className="size-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"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<LayoutGrid className="size-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"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<MessageCircleIcon className="size-4 text-[#737373]" />
Chat with Nova
@ -340,14 +404,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
onClick={handleFeedback}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<LifeBuoy className="size-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Settings className="size-4 text-[#737373]" />
Settings
@ -444,7 +508,7 @@ export function PublicHeader() {
Sign in
</button>
</Link>
<Link href="/login/new">
<Link href="/login">
<button
type="button"
className={cn(

View file

@ -13,6 +13,7 @@ import {
X,
} from "lucide-react"
import { Logo } from "@ui/assets/Logo"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { analytics } from "@/lib/analytics"
export type HighlightFormat = "paragraph" | "bullets" | "quote" | "one_liner"
@ -31,6 +32,7 @@ interface HighlightsCardProps {
onChat: (highlightContent: string, userReply: string) => void
onShowRelated: (query: string) => void
isLoading?: boolean
onAddMemory?: () => void
}
function renderContent(content: string, format: HighlightFormat) {
@ -68,6 +70,7 @@ export function HighlightsCard({
onChat,
onShowRelated,
isLoading = false,
onAddMemory,
}: HighlightsCardProps) {
const [activeIndex, setActiveIndex] = useState(0)
const [isReplyOpen, setIsReplyOpen] = useState(false)
@ -185,10 +188,19 @@ export function HighlightsCard({
</div>
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="flex-1 flex flex-col items-center justify-center gap-3">
<p className="text-[11px] text-fg-muted text-center">
Add some documents to see highlights here
</p>
{onAddMemory ? (
<button
type="button"
onClick={onAddMemory}
className="text-[11px] font-medium text-brand-accent hover:text-brand-accent/80 transition-colors cursor-pointer"
>
Add memory →
</button>
) : null}
</div>
</div>
)
@ -213,7 +225,37 @@ export function HighlightsCard({
</span>
</div>
</div>
<Info className="size-[14px] text-fg-subtle" />
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="About the Daily Brief"
className="shrink-0 rounded-full p-0.5 text-fg-subtle transition-colors hover:text-fg-primary focus-visible:outline-none"
>
<Info className="size-[14px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
className={cn(
"w-64 rounded-xl border border-[#263348]/60 p-3 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #101822 0%, #0A0E14 100%)",
}}
>
<p className="mb-1 text-[12px] font-semibold text-fg-primary">
Daily Brief
</p>
<p className="text-[12px] leading-relaxed text-fg-subtle">
AI-generated highlights and questions drawn from your memories. It
refreshes automatically every few hours — tap the refresh icon to
update it now.
</p>
</PopoverContent>
</Popover>
</div>
<div id="highlights-body" className="flex flex-col gap-1.5">

View file

@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { analytics } from "@/lib/analytics"
import { consumePendingConnectUrl } from "@/lib/constants"
import { cn } from "@lib/utils"
export function InitialHeader({
showUserSupermemory,
@ -28,10 +29,10 @@ export function InitialHeader({
}
return (
<div className="flex p-6 justify-between items-center">
<div className="flex items-center z-10!">
<Logo className="h-7" />
{showUserSupermemory && (
<div className="flex items-center justify-between p-4 sm:p-6">
<div className="flex items-center z-10! min-w-0">
<Logo className="h-6 sm:h-7 shrink-0" />
{showUserSupermemory ? (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
{userName}
@ -40,9 +41,13 @@ export function InitialHeader({
supermemory
</p>
</div>
) : (
<p className="ml-2 truncate text-xl leading-none font-semibold text-white/90">
supermemory
</p>
)}
</div>
<div className="flex items-center gap-3 z-10!">
<div className="flex items-center gap-2 sm:gap-3 z-10! shrink-0">
{showSkipOnboarding && !isLoading && (
<button
type="button"
@ -54,10 +59,21 @@ export function InitialHeader({
)}
<Button
variant="newDefault"
className="rounded-2xl text-base gap-1 h-11!"
className={cn(
"rounded-xl sm:rounded-2xl gap-1 h-9 sm:h-11 px-3 sm:px-4 text-sm sm:text-base",
)}
size="lg"
onClick={() =>
window.open(
"https://console.supermemory.ai",
"_blank",
"noopener,noreferrer",
)
}
>
Memory API <span className="text-xs mt-[4px]">↗</span>
<span className="sm:hidden">API</span>
<span className="hidden sm:inline">Memory API</span>{" "}
<span className="text-xs mt-[4px]">↗</span>
</Button>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,399 @@
"use client"
import Image from "next/image"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
import {
ClaudeDesktopIcon,
GoogleDrive,
MCPIcon,
Notion,
} from "@ui/assets/icons"
import { Logo } from "@ui/assets/Logo"
import NovaOrb from "@/components/nova/nova-orb"
type ToolNode = {
id: string
name: string
x: number
y: number
icon?: React.ComponentType<{ className?: string }>
iconSrc?: string
}
type ContextConnection = {
from: ToolNode
to: ToolNode
}
type ContextPhase = "idle" | "capture" | "hold" | "recall"
const CENTER = { x: 50, y: 50 }
const IN_MS = 1100
const HOLD_MS = 700
const OUT_MS = 1100
const TOTAL_MS = IN_MS + HOLD_MS + OUT_MS
const TOOL_NODES: ToolNode[] = [
{ id: "chrome", name: "Chrome", x: 14, y: 20, icon: ChromeIcon },
{ id: "notion", name: "Notion", x: 84, y: 16, icon: Notion },
{ id: "drive", name: "Google Drive", x: 10, y: 52, icon: GoogleDrive },
{ id: "claude", name: "Claude", x: 90, y: 44, icon: ClaudeDesktopIcon },
{ id: "raycast", name: "Raycast", x: 76, y: 76, icon: RaycastIcon },
{ id: "mcp", name: "MCP", x: 22, y: 84, icon: MCPIcon },
{
id: "claude-code",
name: "Claude Code",
x: 20,
y: 36,
iconSrc: "/images/plugins/claude-code.svg",
},
{
id: "codex",
name: "Codex",
x: 92,
y: 28,
iconSrc: "/images/plugins/codex.png",
},
{
id: "opencode",
name: "OpenCode",
x: 58,
y: 10,
iconSrc: "/images/plugins/opencode.svg",
},
{
id: "hermes",
name: "Hermes",
x: 36,
y: 90,
iconSrc: "/images/plugins/hermes.svg",
},
{
id: "openclaw",
name: "OpenClaw",
x: 68,
y: 68,
iconSrc: "/images/plugins/openclaw.svg",
},
]
const CONTEXT_FLOWS: [string, string][] = [
["chrome", "claude"],
["notion", "raycast"],
["drive", "claude-code"],
["opencode", "codex"],
["claude-code", "mcp"],
["hermes", "openclaw"],
["claude", "mcp"],
["notion", "claude"],
]
function nodeById(id: string) {
return TOOL_NODES.find((node) => node.id === id)
}
function pickContextFlow(): ContextConnection | null {
const flow = CONTEXT_FLOWS[Math.floor(Math.random() * CONTEXT_FLOWS.length)]
if (!flow) return null
const [fromId, toId] = flow
const from = nodeById(fromId)
const to = nodeById(toId)
if (!from || !to) return null
return { from, to }
}
function ToolNodeIcon({
node,
role,
}: {
node: ToolNode
role?: "source" | "destination"
}) {
const Icon = node.icon
return (
<div
className="login-tool-node-wrap"
style={{ left: `${node.x}%`, top: `${node.y}%` }}
>
<div
className={cn(
"login-tool-node",
role === "source" && "login-tool-node-source",
role === "destination" && "login-tool-node-destination",
)}
title={node.name}
>
{Icon ? (
<Icon className="login-tool-node-icon shrink-0" />
) : node.iconSrc ? (
<Image
src={node.iconSrc}
alt=""
width={22}
height={22}
className="login-tool-node-icon shrink-0"
/>
) : null}
</div>
<span
className={cn(
"login-tool-node-label",
dmSansClassName(),
role && "login-tool-node-label-visible",
)}
aria-hidden={!role}
>
{node.name}
</span>
</div>
)
}
function MemoryChip() {
return (
<div className="login-context-chip">
<div className="login-context-chip-lines" aria-hidden>
<span />
<span />
<span />
</div>
</div>
)
}
function AnimatedContextFlow({
connection,
phase,
}: {
connection: ContextConnection
phase: ContextPhase
}) {
const { from, to } = connection
const dIn = `M ${from.x} ${from.y} L ${CENTER.x} ${CENTER.y}`
const dOut = `M ${CENTER.x} ${CENTER.y} L ${to.x} ${to.y}`
const chipLeft =
phase === "recall"
? [`${CENTER.x}%`, `${to.x}%`]
: phase === "hold"
? `${CENTER.x}%`
: [`${from.x}%`, `${CENTER.x}%`]
const chipTop =
phase === "recall"
? [`${CENTER.y}%`, `${to.y}%`]
: phase === "hold"
? `${CENTER.y}%`
: [`${from.y}%`, `${CENTER.y}%`]
return (
<div className="pointer-events-none absolute inset-0 z-[1]">
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d={dIn}
fill="none"
stroke="rgb(75 160 250 / 0.12)"
strokeWidth="2.5"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
<path
d={dOut}
fill="none"
stroke="rgb(75 160 250 / 0.12)"
strokeWidth="2.5"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{(phase === "capture" || phase === "hold") && (
<motion.path
key={`in-${from.id}`}
d={dIn}
fill="none"
stroke="rgb(140 205 255 / 0.75)"
strokeWidth="1.75"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: IN_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
/>
)}
{phase === "recall" && (
<motion.path
key={`out-${to.id}`}
d={dOut}
fill="none"
stroke="rgb(160 220 255 / 0.9)"
strokeWidth="1.75"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: OUT_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
/>
)}
</svg>
{phase !== "idle" && (
<motion.div
className="absolute z-[2] -translate-x-1/2 -translate-y-1/2"
initial={false}
animate={{
left: chipLeft,
top: chipTop,
opacity: phase === "hold" ? 1 : [1, 1],
scale: phase === "hold" ? 1.05 : 1,
}}
transition={{
left: {
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
ease: [0.35, 0, 0.15, 1],
},
top: {
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
ease: [0.35, 0, 0.15, 1],
},
scale: { duration: 0.3 },
}}
>
<MemoryChip />
</motion.div>
)}
</div>
)
}
function ToolsContextNetwork() {
const [connection, setConnection] = useState<ContextConnection | null>(null)
const [pulseId, setPulseId] = useState(0)
const [phase, setPhase] = useState<ContextPhase>("idle")
useEffect(() => {
let cancelled = false
let pulseTimeout: ReturnType<typeof setTimeout>
const runPulse = () => {
if (cancelled) return
const next = pickContextFlow()
if (!next) return
setConnection(next)
setPulseId((id) => id + 1)
pulseTimeout = setTimeout(runPulse, TOTAL_MS + 900 + Math.random() * 500)
}
runPulse()
return () => {
cancelled = true
clearTimeout(pulseTimeout)
}
}, [])
useEffect(() => {
if (!connection) return
setPhase("capture")
const holdTimer = setTimeout(() => setPhase("hold"), IN_MS)
const recallTimer = setTimeout(() => setPhase("recall"), IN_MS + HOLD_MS)
const idleTimer = setTimeout(() => setPhase("idle"), TOTAL_MS)
return () => {
clearTimeout(holdTimer)
clearTimeout(recallTimer)
clearTimeout(idleTimer)
}
}, [connection])
const sourceRole = (nodeId: string) =>
connection &&
connection.from.id === nodeId &&
(phase === "capture" || phase === "hold")
? ("source" as const)
: undefined
const destRole = (nodeId: string) =>
connection && connection.to.id === nodeId && phase === "recall"
? ("destination" as const)
: undefined
return (
<div
className="login-tools-network relative h-full min-h-[240px] w-full lg:min-h-0"
aria-hidden
>
{connection && phase !== "idle" ? (
<AnimatedContextFlow
key={pulseId}
connection={connection}
phase={phase}
/>
) : null}
{TOOL_NODES.map((node) => (
<ToolNodeIcon
key={node.id}
node={node}
role={sourceRole(node.id) ?? destRole(node.id)}
/>
))}
<div className="absolute left-1/2 top-1/2 z-[3] -translate-x-1/2 -translate-y-1/2">
<motion.div
className="relative flex size-24 items-center justify-center sm:size-28 lg:size-36"
animate={{ scale: phase === "hold" ? [1, 1.08, 1] : 1 }}
transition={{ duration: 0.55, ease: "easeOut" }}
>
<NovaOrb size={112} className="blur-[2px]!" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo className="size-6 opacity-80 sm:size-7 lg:size-8" />
</div>
</motion.div>
</div>
</div>
)
}
function LoginPanelBackground() {
return (
<>
<div
className="pointer-events-none absolute inset-0 bg-[#030912]"
aria-hidden
/>
<div className="login-panel-orb pointer-events-none" aria-hidden />
<div className="login-panel-orb-image" aria-hidden />
<div className="login-panel-orb-image-alt" aria-hidden />
</>
)
}
export function LoginToolsPanel() {
return (
<aside className="relative hidden min-h-0 flex-col overflow-hidden border-white/[0.06] lg:col-start-1 lg:row-start-1 lg:flex lg:h-full lg:border-r">
<LoginPanelBackground />
<div className="login-tools-panel-inner relative z-10 flex flex-col justify-center px-4 py-8 sm:px-8 lg:px-10">
<ToolsContextNetwork />
</div>
<p
className={cn(
"relative z-10 shrink-0 px-4 pb-4 text-center text-[11px] leading-snug text-white/40 sm:px-8 sm:text-xs lg:absolute lg:bottom-6 lg:left-1/2 lg:max-w-none lg:-translate-x-1/2 lg:px-0 lg:pb-0 lg:text-sm lg:whitespace-nowrap lg:text-white/45",
dmSansClassName(),
)}
>
One memory layer — context from any tool, everywhere you need it.
</p>
</aside>
)
}

View file

@ -69,52 +69,76 @@ export function ClaudeDesktopManualTimeline({
}: ClaudeDesktopManualTimelineProps) {
const isDetail = variant === "detail"
const textMuted = isDetail ? "text-[#B0B0B0]" : "text-muted-foreground"
const border = isDetail ? "border-[#3D434D]" : "border-border"
const spine = isDetail ? "bg-[#7C8C9E]" : "bg-muted-foreground/40"
const textMuted = isDetail ? "text-[#A1A1AA]" : "text-muted-foreground"
const border = isDetail ? "border-white/[0.07]" : "border-border"
const spine = isDetail ? "bg-white/[0.14]" : "bg-muted-foreground/40"
const circleBorder = isDetail
? "border-[#9CA8B8]"
? "border-white/[0.12]"
: "border-muted-foreground/50"
const circleBg = isDetail ? "bg-[#080B0F]" : "bg-background"
const codeBg = isDetail ? "bg-[#050608]" : "bg-muted"
const imgBorder = isDetail ? "border-[#3D434D]" : "border-border"
const screenshotBed = isDetail ? "bg-[#2A2D35]" : "bg-muted"
const circleBg = isDetail ? "bg-[#0D121A]" : "bg-background"
const codeBg = isDetail ? "bg-[#0B0E13]" : "bg-muted"
const imgBorder = isDetail ? "border-white/[0.07]" : "border-border"
const screenshotBed = isDetail ? "bg-[#0B0E13]" : "bg-muted"
return (
<div
className={cn(
"space-y-0",
isDetail
? "rounded-2xl border border-[#3D434D] bg-[#080B0F] p-5 sm:p-6"
? ""
: "rounded-xl border border-border bg-muted/50 p-4 sm:p-5",
)}
>
<div className="relative">
<div
className={cn(
"absolute top-4.5 bottom-4.5 left-4.5 w-0.5 rounded-full",
isDetail
? "absolute top-3 bottom-3 left-[10.5px] w-px"
: "absolute top-4.5 bottom-4.5 left-4.5 w-0.5 rounded-full",
spine,
)}
aria-hidden
/>
<ol className="relative m-0 list-none space-y-10">
<ol className="relative m-0 list-none space-y-7">
{STEPS.map((item) => (
<li className="relative flex gap-4 sm:gap-5" key={item.step}>
<div className="relative z-1 flex w-9 shrink-0 justify-center pt-0.5">
<div
className={cn(
"flex size-9 items-center justify-center rounded-full border-2 text-sm font-semibold tabular-nums shadow-sm",
circleBorder,
circleBg,
isDetail ? "text-[#FAFAFA]" : "text-foreground",
)}
>
{item.step}
</div>
<div
className={cn(
"relative z-1 flex shrink-0 justify-center pt-0.5",
isDetail ? "w-[22px]" : "w-9",
)}
>
{isDetail ? (
<div
className={cn(
"flex size-[22px] items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold tabular-nums text-[#4BA0FA]",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
)}
>
{item.step}
</div>
) : (
<div
className={cn(
"flex size-9 items-center justify-center rounded-full border-2 text-sm font-semibold tabular-nums shadow-sm",
circleBorder,
circleBg,
"text-foreground",
)}
>
{item.step}
</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-3">
<p className={cn("text-sm leading-relaxed", textMuted)}>
<p
className={cn(
isDetail
? "text-[13px] leading-relaxed text-[#A1A1AA]"
: cn("text-sm leading-relaxed", textMuted),
)}
>
{item.body}
</p>
@ -123,14 +147,14 @@ export function ClaudeDesktopManualTimeline({
<div className="relative">
<pre
className={cn(
"max-w-full overflow-x-auto rounded-lg border p-4 pr-12 text-xs",
"max-w-full overflow-x-auto rounded-[10px] border p-4 pr-12 text-xs",
border,
codeBg,
)}
>
<code
className={cn(
"block font-mono whitespace-pre-wrap break-all text-emerald-400",
"block font-mono whitespace-pre-wrap break-all text-[#E4E4E7]",
dmMonoClassName(),
)}
>
@ -139,13 +163,13 @@ export function ClaudeDesktopManualTimeline({
</pre>
<button
type="button"
className="absolute top-2 right-2 flex size-8 cursor-pointer items-center justify-center rounded-md border border-[#3D434D] bg-[#121820] hover:bg-[#1a2230]"
className="absolute top-2 right-2 flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0D121A] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] transition-opacity hover:opacity-80"
onClick={onCopySnippet}
>
{snippetCopied ? (
<span className="text-xs text-emerald-500">✓</span>
<CheckIcon className="size-3.5 text-[#4BA0FA]" />
) : (
<CopyIcon className="size-3.5 text-[#8B8B8B] hover:text-white" />
<CopyIcon className="size-3.5 text-[#737373]" />
)}
</button>
</div>

View file

@ -30,6 +30,67 @@ import {
getManualInstallEntry,
} from "@/lib/mcp-manual-instructions"
import { ClaudeDesktopManualTimeline } from "@/components/mcp-modal/claude-desktop-manual-timeline"
import { INSET } from "@/components/integrations/install-steps"
function McpCodeBlock({
code,
multiline,
onCopy,
}: {
code: string
multiline?: boolean
onCopy?: () => void
}) {
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(code)
setCopied(true)
toast.success("Copied to clipboard!")
setTimeout(() => setCopied(false), 2000)
onCopy?.()
}
return (
<div
className={cn(
"group flex min-w-0 gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5",
multiline ? "items-start" : "items-center",
)}
>
<div className="relative min-w-0 flex-1">
<pre
className={cn(
"scrollbar-none min-w-0 overflow-x-auto font-mono text-[12px] leading-[1.6] text-[#E4E4E7]",
multiline ? "whitespace-pre-wrap break-all" : "whitespace-pre pr-4",
dmMonoClassName(),
)}
>
{code}
</pre>
{!multiline && (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[#0B0E13] to-transparent"
/>
)}
</div>
<button
type="button"
aria-label="Copy"
onClick={copy}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
INSET,
)}
>
{copied ? (
<Check className="size-3.5 text-[#4BA0FA]" />
) : (
<CopyIcon className="size-3.5 text-[#737373]" />
)}
</button>
</div>
)
}
const clients = {
chatgpt: "ChatGPT",
@ -101,7 +162,8 @@ function ClientToolIcon({
return (
<div
className={cn(
"size-10 shrink-0 rounded-[10px] bg-[#0D121A] border border-[#242A33] flex items-center justify-center overflow-hidden",
"size-10 shrink-0 rounded-[10px] bg-[#080B0F] flex items-center justify-center overflow-hidden",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
)}
>
{failed ? (
@ -137,7 +199,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
"mcpTab",
parseAsStringLiteral(["oneClick", "manual"] as const).withDefault("manual"),
)
const [isCommandCopied, setIsCommandCopied] = useState(false)
const [isManualCopied, setIsManualCopied] = useState(false)
const [activeStep, setActiveStep] = useQueryState(
"mcpStep",
@ -170,15 +231,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
return command
}
const copyToClipboard = () => {
const command = generateInstallCommand()
navigator.clipboard.writeText(command)
analytics.mcpInstallCmdCopied()
setIsCommandCopied(true)
setActiveStep(3)
setTimeout(() => setIsCommandCopied(false), 2000)
}
const copyManualSnippet = (text: string) => {
navigator.clipboard.writeText(text)
analytics.mcpInstallCmdCopied()
@ -203,17 +255,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
? "manual"
: setupTab
const gradientStep3 =
activeStep === 3
? {
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: undefined
return (
<div
className={cn(
@ -228,11 +269,11 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
key={category.label}
className={cn(
"flex flex-col gap-4 py-5 sm:flex-row sm:items-start sm:gap-8",
rowIndex > 0 && "border-t border-[#242A33]",
rowIndex > 0 && "border-t border-white/[0.06]",
)}
>
<div className="w-full shrink-0 sm:w-30">
<p className="text-[13px] font-semibold tracking-[-0.01em] text-[#8B8B8B]">
<p className="text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]">
{category.label}
</p>
</div>
@ -249,9 +290,9 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
setActiveStep(2)
}}
className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-xl border border-[#242A33] bg-[#080B0F] p-3.5 text-left transition-colors",
"hover:border-[#3273FC4D] hover:bg-[#08142D]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3273FC]/40",
"group flex w-full min-w-0 items-center gap-3 rounded-[12px] bg-[#0D121A] p-3.5 text-left transition-colors",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] hover:bg-[#10151D]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/40",
)}
>
<ClientToolIcon clientKey={item.key} title={item.title} />
@ -259,10 +300,12 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
<p className="text-sm font-medium text-[#FAFAFA]">
{item.title}
</p>
<p className="text-xs text-[#737373]">{item.subtitle}</p>
<p className="text-[12px] text-[#737373]">
{item.subtitle}
</p>
</div>
<ChevronRight
className="size-4 shrink-0 text-[#525866] transition-transform group-hover:translate-x-0.5"
className="size-4 shrink-0 text-[#525D6E] transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</button>
@ -270,51 +313,63 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
</div>
</div>
))}
<p className="pt-1 text-sm text-[#8B8B8B]">
<p className="pt-1 text-[13px] text-[#A1A1AA]">
You can connect several clients; setup is different for each one.
</p>
</div>
) : (
<div className={cn("space-y-4", dmSansClassName())}>
<button
type="button"
onClick={() => {
setSelectedClient(null)
setActiveStep(1)
}}
className="text-sm font-medium text-[#8B8B8B] transition-colors hover:text-[#FAFAFA]"
{!isEmbedded && (
<>
<button
type="button"
onClick={() => {
setSelectedClient(null)
setActiveStep(1)
}}
className="text-sm font-medium text-[#A1A1AA] transition-colors hover:text-[#FAFAFA]"
>
← All clients
</button>
<div className="flex flex-wrap items-center gap-3">
<ClientToolIcon
clientKey={selectedKey}
title={clients[selectedKey]}
/>
<div>
<p className="text-lg font-medium text-[#FAFAFA]">
{clients[selectedKey]}
</p>
<p className="text-sm text-[#737373]">
{detailSetup ? setupInstructionsSubtitle(detailSetup) : ""}
</p>
</div>
</div>
</>
)}
<div
className={cn(
"space-y-4",
!isEmbedded && "border-t border-white/[0.06] pt-8",
)}
>
← All clients
</button>
<div className="flex flex-wrap items-center gap-3">
<ClientToolIcon
clientKey={selectedKey}
title={clients[selectedKey]}
/>
<div>
<p className="text-lg font-medium text-[#FAFAFA]">
{clients[selectedKey]}
</p>
<p className="text-sm text-[#737373]">
{detailSetup ? setupInstructionsSubtitle(detailSetup) : ""}
</p>
</div>
</div>
<div className="space-y-4 border-t border-[#242A33] pt-8">
{detailSetup && mcpClientSetupShowsTabs(detailSetup) ? (
<div
className="flex w-full max-w-md flex-row gap-1 rounded-full border border-[#3D434D] bg-[#0D121A] p-1"
className={cn(
"flex w-full flex-row gap-0.5 rounded-full bg-[#0D121A] p-0.5",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
)}
role="tablist"
aria-label="Setup method"
>
<button
className={cn(
"min-h-9 flex-1 rounded-full px-3 py-2 text-center text-xs font-medium transition-all",
"min-h-8 flex-1 rounded-full px-3 text-center text-[12px] font-medium transition-colors",
effectiveSetupTab === "manual"
? "border border-[#3D434D] bg-[#080B0F] text-white"
: "text-[#8B8B8B] hover:text-white",
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
onClick={() => setSetupTab("manual")}
role="tab"
@ -325,10 +380,10 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
</button>
<button
className={cn(
"min-h-9 flex-1 rounded-full px-3 py-2 text-center text-xs font-medium transition-all",
"min-h-8 flex-1 rounded-full px-3 text-center text-[12px] font-medium transition-colors",
effectiveSetupTab === "oneClick"
? "border border-[#3D434D] bg-[#080B0F] text-white"
: "text-[#8B8B8B] hover:text-white",
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
onClick={() => setSetupTab("oneClick")}
role="tab"
@ -345,47 +400,31 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
<>
{selectedClient === "mcp-url" && (
<div className="space-y-2">
<p className="text-sm text-[#8B8B8B]">
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
Paste this URL into any MCP client that supports remote
servers over HTTPS.
</p>
<div className="relative">
<input
className={cn(
"w-full rounded-lg border border-[#3D434D] bg-black p-2 pr-10 font-mono text-xs text-emerald-400",
dmMonoClassName(),
)}
readOnly
value="https://mcp.supermemory.ai/mcp"
/>
<button
type="button"
className="absolute top-1 right-1 cursor-pointer p-1"
onClick={() => {
navigator.clipboard.writeText(
"https://mcp.supermemory.ai/mcp",
)
analytics.mcpInstallCmdCopied()
toast.success("Copied to clipboard!")
setActiveStep(3)
}}
>
<CopyIcon className="size-4 text-[#8B8B8B] hover:text-white" />
</button>
</div>
<McpCodeBlock
code="https://mcp.supermemory.ai/mcp"
onCopy={() => {
analytics.mcpInstallCmdCopied()
toast.success("Copied to clipboard!")
setActiveStep(3)
}}
/>
</div>
)}
{selectedClient === "cursor" && (
<div className="space-y-4">
<p className="text-sm text-[#8B8B8B]">
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
Open the link below to add supermemory in Cursor, or use
Manual instructions to edit{" "}
<code className="text-xs text-emerald-400/90">
<code className="font-mono text-[12px] text-[#8BC6FF]">
~/.cursor/mcp.json
</code>
.
</p>
<div className="flex flex-col items-center gap-4 rounded-xl border border-[#1e3a2f] bg-[#0a1510] p-6">
<div className="flex flex-col items-center gap-4 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] p-6">
<a
href={getCursorDeeplink()}
onClick={() => {
@ -411,50 +450,18 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
{selectedClient !== "mcp-url" &&
selectedClient !== "cursor" && (
<div className="space-y-2">
<p className="text-sm text-[#8B8B8B]">
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
Run this command in your terminal. It installs the MCP
for {clients[selectedKey]} and starts OAuth when
needed.
</p>
<div className="relative">
<input
className={cn(
"w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-xl py-4 pr-28 pl-3 text-xs text-white",
dmMonoClassName(),
)}
style={{
border: "1px solid rgba(61, 67, 77, 0.10)",
background: "#0D121A",
}}
readOnly
value={generateInstallCommand()}
/>
<button
type="button"
className={cn(
"absolute top-[5px] right-1 flex cursor-pointer items-center gap-2 rounded-[10px] px-3 py-2",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(180deg, #267BF1 40.23%, #15468B 100%), linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
border: "1px solid #000",
}}
onClick={copyToClipboard}
>
{isCommandCopied ? (
<>
<Check className="size-4 text-white" />
<span className="text-white">Copied</span>
</>
) : (
<>
<CopyIcon className="size-5 text-white stroke-[2px]" />
<span className="text-white">Copy</span>
</>
)}
</button>
</div>
<McpCodeBlock
code={generateInstallCommand()}
onCopy={() => {
analytics.mcpInstallCmdCopied()
setActiveStep(3)
}}
/>
</div>
)}
</>
@ -464,7 +471,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
if (manual.kind === "chatgpt") {
return (
<div className="space-y-3">
<ol className="list-decimal space-y-2 pl-5 text-sm text-[#8B8B8B]">
<ol className="list-decimal space-y-2 pl-5 text-[13px] leading-relaxed text-[#A1A1AA]">
<li>Open ChatGPT in your browser.</li>
<li>
Go to Settings → Apps → Advanced settings → enable
@ -478,27 +485,15 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
Paste the URL below and complete OAuth in ChatGPT.
</li>
</ol>
<div className="relative">
<input
className={cn(
"w-full rounded-lg border border-[#3D434D] bg-black p-2 pr-10 font-mono text-xs text-emerald-400",
dmMonoClassName(),
)}
readOnly
value={CHATGPT_REMOTE_MCP_URL}
/>
<button
type="button"
className="absolute top-1 right-1 cursor-pointer p-1"
onClick={() =>
copyManualSnippet(CHATGPT_REMOTE_MCP_URL)
}
>
<CopyIcon className="size-4 text-[#8B8B8B] hover:text-white" />
</button>
</div>
<McpCodeBlock
code={CHATGPT_REMOTE_MCP_URL}
onCopy={() => {
analytics.mcpInstallCmdCopied()
setActiveStep(3)
}}
/>
<a
className="inline-block text-sm text-[#4BA0FA] underline hover:text-[#6BB8FF]"
className="inline-block text-[13px] text-[#4BA0FA] underline hover:text-[#8BC6FF]"
href="https://developers.openai.com/api/docs/guides/developer-mode/"
rel="noopener noreferrer"
target="_blank"
@ -523,36 +518,21 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
const snippet = buildMcpUrlRemoteJson("your-api-key-here")
return (
<div className="space-y-3">
<p className="text-sm text-[#8B8B8B]">
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
Add this to your client&apos;s MCP config. Replace the
placeholder with an API key from supermemory settings
(Integrations).
</p>
<div className="relative">
<pre className="max-w-full overflow-x-auto rounded-lg border border-[#3D434D] bg-black p-4 pr-12 text-xs">
<code
className={cn(
"block font-mono whitespace-pre-wrap break-all text-emerald-400",
dmMonoClassName(),
)}
>
{snippet}
</code>
</pre>
<button
type="button"
className="absolute top-2 right-2 flex size-8 cursor-pointer items-center justify-center rounded bg-[#0D121A] hover:bg-[#1a1a1a]"
onClick={() => copyManualSnippet(snippet)}
>
{isManualCopied ? (
<span className="text-emerald-500">✓</span>
) : (
<CopyIcon className="size-3.5" />
)}
</button>
</div>
<McpCodeBlock
code={snippet}
multiline
onCopy={() => {
analytics.mcpInstallCmdCopied()
setActiveStep(3)
}}
/>
{detailSetup?.oneClick ? (
<p className="text-xs text-[#737373]">
<p className="text-[12px] text-[#737373]">
Use Bearer auth in headers, or switch to One click
setup and paste the HTTPS URL if your client
supports OAuth only.
@ -563,35 +543,22 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
}
return (
<div className="space-y-3">
<p className="text-sm text-[#8B8B8B]">{manual.paths}</p>
<p className="text-sm text-[#8B8B8B]">
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
{manual.paths}
</p>
<p className="text-[13px] leading-relaxed text-[#A1A1AA]">
Merge the snippet with your existing file (create it if
needed). Restart the client and complete OAuth when
prompted.
</p>
<div className="relative">
<pre className="max-w-full overflow-x-auto rounded-lg border border-[#3D434D] bg-black p-4 pr-12 text-xs">
<code
className={cn(
"block font-mono whitespace-pre-wrap break-all text-emerald-400",
dmMonoClassName(),
)}
>
{manual.snippet}
</code>
</pre>
<button
type="button"
className="absolute top-2 right-2 flex size-8 cursor-pointer items-center justify-center rounded bg-[#0D121A] hover:bg-[#1a1a1a]"
onClick={() => copyManualSnippet(manual.snippet)}
>
{isManualCopied ? (
<span className="text-emerald-500">✓</span>
) : (
<CopyIcon className="size-3.5" />
)}
</button>
</div>
<McpCodeBlock
code={manual.snippet}
multiline
onCopy={() => {
analytics.mcpInstallCmdCopied()
setActiveStep(3)
}}
/>
</div>
)
})()
@ -599,47 +566,47 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
</div>
</div>
<div className="border-t border-[#242A33] pt-8">
<h3
className="mb-4 text-lg font-medium text-white"
style={gradientStep3}
>
{detailSetup != null &&
mcpClientShowsManual(detailSetup, effectiveSetupTab)
? selectedClient === "chatgpt"
? "Finish in ChatGPT"
: selectedClient === "claude"
? "Finish in Claude Desktop"
: "Save and restart"
: selectedClient === "cursor"
? "Finish in Cursor"
: "Run in terminal"}
</h3>
{activeStep === 3 && (
<p
{!isEmbedded && (
<div className="border-t border-white/[0.06] pt-6">
<h3
className={cn(
"flex items-center gap-2 rounded-xl p-4 pl-3 font-mono text-xs text-white",
dmMonoClassName(),
dmSansClassName(),
"mb-3 text-[15px] font-semibold tracking-[-0.01em] text-[#FAFAFA]",
)}
style={{
border: "1px solid rgba(61, 67, 77, 0.10)",
background: "#0D121A",
}}
>
<SyncLogoIcon className="size-4" />
{detailSetup != null &&
mcpClientShowsManual(detailSetup, effectiveSetupTab)
? selectedClient === "chatgpt"
? "Complete app setup in ChatGPT, then enable it for your chat."
? "Finish in ChatGPT"
: selectedClient === "claude"
? "After Connectors, complete any prompts so supermemory is enabled for chat."
: "Save your config, restart the client, and sign in if prompted."
? "Finish in Claude Desktop"
: "Save and restart"
: selectedClient === "cursor"
? "Complete the install in Cursor and sign in with OAuth if asked."
: "Waiting for installation"}
</p>
)}
</div>
? "Finish in Cursor"
: "Run in terminal"}
</h3>
{activeStep === 3 && (
<p
className={cn(
dmSansClassName(),
"flex items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] p-3 text-[12px] text-[#A1A1AA]",
)}
>
<SyncLogoIcon className="size-4 shrink-0 text-[#4BA0FA]" />
{detailSetup != null &&
mcpClientShowsManual(detailSetup, effectiveSetupTab)
? selectedClient === "chatgpt"
? "Complete app setup in ChatGPT, then enable it for your chat."
: selectedClient === "claude"
? "After Connectors, complete any prompts so supermemory is enabled for chat."
: "Save your config, restart the client, and sign in if prompted."
: selectedClient === "cursor"
? "Complete the install in Cursor and sign in with OAuth if asked."
: "Waiting for installation"}
</p>
)}
</div>
)}
</div>
)}
</div>

View file

@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
import { useQueryState } from "nuqs"
import { AnimatePresence } from "motion/react"
import type { z } from "zod"
import { Masonry, useInfiniteLoader } from "masonic"
import { dmSansClassName } from "@/lib/fonts"
@ -54,11 +55,21 @@ import {
CheckIcon,
LayoutGrid,
Loader,
MoreHorizontal,
Trash2Icon,
UserRound,
XIcon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { useProcessingDocuments } from "@/hooks/use-processing-documents"
import { TimelineView } from "./timeline-view"
import { SpaceProfilePanel } from "@/components/space-profile-panel"
import { SpaceProfileModal } from "@/components/space-profile-modal"
// Document category type
type DocumentCategory =
@ -249,6 +260,7 @@ export function MemoriesGrid({
emptyStateProps,
}: MemoriesGridProps) {
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
const [localViewMode, setLocalViewMode] = useState<"grid" | "timeline">(
() => {
if (typeof window === "undefined") return "grid"
@ -259,7 +271,7 @@ export function MemoriesGrid({
},
)
const { user, isSessionPending } = useAuth()
const { effectiveContainerTags } = useProject()
const { effectiveContainerTags, selectedProject } = useProject()
const processingStatusMap = useProcessingDocuments()
const isMobile = useIsMobile()
const [selectedCategories, setSelectedCategories] = useQueryState(
@ -347,6 +359,14 @@ export function MemoriesGrid({
localStorage.setItem("memories-view-mode", mode)
}, [])
const handleToggleProfile = useCallback(() => {
if (isMobile) {
setProfileOpen(true)
return
}
setProfileOpen((open) => !open)
}, [isMobile])
const handleCategoryToggle = useCallback(
(category: DocumentCategory) => {
setSelectedCategories((prev) => {
@ -394,11 +414,14 @@ export function MemoriesGrid({
return items
}, [documents, isMobile, hasQuickNote, isSelectionMode, selectedDocumentIds])
// Stable key for Masonry based on document IDs, not item values
// Reset Masonry when the actual rendered item set changes. Masonic caches
// positions by index, so mobile removing the quick note must remount it.
const masonryKey = useMemo(() => {
const docIds = documents.map((d) => d.id).join(",")
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}`
}, [documents, isChatOpen, hasQuickNote])
const itemIds = masonryItems.map((item) => item.id).join(",")
return `masonry-${isMobile ? "mobile" : "desktop"}-${masonryItems.length}-${itemIds}-${isChatOpen}`
}, [masonryItems, isChatOpen, isMobile])
const getMasonryItemKey = useCallback((item: MasonryItem) => item.id, [])
const isLoadingMore = isFetchingNextPage
@ -538,17 +561,17 @@ export function MemoriesGrid({
documents.every((d) => d.id && selectedDocumentIds.has(d.id))
return (
<div className="relative">
<div className="relative flex h-full min-h-0 flex-col">
{!isEmpty && !isSelectionMode && (
<div
id="filter-pills"
className="flex items-center justify-between gap-4 mb-3 pr-2"
className="mb-3 flex flex-col gap-2 pr-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div className="flex flex-wrap items-center gap-1.5">
<div className="order-2 flex w-full min-w-0 items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:order-1 sm:flex-wrap sm:overflow-visible">
<Button
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
"shrink-0 whitespace-nowrap rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.length === 0 &&
"bg-[#00173C] border-[#2261CA33]",
)}
@ -566,7 +589,7 @@ export function MemoriesGrid({
key={facet.category}
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
"shrink-0 whitespace-nowrap rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategoriesSet.has(facet.category) &&
"bg-[#00173C] border-[#2261CA33]",
)}
@ -577,7 +600,7 @@ export function MemoriesGrid({
</Button>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="order-1 flex w-full items-center justify-between gap-2 sm:order-2 sm:w-auto sm:justify-start sm:self-start">
{/* View mode toggle — segmented control */}
<div
role="tablist"
@ -618,17 +641,52 @@ export function MemoriesGrid({
Timeline
</button>
</div>
{onEnterSelectionMode && (
<button
type="button"
aria-label="Select documents"
title="Select documents"
className="size-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] hover:border-[#2261CA33] transition-colors cursor-pointer"
onClick={onEnterSelectionMode}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="More memory actions"
className={cn(
dmSansClassName(),
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 text-xs font-medium transition-colors cursor-pointer",
profileOpen
? "border-[#2261CA33] bg-[#00173C] text-white"
: "text-[#737373] hover:bg-white/5",
)}
>
<MoreHorizontal className="size-3.5" />
More
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
"min-w-[180px] rounded-xl border border-[#2E3033] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<BoxSelect className="size-4 text-[#737373]" />
</button>
)}
{onEnterSelectionMode && (
<DropdownMenuItem
onSelect={onEnterSelectionMode}
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white cursor-pointer hover:bg-[#293952]/40"
>
<BoxSelect className="size-4 text-[#737373]" />
Select memories
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={handleToggleProfile}
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white cursor-pointer hover:bg-[#293952]/40"
>
<UserRound className="size-4 text-[#737373]" />
Space profile
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
@ -638,27 +696,29 @@ export function MemoriesGrid({
id="selection-toolbar"
className={cn(
dmSansClassName(),
"flex items-center justify-between gap-3 mb-3 mr-2 px-3 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40",
"flex items-center justify-between gap-1.5 mb-3 mr-2 px-2.5 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40 sm:gap-3 sm:px-3",
)}
>
<div className="flex items-center gap-2 min-w-0">
<div className="flex min-w-0 shrink items-center gap-1.5 sm:gap-2">
<span className="flex items-center gap-1.5 text-xs text-[#FAFAFA] font-medium shrink-0">
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#369BFD] text-[#0B0F14] text-[11px] font-semibold">
{selectedDocumentIds.size}
</span>
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
<span className="hidden sm:inline">
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
</span>
</span>
{selectedDocumentIds.size === 0 && (
<span className="text-xs text-[#737373] truncate">
<span className="hidden truncate text-xs text-[#737373] sm:inline">
Tap documents to select
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<div className="flex min-w-0 shrink-0 items-center gap-0.5 sm:gap-1">
<button
type="button"
className={cn(
"text-xs px-2.5 h-7 rounded-full transition-colors cursor-pointer",
"h-7 rounded-full px-2 text-xs transition-colors cursor-pointer sm:px-2.5",
allVisibleSelected
? "text-[#737373] hover:text-white"
: "text-[#FAFAFA] hover:bg-white/5",
@ -672,7 +732,7 @@ export function MemoriesGrid({
<button
type="button"
className={cn(
"flex items-center gap-1 text-xs px-3 h-7 rounded-full transition-colors cursor-pointer",
"flex h-7 items-center gap-1 rounded-full px-2 text-xs transition-colors cursor-pointer sm:px-3",
selectedDocumentIds.size === 0 || isBulkDeleting
? "text-[#737373]/60 cursor-not-allowed"
: "text-red-400 hover:text-red-300 hover:bg-red-500/10",
@ -692,7 +752,7 @@ export function MemoriesGrid({
<button
type="button"
aria-label="Exit selection mode"
className="flex items-center gap-1 text-xs px-3 h-7 rounded-full text-[#737373] hover:text-white hover:bg-white/5 transition-colors cursor-pointer"
className="flex h-7 items-center gap-1 rounded-full px-2 text-xs text-[#737373] transition-colors hover:text-white hover:bg-white/5 cursor-pointer sm:px-3"
onClick={onClearSelection}
>
<XIcon className="size-3" />
@ -744,60 +804,82 @@ export function MemoriesGrid({
</AlertDialogContent>
</AlertDialog>
{error ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
Error loading documents: {error.message}
</div>
</div>
) : isPending ? (
<MemoriesGridLoading />
) : showNovaEmptyState ? (
<NovaEmptyState
onAddMemory={emptyStateProps.onAddMemory}
onOpenIntegrations={emptyStateProps.onOpenIntegrations}
isAllSpaces={emptyStateProps.isAllSpaces}
spaceName={emptyStateProps.spaceName}
onSwitchToAllSpaces={emptyStateProps.onSwitchToAllSpaces}
/>
) : isEmpty ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
No memories found
</div>
</div>
) : (
<div className="h-full overflow-auto scrollbar-thin">
{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}
/>
)}
{isLoadingMore && localViewMode === "grid" && (
<div className="py-10 flex items-center justify-center">
<Loader className="size-10 animate-spin text-sky-400" />
<div className="min-h-0 flex-1">
{error ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
Error loading documents: {error.message}
</div>
)}
</div>
)}
</div>
) : isPending ? (
<MemoriesGridLoading />
) : showNovaEmptyState ? (
<NovaEmptyState
onAddMemory={emptyStateProps.onAddMemory}
onOpenIntegrations={emptyStateProps.onOpenIntegrations}
isAllSpaces={emptyStateProps.isAllSpaces}
spaceName={emptyStateProps.spaceName}
onSwitchToAllSpaces={emptyStateProps.onSwitchToAllSpaces}
/>
) : isEmpty ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
No memories found
</div>
</div>
) : (
<div className="flex h-full min-h-0 gap-4">
<div className="min-w-0 flex-1 overflow-auto scrollbar-thin">
{localViewMode === "timeline" ? (
<TimelineView
documents={documents}
onOpenDocument={onOpenDocument}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={loadMoreDocuments}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onToggleSelection={onToggleSelection}
/>
) : (
<Masonry
key={masonryKey}
items={masonryItems}
render={renderMasonryItem}
itemKey={getMasonryItemKey}
columnGutter={0}
rowGutter={0}
columnWidth={260}
maxColumnCount={isMobile ? 1 : undefined}
itemHeightEstimate={200}
overscanBy={3}
onRender={maybeLoadMore}
/>
)}
{isLoadingMore && localViewMode === "grid" && (
<div className="py-10 flex items-center justify-center">
<Loader className="size-10 animate-spin text-sky-400" />
</div>
)}
</div>
<AnimatePresence initial={false}>
{profileOpen && !isMobile && (
<SpaceProfilePanel
containerTag={selectedProject}
isOpen
onClose={() => setProfileOpen(false)}
/>
)}
</AnimatePresence>
</div>
)}
</div>
<SpaceProfileModal
containerTag={selectedProject}
open={profileOpen && isMobile}
onOpenChange={setProfileOpen}
/>
</div>
)
}

View file

@ -1,7 +1,7 @@
"use client"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useEffect, useMemo } from "react"
import { useMemo } from "react"
import { $fetch } from "@lib/api"
import type {
GraphApiDocument,
@ -15,7 +15,6 @@ interface UseGraphApiOptions {
containerTags?: string[]
documentIds?: string[]
enabled?: boolean
autoFetchAll?: boolean
}
interface ApiMemoryEntry {
@ -109,12 +108,7 @@ function toGraphDocument(
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
const {
containerTags,
documentIds,
enabled = true,
autoFetchAll = false,
} = options
const { containerTags, documentIds, enabled = true } = options
const filteredDocumentIds = documentIds?.filter(Boolean)
const hasDocumentIds =
filteredDocumentIds != null && filteredDocumentIds.length > 0
@ -169,19 +163,6 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
enabled,
})
useEffect(() => {
if (!autoFetchAll || !enabled) return
if (!hasNextPage || isPending || isFetchingNextPage) return
fetchNextPage()
}, [
autoFetchAll,
enabled,
hasNextPage,
isPending,
isFetchingNextPage,
fetchNextPage,
])
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) =>

View file

@ -1,11 +1,9 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { Loader2 } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
import type { GraphThemeColors } from "@supermemory/memory-graph"
import { useGraphApi } from "./hooks/use-graph-api"
import { cn } from "@lib/utils"
export interface MemoryGraphWrapperProps {
children?: React.ReactNode
@ -23,7 +21,6 @@ export interface MemoryGraphWrapperProps {
onSlideshowStop?: () => void
canvasRef?: React.RefObject<HTMLCanvasElement | null>
onOpenDocument?: (documentId: string) => void
autoFetchAll?: boolean
}
export function MemoryGraph({
@ -35,7 +32,6 @@ export function MemoryGraph({
documentIds,
maxNodes,
canvasRef,
autoFetchAll = false,
...rest
}: MemoryGraphWrapperProps) {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
@ -64,22 +60,10 @@ export function MemoryGraph({
containerTags,
documentIds,
enabled: containerSize.width > 0 && containerSize.height > 0,
autoFetchAll,
})
const loadedDocCount = documents.length
const loadedMemoryCount = useMemo(
() => documents.reduce((sum, doc) => sum + doc.memories.length, 0),
[documents],
)
const showProgress =
autoFetchAll &&
!apiIsLoading &&
totalCount > 0 &&
(hasMore || isLoadingMore || loadedDocCount < totalCount)
return (
<div ref={containerRef} className="relative size-full [&>div]:!bg-none">
<div ref={containerRef} className="size-full [&>div]:!bg-none">
<MemoryGraphBase
documents={documents}
isLoading={externalIsLoading || apiIsLoading}
@ -101,24 +85,6 @@ export function MemoryGraph({
>
{children}
</MemoryGraphBase>
{showProgress && (
<div
className={cn(
"absolute right-3 top-3 z-15 md:right-4 md:top-4",
"flex items-center gap-2 rounded-full",
"bg-background/85 px-3 py-1.5 text-xs backdrop-blur-md",
"border border-border/60 shadow-sm",
)}
aria-live="polite"
>
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
<span className="tabular-nums">
{loadedMemoryCount.toLocaleString()} memories ·{" "}
{loadedDocCount.toLocaleString()}/{totalCount.toLocaleString()}{" "}
documents
</span>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,35 @@
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { PLAN_DISPLAY_NAMES, type PlanType } from "@/hooks/use-token-usage"
const orgPlanBadgeBase = cn(
dmSans125ClassName(),
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
)
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
}
export function OrgPlanBadge({ plan }: { plan: PlanType }) {
return (
<span className={cn(orgPlanBadgeBase, ORG_PLAN_BADGE_STYLES[plan])}>
{PLAN_DISPLAY_NAMES[plan]}
</span>
)
}
export function resolveOrgPlan(
orgId: string,
isCurrent: boolean,
currentPlan: PlanType,
planByOrgId: Map<string, PlanType>,
): PlanType {
const fromSummary = planByOrgId.get(orgId)
if (fromSummary) return fromSummary
if (isCurrent) return currentPlan
return "free"
}

View file

@ -1,11 +1,14 @@
"use client"
import { useRef, useCallback } from "react"
import { useRef, useCallback, useEffect, useMemo, useState } from "react"
import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Maximize2, Plus, Loader2 } from "lucide-react"
import { Maximize2, Plus, Loader2, X } from "lucide-react"
import { useProject } from "@/stores"
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
import { TextEditor } from "./text-editor"
interface QuickNoteCardProps {
onSave: (content: string) => void
@ -13,33 +16,74 @@ interface QuickNoteCardProps {
isSaving?: boolean
}
type NoteRect = {
left: number
top: number
width: number
height: number
}
function getExpandedRect(source: NoteRect): NoteRect {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const margin = viewportWidth < 768 ? 16 : 32
const availableWidth = viewportWidth - source.left - margin
const availableHeight = viewportHeight - source.top - margin
const width = Math.min(1008, Math.max(source.width, availableWidth))
const height = Math.min(720, Math.max(source.height, availableHeight))
return {
left: source.left,
top: source.top,
width,
height,
}
}
export function QuickNoteCard({
onSave,
onMaximize,
isSaving = false,
}: QuickNoteCardProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const wasSavingRef = useRef(isSaving)
const { selectedProject } = useProject()
const { draft, setDraft } = useQuickNoteDraft(selectedProject)
const [isExpanded, setIsExpanded] = useState(false)
const [sourceRect, setSourceRect] = useState<NoteRect | null>(null)
const [targetRect, setTargetRect] = useState<NoteRect | null>(null)
const [expandedInitialContent, setExpandedInitialContent] = useState<
string | undefined
>(undefined)
const [isMounted, setIsMounted] = useState(false)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraft(e.target.value)
(content: string) => {
setDraft(content)
},
[setDraft],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault()
if (draft.trim() && !isSaving) {
onSave(draft)
}
}
},
[draft, isSaving, onSave],
)
const handleExpand = useCallback(() => {
const rect = cardRef.current?.getBoundingClientRect()
if (!rect) return
const nextSourceRect = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
setSourceRect(nextSourceRect)
setTargetRect(getExpandedRect(nextSourceRect))
setExpandedInitialContent(draft || undefined)
setIsExpanded(true)
}, [draft])
const handleClose = useCallback(() => {
setIsExpanded(false)
}, [])
const handleSaveClick = useCallback(() => {
if (draft.trim() && !isSaving) {
@ -48,112 +92,278 @@ export function QuickNoteCard({
}, [draft, isSaving, onSave])
const handleMaximizeClick = useCallback(() => {
setIsExpanded(false)
onMaximize(draft)
}, [draft, onMaximize])
const canSave = draft.trim().length > 0 && !isSaving
const previewText = useMemo(() => {
const trimmed = draft.trim()
if (!trimmed) return null
return trimmed.replace(/\s+/g, " ")
}, [draft])
useEffect(() => {
setIsMounted(true)
}, [])
useEffect(() => {
if (!isExpanded || !sourceRect) return
const handleResize = () => setTargetRect(getExpandedRect(sourceRect))
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [isExpanded, sourceRect])
useEffect(() => {
if (!isExpanded) return
const previousOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
handleClose()
}
}
document.addEventListener("keydown", handleGlobalKeyDown)
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener("keydown", handleGlobalKeyDown)
}
}, [isExpanded, handleClose])
useEffect(() => {
if (wasSavingRef.current && !isSaving && draft.trim().length === 0) {
setIsExpanded(false)
}
wasSavingRef.current = isSaving
}, [draft, isSaving])
return (
<div
className="bg-[#1B1F24] rounded-[22px] p-1"
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<>
<div
id="quick-note-inner"
className="bg-[#0B1017] rounded-[18px] p-3 relative"
ref={cardRef}
className="bg-[#1B1F24] rounded-[22px] p-1"
style={{
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<button
type="button"
onClick={handleMaximizeClick}
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Expand to full screen"
>
<Maximize2 className="size-[14px]" />
</button>
<textarea
ref={textareaRef}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Start writing..."
disabled={isSaving}
className={cn(
dmSansClassName(),
"w-full h-[120px] bg-transparent resize-none outline-none text-[12px] leading-normal text-white placeholder:text-[#737373] pr-5 disabled:opacity-50",
)}
/>
<div
id="quick-note-action-bar"
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
id="quick-note-inner"
className="bg-[#0B1017] rounded-[18px] p-3 relative"
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
}}
>
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
onClick={handleMaximizeClick}
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Open full screen note"
>
<span className="flex items-center gap-1.5">
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</span>
<Maximize2 className="size-[14px]" />
</button>
<span
className={cn(
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
)}
>
<svg
className="size-[10px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<button
type="button"
onClick={handleExpand}
disabled={isSaving}
className="w-full h-[120px] cursor-text text-left disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Expand quick note"
>
<span className="flex h-full flex-col pr-5">
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
"line-clamp-4 text-[12px] leading-normal text-[#D7DEE8]",
!previewText && "text-[#737373]",
)}
>
Enter
{previewText ?? "Start writing..."}
</span>
</span>
</button>
<div
id="quick-note-action-bar"
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="flex items-center gap-1.5">
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</span>
<span
className={cn(
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
)}
>
<svg
className="size-[10px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
)}
>
Enter
</span>
</span>
</button>
</div>
</div>
</div>
</div>
{isMounted &&
createPortal(
<AnimatePresence>
{isExpanded && sourceRect && targetRect && (
<div className="fixed inset-0 z-[100]">
<motion.button
type="button"
aria-label="Close quick note"
className="absolute inset-0 cursor-default bg-[#05080D]/60 backdrop-blur-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
onClick={handleClose}
/>
<motion.div
role="dialog"
aria-modal="true"
aria-label="New quick note"
className="absolute overflow-hidden rounded-[22px] bg-[#1B1F24] p-1"
initial={sourceRect}
animate={targetRect}
exit={sourceRect}
transition={{
type: "spring",
stiffness: 420,
damping: 42,
mass: 0.9,
}}
style={{
boxShadow:
"0 28px 80px rgba(0, 0, 0, 0.55), 0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<div
className="flex size-full flex-col rounded-[18px] bg-[#0B1017]"
style={{
boxShadow:
"inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
}}
>
<header className="flex shrink-0 justify-end border-b border-[#202A36]/70 px-5 py-4 md:px-7">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleMaximizeClick}
className="flex size-8 items-center justify-center rounded-full text-[#737373] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
aria-label="Open full screen note"
>
<Maximize2 className="size-4" />
</button>
<button
type="button"
onClick={handleClose}
className="flex size-8 items-center justify-center rounded-full text-[#737373] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
aria-label="Close quick note"
>
<X className="size-4" />
</button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-auto px-5 py-5 md:px-7 md:py-6">
<TextEditor
content={expandedInitialContent}
onContentChange={handleChange}
onSubmit={handleSaveClick}
debounceMs={0}
autoFocus
placeholder="Start writing..."
/>
</div>
<footer className="flex shrink-0 justify-center border-t border-[#202A36]/70 px-4 py-4">
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"bg-[#1B1F24] rounded-[8px] px-4 py-2.5 flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[14px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</button>
</footer>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
)}
</>
)
}

View file

@ -4,7 +4,9 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"
import Image from "next/image"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
import { cn } from "@lib/utils"
import { useIsMobile } from "@hooks/use-mobile"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import {
XIcon,
@ -47,6 +49,7 @@ import { useProjectMutations } from "@/hooks/use-project-mutations"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import NovaOrb from "@/components/nova/nova-orb"
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
import { SpaceGlyph } from "./space-glyph"
interface SelectSpacesModalProps {
isOpen: boolean
@ -116,6 +119,7 @@ export function SelectSpacesModal({
const editInputRef = useRef<HTMLInputElement | null>(null)
const editingContainerTag = editingProject?.containerTag
const currentSelection = selectedProjects[0] ?? ""
const isMobile = useIsMobile()
const pluginTags = useMemo(
() =>
@ -645,7 +649,11 @@ export function SelectSpacesModal({
</button>
{isEditing ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0 text-lg">{project.emoji || "📁"}</span>
<SpaceGlyph
emoji={project.emoji}
size={18}
className="shrink-0"
/>
<input
type="text"
value={editingProject.name}
@ -709,9 +717,11 @@ export function SelectSpacesModal({
) : isOwnSpace ? (
<NovaOrb size={20} className="shrink-0 blur-[0.55px]!" />
) : (
<span className="shrink-0 text-lg">
{project.emoji || "📁"}
</span>
<SpaceGlyph
emoji={project.emoji}
size={20}
className="shrink-0"
/>
)}
<span
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
@ -825,6 +835,283 @@ export function SelectSpacesModal({
)
}, [currentSelection, handleSelectAuto])
const renderCategoryChip = (
category: {
id: string
label: string
count?: number
iconSrc?: string
emoji?: string
},
isDiscover: boolean,
) => {
const isActive = activeCategory === category.id
return (
<button
key={category.id}
type="button"
onClick={() => setActiveCategory(category.id)}
className={cn(
"flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border px-3 py-2 transition-colors",
isActive
? "border-[#2261CA33] bg-[#00173C] text-[#fafafa]"
: "border-[#161F2C] bg-[#0D121A] text-[#A1A1AA]",
isDiscover && !isActive && "opacity-60",
)}
>
<span className="flex size-[18px] shrink-0 items-center justify-center">
{category.id === "all" ? (
<LayoutGrid
className={cn(
"size-4",
isActive ? "text-[#fafafa]" : "text-[#737373]",
)}
/>
) : category.iconSrc ? (
<Image
src={category.iconSrc}
alt=""
width={18}
height={18}
className="rounded-[3px]"
aria-hidden
/>
) : category.emoji ? (
<SpaceGlyph emoji={category.emoji} size={16} />
) : category.id.startsWith("plugin:") ? (
<span
className="flex h-[18px] w-[18px] items-center justify-center rounded-[3px] bg-[#1E232B] text-[10px] font-semibold uppercase text-[#FAFAFA]"
aria-hidden
>
{pluginInitial(category.label)}
</span>
) : (
<FolderIcon
className={cn(
"size-4",
isActive ? "text-[#fafafa]" : "text-[#737373]",
)}
/>
)}
</span>
<span className="text-[13px] font-medium">{category.label}</span>
{isDiscover ? (
<ArrowRight className="size-3.5 text-[#737373]" />
) : (
<span className="text-[11px] tabular-nums text-[#737373]">
{category.count}
</span>
)}
</button>
)
}
const rightPanelContent = activeCategory.startsWith("discover:") ? (
<DiscoverPanel
catalogId={activeDiscoverId ?? ""}
isConnecting={connectingPluginId === activeDiscoverId}
newKey={newKey?.pluginId === activeDiscoverId ? newKey.key : null}
onConnect={() => {
if (activeDiscoverId) connectMutation.mutate(activeDiscoverId)
}}
onDismissKey={() => setNewKey(null)}
/>
) : (
<>
<div className="relative shrink-0">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#737373]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search spaces..."
className={cn(
"w-full rounded-[12px] bg-[#14161A] py-2.5 pl-10 pr-4 text-[14px] text-[#fafafa] shadow-inside-out placeholder:text-[#737373] focus:outline-none",
dmSansClassName(),
)}
/>
</div>
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pr-1">
{filteredProjects.length === 0 ? (
<p className="py-8 text-center text-sm text-[#737373]">
No spaces found
</p>
) : (
<div className="flex flex-col gap-1">
{showAutoRow && (
<>
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
Mode
</div>
{renderAutoRow()}
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
</>
)}
{recentProjects.length > 0 && (
<>
<div className="flex items-center gap-1.5 px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
<Clock className="size-3" />
Recently used
</div>
{recentProjects.map(renderRow)}
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
<div className="px-3 pt-0.5 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
All spaces
</div>
</>
)}
{mainList.map(renderRow)}
</div>
)}
</div>
</>
)
const footerContent = !activeCategory.startsWith("discover:") &&
(isBulkDeleteMode || (showNewSpace && onNewSpace)) && (
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
{isBulkDeleteMode ? (
<>
<p className="min-w-0 text-[13px] font-medium text-[#737373]">
{bulkDeleteCount === 0
? "No spaces selected"
: `${bulkDeleteCount} ${
bulkDeleteCount === 1 ? "space" : "spaces"
} selected`}
</p>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
dmSansClassName(),
)}
>
Cancel
</button>
<button
type="button"
disabled={bulkDeleteCount === 0}
onClick={() => {
if (bulkDeleteCount === 0) return
onBulkDeleteRequest?.(bulkDeleteProjects)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}}
className={cn(
"flex items-center gap-2 rounded-full bg-red-600 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40",
dmSansClassName(),
)}
>
<Trash2 className="size-4" />
Delete selected
</button>
</div>
</>
) : (
<>
<span />
{showNewSpace && onNewSpace && (
<button
type="button"
onClick={onNewSpace}
className={cn(
"flex items-center gap-2 rounded-full bg-[#14161A] px-4 py-2 text-[13px] font-medium text-[#fafafa] shadow-inside-out transition-colors hover:bg-[#121820] focus:outline-none focus:ring-0",
dmSansClassName(),
)}
>
<Plus className="size-4" />
New space
</button>
)}
</>
)}
</div>
)
if (isMobile) {
return (
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
<DrawerContent
className={cn(
"flex h-[85dvh] flex-col gap-0 overflow-hidden border-none bg-[#1B1F24] p-0",
dmSansClassName(),
)}
>
<DrawerTitle className="sr-only">Select Space</DrawerTitle>
<div className="flex shrink-0 items-start justify-between gap-3 px-4 pt-1">
<div className="space-y-1">
<p
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Select Space
</p>
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
{isBulkDeleteMode
? "Choose spaces to permanently delete"
: "Filter your memories by space"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{enableDelete && onBulkDeleteRequest && !activeDiscoverId && (
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-2.5 text-[12px] font-medium transition-colors",
isBulkDeleteMode ? "text-[#fafafa]" : "text-[#737373]",
)}
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<Trash2 className="size-3.5" />
{isBulkDeleteMode ? "Cancel" : "Bulk delete"}
</button>
)}
<button
type="button"
onClick={() => handleOpenChange(false)}
aria-label="Close"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A]"
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" className="size-4" />
</button>
</div>
</div>
<div className="mt-3 flex shrink-0 gap-1.5 overflow-x-auto px-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{categories.map((category) => renderCategoryChip(category, false))}
{discoverCategories.length > 0 && (
<>
<div className="mx-0.5 my-1 w-px shrink-0 bg-[rgba(82,89,102,0.25)]" />
{discoverCategories.map((category) =>
renderCategoryChip(category, true),
)}
</>
)}
</div>
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-4 pb-2">
{rightPanelContent}
</div>
{footerContent}
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
@ -921,7 +1208,7 @@ export function SelectSpacesModal({
aria-hidden
/>
) : category.emoji ? (
<span className="text-base">{category.emoji}</span>
<SpaceGlyph emoji={category.emoji} size={16} />
) : category.id.startsWith("plugin:") ? (
<span
className="w-[18px] h-[18px] flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ import {
ChevronDown,
History,
Loader2,
Play,
RefreshCw,
Plus,
Trash2,
Zap,
@ -270,7 +270,7 @@ function ConnectionRow({
{isSyncing ? (
<Loader2 className="size-[18px] animate-spin" />
) : (
<Play className="size-[18px]" />
<RefreshCw className="size-[18px]" />
)}
</button>
)}
@ -559,7 +559,7 @@ export default function ConnectionsMCP() {
const isLoading = autumn.isLoading
return (
<div className="flex flex-col gap-8 pt-4 w-full">
<div className="flex flex-col gap-8 w-full">
{/* Supermemory Connections Section */}
<div className="flex flex-col gap-4">
<SectionTitle badge={<ProBadge />}>
@ -581,11 +581,11 @@ export default function ConnectionsMCP() {
!hasProProduct && !isLoading && "opacity-30 pointer-events-none",
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3">
<span
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
"min-w-0 flex-1 truncate font-semibold text-[15px] tracking-[-0.16px] text-[#FAFAFA] sm:text-[16px]",
)}
>
Connected to Supermemory
@ -593,7 +593,7 @@ export default function ConnectionsMCP() {
<span
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#737373]",
"shrink-0 whitespace-nowrap text-right font-semibold text-[13px] tracking-[-0.16px] text-[#737373] sm:text-[16px]",
)}
>
{connections.length}/{connectionsLimit} connections used

View file

@ -259,7 +259,7 @@ export default function Integrations() {
}
return (
<div className="flex flex-col gap-4 pt-4 w-full">
<div className="flex flex-col gap-4 w-full">
<SectionTitle>Integrations</SectionTitle>
<IntegrationCard id="chrome-extension-card">

View file

@ -0,0 +1,385 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { LoaderIcon, Settings, X } from "lucide-react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
import { cn } from "@lib/utils"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
type ContextTemplate = {
id: string
label: string
description: string
prompt: string
}
const SURFACE_SHADOW =
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
const CONTEXT_TEMPLATES: ContextTemplate[] = [
{
id: "personal-general",
label: "General Personal Assistant",
description:
"Remember preferences, routines, relationships, plans, and life context.",
prompt: `Supermemory personal assistant. The user saves conversations, notes, and daily context.
EXTRACT:
- Preferences: "prefers morning meetings", "allergic to peanuts"
- Routines: "works out every Tuesday and Thursday"
- Relationships: "Sarah is their manager", "lives with roommate Jake"
- Plans: "planning a trip to Japan in March"
- Life events: "moved to Austin last month", "started a new job"
SKIP:
- Generic assistant suggestions the user did not confirm
- Pleasantries and small talk without factual content
- Repeated scheduling details already captured`,
},
{
id: "personal-productivity",
label: "Productivity Assistant",
description:
"Focus on tasks, decisions, deadlines, workflows, and blockers.",
prompt: `Supermemory productivity tool. The user saves meeting notes, task updates, and project context.
EXTRACT:
- Action items: "needs to send proposal to client by Friday"
- Decisions: "team decided to use Figma for design handoff"
- Deadlines: "Q3 review due September 15th"
- Workflows: "deploys happen every Wednesday via CI pipeline"
- Blockers: "waiting on legal approval before launch"
SKIP:
- Meeting filler ("let's circle back", "good point")
- Status updates that repeat previously captured information
- Agenda items with no outcome or decision`,
},
]
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
)}
>
{children}
</p>
)
}
function SettingsCard({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
{children}
</div>
)
}
function PillButton({
children,
onClick,
disabled,
variant = "default",
}: {
children: React.ReactNode
onClick: () => void
disabled?: boolean
variant?: "default" | "danger" | "primary"
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
dmSansClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
"bg-[#0D121A] text-[#FAFAFA] hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
variant === "primary"
? "border-transparent"
: variant === "danger"
? "border-transparent"
: "border-transparent",
)}
>
{children}
</button>
)
}
export function OrgContext() {
const { data: settings, isLoading, isError } = useOrgSettings()
const updateSettings = useUpdateOrgSettings()
const [confirmDialog, setConfirmDialog] = useState<
"enable" | "disable" | null
>(null)
const [isManaging, setIsManaging] = useState(false)
const [prompt, setPrompt] = useState("")
const enabled = settings?.shouldLLMFilter ?? false
const savedPrompt = settings?.filterPrompt ?? ""
const dirty = prompt.trim() !== savedPrompt.trim()
const settingsReady = !isLoading && !isError
useEffect(() => {
setPrompt(settings?.filterPrompt ?? "")
}, [settings?.filterPrompt])
const selectedTemplateId = useMemo(() => {
const normalized = prompt.trim()
return CONTEXT_TEMPLATES.find(
(template) => template.prompt.trim() === normalized,
)?.id
}, [prompt])
const handleConfirmToggle = () => {
const newEnabled = confirmDialog === "enable"
updateSettings.mutate(
newEnabled
? { shouldLLMFilter: true }
: { shouldLLMFilter: false, filterPrompt: null },
{
onSuccess: () => {
setConfirmDialog(null)
if (!newEnabled) {
setIsManaging(false)
setPrompt("")
}
},
},
)
}
const handleSave = () => {
updateSettings.mutate(
{
shouldLLMFilter: true,
filterPrompt: prompt.trim() ? prompt.trim() : null,
},
{
onSuccess: () => {
setIsManaging(false)
},
},
)
}
const handleCancel = () => {
setPrompt(savedPrompt)
setIsManaging(false)
}
return (
<section id="organization-context" className="flex flex-col gap-4">
<div className="flex flex-col gap-1 px-2">
<SectionTitle>Organization Context</SectionTitle>
<p
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
)}
>
Guide how Nova processes and remembers your content.
</p>
</div>
<SettingsCard>
<div className={cn(dmSansClassName(), "flex flex-col gap-5")}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center">
<div className="min-w-0">
<p className="text-[15px] font-medium text-[#FAFAFA]">
{enabled ? "Context is enabled" : "Context is disabled"}
</p>
<p className="text-[13px] leading-snug text-[#737373]">
{enabled
? "New content will use your guidance when Nova creates memories."
: "Turn this on to tell Nova what matters most when it learns."}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<PillButton
onClick={() => setConfirmDialog(enabled ? "disable" : "enable")}
disabled={!settingsReady || updateSettings.isPending}
variant={enabled ? "danger" : "primary"}
>
{enabled ? "DISABLE" : "ENABLE"}
</PillButton>
{enabled && (
<PillButton
onClick={() => setIsManaging(true)}
disabled={updateSettings.isPending}
>
<Settings className="size-3.5" />
MANAGE
</PillButton>
)}
</div>
</div>
{enabled && !isManaging && savedPrompt && (
<div className="rounded-[12px] border border-white/[0.08] bg-[#0D121A] p-4">
<p className="line-clamp-6 whitespace-pre-wrap text-[13px] leading-relaxed text-[#A3A3A3]">
{savedPrompt}
</p>
</div>
)}
{enabled && !isManaging && !savedPrompt && (
<p className="text-[13px] italic text-[#737373]">
No organization context configured.{" "}
<button
type="button"
onClick={() => setIsManaging(true)}
className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer"
>
Set up now
</button>
</p>
)}
{enabled && isManaging && (
<div className="overflow-hidden rounded-[18px] border border-white/[0.08] bg-[#1B1F24]">
<div className="flex flex-col gap-4 p-4">
<label className="flex flex-col gap-2">
<span className="text-[13px] font-medium text-[#FAFAFA]">
What should Nova focus on?
</span>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Describe what Nova should extract, skip, and prioritize when turning your content into memories..."
className={cn(
dmSansClassName(),
"min-h-[180px] w-full resize-y rounded-[14px] border border-white/[0.08] bg-[#0D121A] p-4 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.14] focus:outline-none",
)}
maxLength={750}
/>
<span className="self-end text-[11px] text-[#737373]">
{prompt.length}/750
</span>
</label>
<div className="grid gap-3 md:grid-cols-2">
{CONTEXT_TEMPLATES.map((template) => {
const isSelected = selectedTemplateId === template.id
return (
<button
key={template.id}
type="button"
onClick={() => setPrompt(template.prompt)}
className={cn(
"rounded-[14px] border p-4 text-left transition-colors cursor-pointer",
"bg-[#14161A] hover:bg-[#121820]",
isSelected
? "border-white/[0.16] bg-[#171B22]"
: "border-white/[0.08]",
)}
>
<p className="text-[14px] font-medium text-[#FAFAFA]">
{template.label}
</p>
<p className="mt-1 text-[12px] leading-snug text-[#737373]">
{template.description}
</p>
</button>
)
})}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-white/[0.08] bg-[#171B22] px-4 py-3">
<PillButton onClick={handleCancel}>CANCEL</PillButton>
<PillButton
onClick={handleSave}
disabled={!dirty || updateSettings.isPending}
variant="primary"
>
{updateSettings.isPending && (
<LoaderIcon className="size-3.5 animate-spin" />
)}
SAVE
</PillButton>
</div>
</div>
)}
</div>
</SettingsCard>
<Dialog
open={!!confirmDialog}
onOpenChange={(open) => {
if (!open) setConfirmDialog(null)
}}
>
<DialogContent
showCloseButton={false}
style={{
boxShadow: SURFACE_SHADOW,
}}
className={cn(
"sm:max-w-[440px] border border-white/[0.12] bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
dmSansClassName(),
)}
>
<div className="flex flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5 flex-1">
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
)}
>
{confirmDialog === "enable"
? "Enable Organization Context?"
: "Disable Organization Context?"}
</DialogTitle>
<p
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]",
)}
>
{confirmDialog === "enable"
? "Nova will use your guidance when processing new content across this organization."
: "Nova will stop using this guidance for new content, and the saved context will be cleared."}
</p>
</div>
<DialogPrimitive.Close className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 focus:outline-hidden cursor-pointer">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="flex justify-end gap-2">
<PillButton onClick={() => setConfirmDialog(null)}>
CANCEL
</PillButton>
<PillButton
onClick={handleConfirmToggle}
disabled={updateSettings.isPending}
variant={confirmDialog === "disable" ? "danger" : "primary"}
>
{updateSettings.isPending && (
<LoaderIcon className="size-3.5 animate-spin" />
)}
{confirmDialog === "enable" ? "ENABLE" : "DISABLE"}
</PillButton>
</div>
</div>
</DialogContent>
</Dialog>
</section>
)
}

View file

@ -103,7 +103,7 @@ export default function Support() {
}
return (
<div className="flex flex-col gap-8 pt-4 w-full">
<div className="flex flex-col gap-8 w-full">
{/* Support & Help Section */}
<section className="flex flex-col gap-4">
<SectionTitle>Support &amp; Help</SectionTitle>

View file

@ -0,0 +1,34 @@
export function SpaceFolderIcon({
size = 16,
className,
}: {
size?: number
className?: string
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={className}
aria-hidden
>
<title>Space</title>
{/* paper tab peeking above the folder */}
<rect x="7" y="3.2" width="10" height="5" rx="1.6" fill="#F5C518" />
{/* folder back with tab */}
<path
d="M2.6 8.8a2 2 0 0 1 2-2h4.2a2 2 0 0 1 1.5.68l1 1.13a2 2 0 0 0 1.5.69h6.6a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H4.6a2 2 0 0 1-2-2Z"
fill="#161D29"
stroke="#3C4658"
strokeWidth="1.3"
/>
{/* lighter front flap for depth */}
<path
d="M2.6 11.6h18.8V17a2 2 0 0 1-2 2H4.6a2 2 0 0 1-2-2Z"
fill="#27313F"
/>
</svg>
)
}

View file

@ -0,0 +1,24 @@
import { cn } from "@lib/utils"
import { SpaceFolderIcon } from "./space-folder-icon"
export function SpaceGlyph({
emoji,
size = 16,
className,
}: {
emoji?: string | null
size?: number
className?: string
}) {
if (!emoji || emoji === "📁") {
return <SpaceFolderIcon size={size} className={cn("shrink-0", className)} />
}
return (
<span
className={cn("shrink-0 leading-none", className)}
style={{ fontSize: size }}
>
{emoji}
</span>
)
}

View file

@ -0,0 +1,39 @@
"use client"
import { dmSansClassName } from "@/lib/fonts"
import { SpaceProfileContent } from "@/components/space-profile-panel"
import { cn } from "@lib/utils"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
type SpaceProfileModalProps = {
containerTag: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function SpaceProfileModal({
containerTag,
open,
onOpenChange,
}: SpaceProfileModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
"w-[94%]! max-w-[440px]! max-h-[82dvh] border border-white/[0.08] bg-[#1B1F24] p-4 rounded-[22px]",
"shadow-[0_2.842px_14.211px_rgba(0,0,0,0.25),inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.10)]",
dmSansClassName(),
)}
>
<DialogTitle className="sr-only">Space Profile</DialogTitle>
<div className="flex min-h-[420px] flex-col">
<SpaceProfileContent
containerTag={containerTag}
onClose={() => onOpenChange(false)}
/>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,168 @@
"use client"
import { Brain, X } from "lucide-react"
import { motion } from "motion/react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { useSpaceProfile } from "@/hooks/use-space-profile"
import { cn } from "@lib/utils"
type SpaceProfilePanelProps = {
containerTag: string
isOpen: boolean
onClose: () => void
}
type SpaceProfileContentProps = {
containerTag: string
onClose?: () => void
}
function CountBadge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex h-[18px] min-w-[22px] items-center justify-center rounded-[4px] bg-white/[0.05] px-1.5 text-[10px] font-semibold text-[#A3A3A3]">
{children}
</span>
)
}
function LoadingState() {
return (
<div className="flex flex-col gap-3 pt-1">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="h-10 w-full animate-pulse rounded-[10px] bg-white/[0.04]"
/>
))}
</div>
)
}
function EmptyState() {
return (
<div className="flex min-h-[180px] flex-col items-center justify-center rounded-[14px] border border-white/[0.08] bg-[#14161A] px-5 py-8 text-center">
<div className="mb-3 flex size-10 items-center justify-center rounded-full bg-[#0D121A] shadow-inside-out">
<Brain className="size-4 text-[#A3A3A3]" />
</div>
<p className="text-[14px] font-medium text-[#FAFAFA]">No profile yet</p>
<p className="mt-1 text-[12px] leading-relaxed text-[#737373]">
Add more memories and Nova will learn about this space.
</p>
</div>
)
}
function ProfileSection({ label, items }: { label: string; items: string[] }) {
if (items.length === 0) return null
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-2">
<span
className={cn(
dmSans125ClassName(),
"text-[11px] font-semibold uppercase tracking-[0.12em] text-[#737373]",
)}
>
{label}
</span>
<CountBadge>{items.length}</CountBadge>
</div>
<div className="flex flex-col gap-1">
{items.map((item, index) => (
<div
key={`${label}-${index}-${item}`}
className="rounded-[10px] px-2 py-1.5 transition-colors hover:bg-white/[0.03]"
>
<div className="flex items-start gap-2">
<Brain className="mt-1 size-3 shrink-0 text-[#A3A3A3]" />
<p className="line-clamp-3 text-[12px] leading-relaxed text-[#D4D4D4]">
{item}
</p>
</div>
</div>
))}
</div>
</div>
)
}
export function SpaceProfileContent({
containerTag,
onClose,
}: SpaceProfileContentProps) {
const { data, isLoading, error } = useSpaceProfile(containerTag)
const keyFacts = data?.static ?? []
const recentContext = data?.dynamic ?? []
const totalCount = keyFacts.length + recentContext.length
return (
<div className={cn(dmSansClassName(), "flex min-h-0 flex-1 flex-col")}>
<div className="flex items-start justify-between gap-3 border-b border-white/[0.08] pb-3">
<div>
<h3
className={cn(
dmSans125ClassName(),
"text-[15px] font-semibold tracking-[-0.15px] text-[#FAFAFA]",
)}
>
Space Profile
</h3>
<p className="mt-0.5 text-[12px] text-[#737373]">
What Nova knows in this space
</p>
</div>
{onClose ? (
<button
type="button"
aria-label="Close space profile"
onClick={onClose}
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 cursor-pointer"
>
<X className="size-4" />
</button>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto pt-4 scrollbar-thin">
{isLoading ? (
<LoadingState />
) : error ? (
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
Failed to load space profile.
</p>
) : totalCount === 0 ? (
<EmptyState />
) : (
<div className="flex flex-col gap-5">
<ProfileSection label="Key Facts" items={keyFacts} />
<ProfileSection label="Recent Context" items={recentContext} />
</div>
)}
</div>
</div>
)
}
export function SpaceProfilePanel({
containerTag,
isOpen,
onClose,
}: SpaceProfilePanelProps) {
if (!isOpen) return null
return (
<motion.aside
initial={{ opacity: 0, width: 0, x: 12 }}
animate={{ opacity: 1, width: 300, x: 0 }}
exit={{ opacity: 0, width: 0, x: 12 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"hidden h-full w-[300px] shrink-0 flex-col rounded-[18px] border border-white/[0.08] bg-[#1B1F24] p-4 md:flex",
"shadow-[0_2.842px_14.211px_rgba(0,0,0,0.25),inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.10)]",
)}
>
<SpaceProfileContent containerTag={containerTag} onClose={onClose} />
</motion.aside>
)
}

View file

@ -12,6 +12,7 @@ import type { ContainerTagListType } from "@lib/types"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { AddSpaceModal } from "./add-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
import { SpaceGlyph } from "./space-glyph"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useContainerTags } from "@/hooks/use-container-tags"
import { motion } from "motion/react"
@ -52,6 +53,7 @@ export interface SpaceSelectorProps {
enableDelete?: boolean
compact?: boolean
includeAuto?: boolean
hideCount?: boolean
}
const triggerVariants = {
@ -111,6 +113,7 @@ export function SpaceSelector({
enableDelete = false,
compact = false,
includeAuto = false,
hideCount = false,
}: SpaceSelectorProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
@ -418,40 +421,29 @@ export function SpaceSelector({
</span>
)
) : (
<span
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
aria-hidden
>
{displayInfo.emoji}
</span>
)}
{!compact && (
<span
className={cn(
"min-w-0 truncate text-sm font-medium text-white",
"max-w-[10rem] md:max-w-[15rem]",
)}
title={isLoading ? undefined : displayInfo.name}
>
{isLoading ? "…" : displayInfo.name}
</span>
)}
{!compact && spaceCountData !== undefined && spaceCountData > 0 && (
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
· {formatCount(spaceCountData)}
</span>
)}
{!compact && (
<ChevronDownIcon
className="size-3.5 shrink-0 text-[#737373]"
aria-hidden
/>
)}
{compact && (
<span className="sr-only">
{isLoading ? "Loading" : displayInfo.name}
</span>
<SpaceGlyph emoji={displayInfo.emoji} size={compact ? 16 : 18} />
)}
<span
className={cn(
"min-w-0 truncate text-sm font-medium text-white",
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
)}
title={isLoading ? undefined : displayInfo.name}
>
{isLoading ? "…" : displayInfo.name}
</span>
{!compact &&
!hideCount &&
spaceCountData !== undefined &&
spaceCountData > 0 && (
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
· {formatCount(spaceCountData)}
</span>
)}
<ChevronDownIcon
className="size-3.5 shrink-0 text-[#737373]"
aria-hidden
/>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
@ -633,7 +625,7 @@ export function SpaceSelector({
className="shrink-0 blur-[0.45px]!"
/>
) : (
<span>{p.emoji || "📁"}</span>
<SpaceGlyph emoji={p.emoji} size={16} />
)}
<span className="truncate">
{p.containerTag === DEFAULT_PROJECT_ID ? (

View file

@ -6,9 +6,13 @@ import TaskList from "@tiptap/extension-task-list"
import TaskItem from "@tiptap/extension-task-item"
import { cx } from "class-variance-authority"
const placeholder = Placeholder.configure({
placeholder: 'Write, paste anything or type "/" for commands...',
})
const DEFAULT_PLACEHOLDER = 'Write, paste anything or type "/" for commands...'
function createPlaceholder(placeholderText = DEFAULT_PLACEHOLDER) {
return Placeholder.configure({
placeholder: placeholderText,
})
}
const taskList = TaskList.configure({
HTMLAttributes: {
@ -82,11 +86,15 @@ const starterKit = StarterKit.configure({
gapcursor: false,
})
export const defaultExtensions = [
starterKit,
placeholder,
link,
image,
taskList,
taskItem,
]
export function createDefaultExtensions(placeholderText?: string) {
return [
starterKit,
createPlaceholder(placeholderText),
link,
image,
taskList,
taskItem,
]
}
export const defaultExtensions = createDefaultExtensions()

View file

@ -4,30 +4,36 @@ import { useEditor, EditorContent } from "@tiptap/react"
import { BubbleMenu } from "@tiptap/react/menus"
import type { Editor } from "@tiptap/core"
import { Markdown } from "@tiptap/markdown"
import { useRef, useEffect, useCallback } from "react"
import { defaultExtensions } from "./extensions"
import { useRef, useEffect, useCallback, useMemo } from "react"
import { createDefaultExtensions } from "./extensions"
import { slashCommand } from "./suggestions"
import { Bold, Italic, Code } from "lucide-react"
import { useDebouncedCallback } from "use-debounce"
import { cn } from "@lib/utils"
const extensions = [...defaultExtensions, slashCommand, Markdown]
export function TextEditor({
content: initialContent,
onContentChange,
onSubmit,
debounceMs = 500,
autoFocus = false,
placeholder,
}: {
content: string | undefined
onContentChange: (content: string) => void
onSubmit: () => void
debounceMs?: number
autoFocus?: boolean
placeholder?: string
}) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(null)
const onSubmitRef = useRef(onSubmit)
const hasUserEditedRef = useRef(false)
const extensions = useMemo(
() => [...createDefaultExtensions(placeholder), slashCommand, Markdown],
[placeholder],
)
useEffect(() => {
onSubmitRef.current = onSubmit
@ -92,6 +98,16 @@ export function TextEditor({
}
}, [editor, initialContent])
useEffect(() => {
if (!editor || !autoFocus) return
const id = window.setTimeout(() => {
editor.commands.focus("end")
}, 0)
return () => window.clearTimeout(id)
}, [editor, autoFocus])
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (target.closest(".ProseMirror")) {

View file

@ -111,7 +111,7 @@ function CommandMenu({
if (!mounted) return null
return createPortal(
<div ref={refs.setFloating} style={floatingStyles} className="z-50">
<div ref={refs.setFloating} style={floatingStyles} className="z-[120]">
<CommandList
items={items}
command={command}

View file

@ -1,13 +1,14 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { AnimatePresence, motion } from "motion/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"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -100,6 +101,39 @@ function getPreviewText(doc: DocumentWithMemories): string {
return doc.summary || doc.content || doc.title || ""
}
function isTemporaryId(id: string | null | undefined): boolean {
if (!id) return false
return id.startsWith("temp-") || id.startsWith("temp-file-")
}
function SelectionBox({
isSelected,
isPartial = false,
}: {
isSelected: boolean
isPartial?: boolean
}) {
return (
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
isSelected
? "border-[#369BFD] bg-[#369BFD]"
: isPartial
? "border-[#369BFD] bg-[#369BFD]/20"
: "border-[#737373] bg-transparent",
)}
aria-hidden
>
{isSelected ? (
<CheckIcon className="size-3 text-white" strokeWidth={3} />
) : isPartial ? (
<span className="h-0.5 w-2 rounded-full bg-[#369BFD]" />
) : null}
</span>
)
}
// ─── Grouped data structures ─────────────────────────────────────────────────
type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] }
@ -126,7 +160,7 @@ function groupDocuments(
}
return periodOrder.map((label) => {
const docs = periodMap.get(label)!
const docs = periodMap.get(label) ?? []
const categoryMap = new Map<
string,
{ info: CategoryInfo; docs: DocumentWithMemories[] }
@ -144,9 +178,9 @@ function groupDocuments(
return {
label,
typeGroups: categoryOrder.map((key) => {
const entry = categoryMap.get(key)!
return { categoryInfo: entry.info, docs: entry.docs }
typeGroups: categoryOrder.flatMap((key) => {
const entry = categoryMap.get(key)
return entry ? [{ categoryInfo: entry.info, docs: entry.docs }] : []
}),
}
})
@ -157,30 +191,58 @@ function groupDocuments(
function TimelineCard({
doc,
onOpenDocument,
isSelectionMode = false,
isSelected = false,
onToggleSelection,
indent = false,
isLast = false,
}: {
doc: DocumentWithMemories
onOpenDocument: (doc: DocumentWithMemories) => void
isSelectionMode?: boolean
isSelected?: boolean
onToggleSelection?: (doc: DocumentWithMemories) => void
indent?: boolean
isLast?: 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
const canSelect = !isTemporaryId(doc.id) && !isTemporaryId(doc.customId)
const handleClick = () => {
if (isSelectionMode && canSelect) {
onToggleSelection?.(doc)
return
}
onOpenDocument(doc)
}
return (
<button
type="button"
className={cn(
"w-full text-left px-4 py-3 cursor-pointer transition-colors",
"relative w-full text-left px-3 py-3 cursor-pointer transition-colors sm:px-4",
indent
? "bg-transparent hover:bg-white/[0.04]"
: "rounded-2xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
? "bg-[#121820] hover:bg-[#17202A]"
: "rounded-xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D] sm:rounded-2xl",
indent && !isLast && "border-b border-[#252B35]/70",
indent && isLast && "rounded-b-xl sm:rounded-b-2xl",
isSelectionMode && canSelect && "pl-14 sm:pl-16",
isSelectionMode && isSelected && "border-[#369BFD]/70 bg-[#00173C]/45",
dmSansClassName(),
)}
onClick={() => onOpenDocument(doc)}
onClick={handleClick}
aria-pressed={isSelectionMode ? isSelected : undefined}
>
{isSelectionMode && canSelect && (
<span className="absolute left-5 top-4 z-10">
<SelectionBox isSelected={isSelected} />
</span>
)}
{/* Type label */}
<div className="flex items-center gap-1.5 mb-2">
<DocumentIcon
@ -243,15 +305,23 @@ function GroupCard({
isExpanded,
onToggle,
onOpenDocument,
isSelectionMode,
selectedDocumentIds,
onToggleSelection,
expandKey,
}: {
group: TypeGroup
isExpanded: boolean
onToggle: () => void
onOpenDocument: (doc: DocumentWithMemories) => void
isSelectionMode: boolean
selectedDocumentIds: Set<string>
onToggleSelection?: (documentId: string) => void
expandKey: string
}) {
const firstDoc = group.docs[0]!
const firstDoc = group.docs[0]
if (!firstDoc) return null
const preview = getPreviewText(firstDoc)
const count = group.docs.length
const { label, singularLabel } = group.categoryInfo
@ -260,75 +330,153 @@ function GroupCard({
(sum, d) => sum + d.memoryEntries.length,
0,
)
const selectableDocs = group.docs.filter(
(doc) => !isTemporaryId(doc.id) && !isTemporaryId(doc.customId) && doc.id,
)
const selectedCount = selectableDocs.filter(
(doc) => doc.id && selectedDocumentIds.has(doc.id),
).length
const isGroupSelected =
selectableDocs.length > 0 && selectedCount === selectableDocs.length
const isGroupPartial = selectedCount > 0 && !isGroupSelected
const handleGroupSelect = () => {
for (const doc of selectableDocs) {
if (!doc.id) continue
const shouldToggle = isGroupSelected
? selectedDocumentIds.has(doc.id)
: !selectedDocumentIds.has(doc.id)
if (shouldToggle) onToggleSelection?.(doc.id)
}
}
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="size-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(
"size-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
className={cn(
"overflow-hidden rounded-xl border border-[#252B35] bg-[#1B1F24] sm:rounded-2xl",
isSelectionMode &&
(isGroupSelected || isGroupPartial) &&
"border-[#369BFD]/70 bg-[#00173C]/45",
)}
>
<div
className={cn(
"flex w-full items-stretch transition-colors hover:bg-[#21262D]",
isExpanded && "border-b border-[#252B35]",
isSelectionMode &&
(isGroupSelected || isGroupPartial) &&
"bg-[#00173C]/45",
)}
>
{isSelectionMode && selectableDocs.length > 0 && (
<button
type="button"
className="flex w-14 shrink-0 cursor-pointer items-start justify-center py-4 sm:w-16"
onClick={handleGroupSelect}
aria-label={isGroupSelected ? "Deselect group" : "Select group"}
aria-pressed={isGroupSelected}
>
<SelectionBox
isSelected={isGroupSelected}
isPartial={isGroupPartial}
/>
</button>
)}
<button
type="button"
className={cn(
"flex min-w-0 flex-1 cursor-pointer items-start justify-between gap-2 py-3 pr-3 text-left sm:items-center sm:gap-3 sm:pr-4",
isSelectionMode && selectableDocs.length > 0
? "pl-1"
: "pl-3 sm:pl-4",
dmSansClassName(),
)}
onClick={onToggle}
aria-expanded={isExpanded}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2.5 gap-y-1">
<DocumentIcon
type={firstDoc.type}
source={firstDoc.source ?? undefined}
url={firstDoc.url ?? undefined}
className="size-3.5 shrink-0 opacity-60"
/>
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
{countLabel}
</span>
{preview && (
<span className="basis-full text-[12px] leading-relaxed text-white/35 line-clamp-2 sm:basis-auto sm:truncate sm:leading-normal">
· {preview}
</span>
)}
{totalMemories > 0 && (
<span
className="shrink-0 text-[11px] font-medium sm: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(
"mt-0.5 size-3.5 text-white/20 shrink-0 transition-transform duration-200 sm:mt-0",
isExpanded && "rotate-180",
)}
/>
</button>
</div>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
id={`group-${expandKey}`}
className="overflow-hidden bg-[#121820] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ height: 0, opacity: 0, y: -4 }}
animate={{ height: "auto", opacity: 1, y: 0 }}
exit={{ height: 0, opacity: 0, y: -3 }}
transition={{
height: { duration: 0.28, ease: [0.4, 0, 0.2, 1] },
opacity: { duration: 0.18 },
y: { duration: 0.22, ease: [0.4, 0, 0.2, 1] },
}}
>
{group.docs.map((doc, index) => (
<motion.div
key={doc.id}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -3 }}
transition={{
duration: 0.18,
delay: Math.min(index * 0.025, 0.12),
ease: [0.4, 0, 0.2, 1],
}}
>
<TimelineCard
doc={doc}
onOpenDocument={onOpenDocument}
isSelectionMode={isSelectionMode}
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
onToggleSelection={(doc) => {
if (doc.id) onToggleSelection?.(doc.id)
}}
indent
isLast={index === group.docs.length - 1}
/>
</motion.div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@ -341,6 +489,9 @@ interface TimelineViewProps {
hasNextPage?: boolean
isFetchingNextPage?: boolean
onLoadMore?: () => void
isSelectionMode?: boolean
selectedDocumentIds?: Set<string>
onToggleSelection?: (documentId: string) => void
}
export function TimelineView({
@ -349,6 +500,9 @@ export function TimelineView({
hasNextPage,
isFetchingNextPage,
onLoadMore,
isSelectionMode = false,
selectedDocumentIds = new Set(),
onToggleSelection,
}: TimelineViewProps) {
const [now] = useState(() => new Date())
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
@ -378,50 +532,131 @@ export function TimelineView({
}, [])
const periodGroups = groupDocuments(documents, now)
const handleTimelineCardSelection = useCallback(
(doc: DocumentWithMemories) => {
if (doc.id) onToggleSelection?.(doc.id)
},
[onToggleSelection],
)
return (
<div
className={cn(
"w-full max-w-[780px] mx-auto py-4 pb-12 space-y-6",
"w-full max-w-[820px] mx-auto py-3 pb-12 space-y-5 sm:py-4 sm:space-y-7",
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>
{periodGroups.map((period, periodIndex) => {
const periodHasExpandedGroup = period.typeGroups.some((group) =>
expandedGroups.has(`${period.label}::${group.categoryInfo.key}`),
)
<div className="space-y-1.5 min-w-0">
{period.typeGroups.map((group) => {
const expandKey = `${period.label}::${group.categoryInfo.key}`
return (
<div
key={period.label}
className="grid grid-cols-[22px_minmax(0,1fr)] gap-x-2 gap-y-2 sm:grid-cols-[96px_24px_minmax(0,1fr)] sm:gap-x-3 sm:gap-y-0"
>
<div className="col-start-2 row-start-1 shrink-0 pt-0 sm:col-start-1 sm:row-start-1 sm:pt-3 sm:text-right">
<span className="inline-flex rounded-full border border-[#252B35] bg-[#0D121A] px-2.5 py-1 text-[10px] font-medium uppercase leading-none tracking-[0.15em] text-white/45 sm:border-transparent sm:bg-transparent sm:px-0 sm:py-0 sm:text-white/30">
{period.label}
</span>
</div>
<div className="relative col-start-1 row-start-1 row-span-2 flex justify-center sm:col-start-2 sm:row-start-1 sm:row-span-1">
<div
className={cn(
"absolute top-4 bottom-[-20px] w-px bg-[#1F2835] sm:top-5 sm:bottom-[-28px]",
periodIndex === periodGroups.length - 1 && "bottom-2",
)}
/>
<motion.div
className={cn(
"absolute top-4 bottom-[-20px] w-px origin-top bg-linear-to-b from-[#369BFD] via-[#369BFD]/70 to-transparent sm:top-5 sm:bottom-[-28px]",
periodIndex === periodGroups.length - 1 && "bottom-2",
)}
initial={false}
animate={{
scaleY: periodHasExpandedGroup ? 1 : 0,
opacity: periodHasExpandedGroup ? 1 : 0,
}}
transition={{ duration: 0.34, ease: [0.4, 0, 0.2, 1] }}
/>
<motion.div
className="relative mt-1.5 flex size-3 items-center justify-center rounded-full border border-[#369BFD]/35 bg-[#0D121A] shadow-[0_0_0_4px_rgba(54,155,253,0.06)] sm:mt-3"
initial={false}
animate={{
borderColor: periodHasExpandedGroup
? "rgba(54,155,253,0.8)"
: "rgba(54,155,253,0.35)",
boxShadow: periodHasExpandedGroup
? "0 0 0 7px rgba(54,155,253,0.09)"
: "0 0 0 4px rgba(54,155,253,0.06)",
}}
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
>
<AnimatePresence>
{periodHasExpandedGroup && (
<motion.div
className="absolute inset-[-6px] rounded-full border border-[#369BFD]/35"
initial={{ scale: 0.6, opacity: 0.8 }}
animate={{ scale: 1.45, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.52,
ease: [0.2, 0.8, 0.2, 1],
}}
/>
)}
</AnimatePresence>
<motion.div
className="size-1.5 rounded-full bg-[#369BFD]"
initial={false}
animate={{ scale: periodHasExpandedGroup ? 1.15 : 1 }}
transition={{ duration: 0.24, ease: [0.4, 0, 0.2, 1] }}
/>
</motion.div>
</div>
<div className="col-start-2 row-start-2 min-w-0 space-y-1.5 sm:col-start-3 sm:row-start-1">
{period.typeGroups.map((group) => {
const expandKey = `${period.label}::${group.categoryInfo.key}`
if (group.docs.length === 1) {
const doc = group.docs[0]
if (!doc) return null
return (
<TimelineCard
key={expandKey}
doc={doc}
onOpenDocument={onOpenDocument}
isSelectionMode={isSelectionMode}
isSelected={
doc.id ? selectedDocumentIds.has(doc.id) : false
}
onToggleSelection={handleTimelineCardSelection}
/>
)
}
if (group.docs.length === 1) {
return (
<TimelineCard
<GroupCard
key={expandKey}
doc={group.docs[0]!}
group={group}
expandKey={expandKey}
isExpanded={expandedGroups.has(expandKey)}
onToggle={() => toggleGroup(expandKey)}
onOpenDocument={onOpenDocument}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onToggleSelection={onToggleSelection}
/>
)
}
return (
<GroupCard
key={expandKey}
group={group}
expandKey={expandKey}
isExpanded={expandedGroups.has(expandKey)}
onToggle={() => toggleGroup(expandKey)}
onOpenDocument={onOpenDocument}
/>
)
})}
})}
</div>
</div>
</div>
))}
)
})}
<div ref={sentinelRef} className="h-1" />
</div>

View file

@ -48,7 +48,7 @@ export function UserProfileMenu({
} catch {
// still navigate if the request fails (offline, etc.)
}
router.push("/login/new")
router.push("/login")
})()
}
@ -125,28 +125,28 @@ export function UserProfileMenu({
<DropdownMenuContent
align="end"
className={cn(
"min-w-[220px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
"min-w-[240px] p-1.5 rounded-xl border border-white/[0.08] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<div id="user-info" className="px-3 py-2.5">
<div id="user-info" className="px-2.5 py-2">
<p className="text-white text-sm font-medium truncate">{user.name}</p>
<p className="text-[#737373] text-xs truncate">{user.email}</p>
</div>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem
onClick={() => router.push("/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleTryOnboarding}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<RotateCcw className="size-4 text-[#737373]" />
Try onboarding
@ -154,16 +154,16 @@ export function UserProfileMenu({
{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"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<LifeBuoy className="size-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<a href="mailto:support@supermemory.com">
<HelpCircle className="size-4 text-[#737373]" />
@ -172,7 +172,7 @@ export function UserProfileMenu({
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<a
href="https://supermemory.link/discord"
@ -190,10 +190,10 @@ export function UserProfileMenu({
Discord
</a>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem
onClick={handleSignOut}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<LogOut className="size-4 text-[#737373]" />
Logout

View file

@ -26,6 +26,7 @@
--animate-file-upload-grow: file-upload-grow 6s cubic-bezier(0.22, 1, 0.36, 1)
forwards;
--animate-login-marquee: login-marquee 40s linear infinite;
}
:root {
@ -236,3 +237,319 @@
width: 92%;
}
}
@keyframes login-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.login-page-grid {
display: grid;
min-height: 100dvh;
height: 100dvh;
overflow-x: hidden;
overflow-y: auto;
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
@media (min-width: 1024px) {
.login-page-grid {
height: 100dvh;
overflow: hidden;
grid-template-columns: 65% 35%;
grid-template-rows: minmax(0, 1fr);
}
}
.login-page-mobile-bg {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 0;
}
@media (min-width: 1024px) {
.login-page-mobile-bg {
display: none;
}
}
.login-tools-panel-inner {
flex: 1;
min-height: 0;
}
.login-panel-orb {
position: absolute;
inset: 0;
background:
radial-gradient(
ellipse 130% 100% at 50% 45%,
rgba(70, 155, 255, 0.24) 0%,
rgba(35, 100, 210, 0.14) 38%,
rgba(8, 22, 45, 0.55) 68%,
#030912 100%
),
radial-gradient(
ellipse 90% 70% at 50% 100%,
rgba(50, 130, 240, 0.16) 0%,
transparent 65%
);
}
.login-panel-orb-image {
position: absolute;
inset: -10% -15% -5% -15%;
background-image: url("/onboarding/bg-gradient-0.png");
background-size: cover;
background-position: center 35%;
background-repeat: no-repeat;
opacity: 0.22;
pointer-events: none;
}
.login-panel-orb-image-alt {
position: absolute;
inset: -10% -15% -5% -15%;
background-image: url("/onboarding/bg-gradient-1.png");
background-size: cover;
background-position: center 35%;
background-repeat: no-repeat;
opacity: 0.11;
pointer-events: none;
}
.login-tools-section {
position: relative;
flex-shrink: 0;
padding: 1.5rem 0 2rem;
}
@media (min-width: 1024px) {
.login-tools-section {
padding: 1.75rem 0 2.5rem;
}
}
.login-tools-section::before {
content: "";
position: absolute;
inset-inline: 10%;
top: 0;
height: 1px;
background: linear-gradient(
to right,
transparent,
rgb(255 255 255 / 0.12),
transparent
);
}
.login-tools-marquee {
position: relative;
overflow: hidden;
mask-image: linear-gradient(
to right,
transparent,
black 10%,
black 90%,
transparent
);
}
.login-tools-marquee-track {
display: flex;
width: max-content;
gap: 0.625rem;
animation: login-marquee var(--marquee-duration, 40s) linear infinite;
will-change: transform;
}
.login-tools-marquee-track-reverse {
animation-direction: reverse;
}
.login-tools-marquee:hover .login-tools-marquee-track {
animation-play-state: paused;
}
.login-tools-chip {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.05);
padding: 0.375rem 0.875rem 0.375rem 0.375rem;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
backdrop-filter: blur(8px);
}
.login-tools-chip-icon {
display: flex;
width: 1.625rem;
height: 1.625rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgb(255 255 255 / 0.07);
}
.login-tools-chip-icon svg,
.login-tools-chip-icon img {
width: 0.875rem;
height: 0.875rem;
opacity: 0.92;
}
.login-tools-chip-label {
font-size: 0.75rem;
font-weight: 500;
color: rgb(255 255 255 / 0.82);
white-space: nowrap;
}
.login-tool-node-wrap {
position: absolute;
z-index: 2;
transform: translate(-50%, -50%);
}
.login-tool-node-label {
position: absolute;
top: calc(100% + 0.4rem);
left: 50%;
max-width: 5.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transform: translateX(-50%);
font-size: 0.6875rem;
font-weight: 500;
color: rgb(200 225 255 / 0.95);
text-shadow: 0 1px 8px rgb(0 0 0 / 0.65);
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.login-tool-node-label-visible {
opacity: 1;
}
.login-tool-node {
display: flex;
width: 2.625rem;
height: 2.625rem;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
border: 1px solid rgb(255 255 255 / 0.12);
background: rgb(12 22 40 / 0.82);
backdrop-filter: blur(12px);
box-shadow:
0 1px 0 0 rgb(255 255 255 / 0.1) inset,
0 6px 20px rgb(0 0 0 / 0.22);
transition:
border-color 0.35s ease,
background 0.35s ease,
box-shadow 0.35s ease,
transform 0.35s cubic-bezier(0.34, 1.2, 0.64, 1);
}
@media (min-width: 640px) {
.login-tool-node {
width: 2.875rem;
height: 2.875rem;
border-radius: 0.8125rem;
}
}
.login-tool-node-icon {
width: 1.25rem;
height: 1.25rem;
opacity: 0.88;
transition: opacity 0.35s ease;
}
@media (min-width: 640px) {
.login-tool-node-icon {
width: 1.375rem;
height: 1.375rem;
}
}
.login-tool-node-source {
border-color: rgb(100 180 255 / 0.55);
background: rgb(14 28 50 / 0.92);
box-shadow:
0 0 0 1px rgb(100 180 255 / 0.25),
0 0 24px rgb(75 160 250 / 0.28),
0 1px 0 0 rgb(255 255 255 / 0.1) inset;
transform: scale(1.05);
}
.login-tool-node-source .login-tool-node-icon {
opacity: 1;
}
.login-tool-node-destination {
border-color: rgb(130 210 255 / 0.65);
background: rgb(16 32 58 / 0.95);
box-shadow:
0 0 0 1px rgb(130 210 255 / 0.3),
0 0 32px rgb(100 180 255 / 0.35),
0 1px 0 0 rgb(255 255 255 / 0.12) inset;
transform: scale(1.08);
}
.login-tool-node-destination .login-tool-node-icon {
opacity: 1;
}
.login-context-chip {
display: flex;
width: 2.75rem;
height: 1.625rem;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
border: 1px solid rgb(180 220 255 / 0.35);
background: rgb(12 24 44 / 0.95);
box-shadow:
0 0 16px rgb(100 180 255 / 0.4),
0 1px 0 0 rgb(255 255 255 / 0.12) inset;
}
.login-context-chip-lines {
display: flex;
width: 1.5rem;
flex-direction: column;
gap: 0.2rem;
}
.login-context-chip-lines span {
display: block;
height: 0.125rem;
border-radius: 9999px;
background: rgb(180 220 255 / 0.55);
}
.login-context-chip-lines span:nth-child(1) {
width: 100%;
}
.login-context-chip-lines span:nth-child(2) {
width: 78%;
opacity: 0.75;
}
.login-context-chip-lines span:nth-child(3) {
width: 52%;
opacity: 0.5;
}

View file

@ -0,0 +1,80 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
const API_BASE = `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`
export type OrgSettings = {
shouldLLMFilter: boolean
filterPrompt: string | null
includeItems?: string[] | null
excludeItems?: string[] | null
}
type OrgSettingsResponse = {
settings?: Partial<OrgSettings>
} & Partial<OrgSettings>
export function useOrgSettings() {
const { org } = useAuth()
const orgId = org?.id ?? ""
return useQuery({
queryKey: ["settings", "org", orgId],
queryFn: async (): Promise<OrgSettings> => {
const response = await $fetch("@get/settings", {
disableValidation: true,
})
if (response.error) {
throw new Error(response.error.message || "Failed to load settings")
}
const data = response.data as OrgSettingsResponse | null
const settings = data?.settings ?? data ?? {}
return {
shouldLLMFilter: settings.shouldLLMFilter ?? false,
filterPrompt: settings.filterPrompt ?? null,
includeItems: settings.includeItems ?? null,
excludeItems: settings.excludeItems ?? null,
}
},
enabled: !!orgId,
staleTime: 60 * 1000,
})
}
export function useUpdateOrgSettings() {
const { org } = useAuth()
const queryClient = useQueryClient()
const orgId = org?.id ?? ""
return useMutation({
mutationFn: async (settings: Partial<OrgSettings>) => {
const res = await fetch(`${API_BASE}/settings`, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify(settings),
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
message?: string
}
throw new Error(body?.message || "Failed to save settings")
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
toast.success("Settings saved")
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save settings",
)
},
})
}

View file

@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
export type SpaceProfile = {
static: string[]
dynamic: string[]
}
export function useSpaceProfile(containerTag: string) {
const { org } = useAuth()
const orgId = org?.id ?? ""
return useQuery({
queryKey: ["space-profile", orgId, containerTag],
queryFn: async (): Promise<SpaceProfile> => {
const response = await $fetch(
"@get/container-tags/:containerTag/profile",
{
params: { containerTag },
},
)
if (response.error) {
throw new Error(
response.error.message || "Failed to load space profile",
)
}
const profile = response.data.profile
return {
static: profile.static ?? [],
dynamic: profile.dynamic ?? [],
}
},
enabled: !!orgId && !!containerTag,
staleTime: 60 * 1000,
})
}

View file

@ -151,7 +151,7 @@ export const analytics = {
// settings / spaces / docs analytics
settingsTabChanged: (props: {
tab: "account" | "integrations" | "connections" | "support"
tab: "account" | "billing" | "integrations" | "connections" | "support"
}) => safeCapture("settings_tab_changed", props),
spaceCreated: () => safeCapture("space_created"),

View file

@ -147,3 +147,9 @@ const SPACE_TO_CATALOG_ID: Record<string, string> = {
export function spacePluginIdToCatalogId(spacePluginId: string): string | null {
return SPACE_TO_CATALOG_ID[spacePluginId] ?? null
}
/** Normalize plugin client ids from API keys, metadata, and space ids. */
export function normalizePluginClientId(client: string): string {
const trimmed = client.trim().toLowerCase()
return spacePluginIdToCatalogId(trimmed) ?? trimmed.replace(/-/g, "_")
}

View file

@ -1,3 +1,5 @@
import { normalizePluginClientId } from "@/lib/plugin-catalog"
export type PluginSpaceInfo = {
pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp"
label: string
@ -131,6 +133,48 @@ export function detectPluginSource(
}
}
type DocumentSpaceCandidate = {
containerTags?: string[]
memoryEntries?: Array<{ spaceContainerTag?: string | null }> | null
}
function catalogIdForPluginSpace(
pluginId: PluginSpaceInfo["pluginId"],
): string {
return pluginId.replace(/-/g, "_")
}
/** Resolve the best container tag for a tool/plugin document. */
export function getToolDocumentSpace(
document: DocumentSpaceCandidate,
pluginClientId?: string | null,
): string | null {
const containerTags = document.containerTags ?? []
const memorySpaceTags = (document.memoryEntries ?? [])
.map((entry) => entry.spaceContainerTag)
.filter((tag): tag is string => !!tag)
const allTags = [...containerTags, ...memorySpaceTags]
if (pluginClientId) {
const normalizedClient = normalizePluginClientId(pluginClientId)
for (const tag of allTags) {
const pluginSpace = detectPluginSpace(tag)
if (
pluginSpace &&
catalogIdForPluginSpace(pluginSpace.pluginId) === normalizedClient
) {
return tag
}
}
}
for (const tag of allTags) {
if (detectPluginSpace(tag)) return tag
}
return allTags[0] ?? null
}
export function detectPluginSpace(
containerTag: string,
): PluginSpaceInfo | null {

View file

@ -42,7 +42,8 @@ export type ViewParamValue = (typeof viewLiterals)[number]
export const viewParam =
parseAsStringLiteral(viewLiterals).withDefault("dashboard")
// Kept for backwards compat with components that pass integration hints
// Kept for backwards compat with components that pass integration hints.
// "notion"/"google-drive" are connection providers, not view modes — they open the connect modal.
export type IntegrationParamValue =
| "mcp"
| "plugins"
@ -51,6 +52,8 @@ export type IntegrationParamValue =
| "shortcuts"
| "raycast"
| "import"
| "notion"
| "google-drive"
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
[],
)

View file

@ -1,3 +1,47 @@
const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
/** Reconstruct the browser-facing URL when running behind portless (or similar). */
export function getPublicRequestUrl(request: Request): URL {
const internal = new URL(request.url)
const forwardedHost = request.headers
.get("x-forwarded-host")
?.split(",")[0]
?.trim()
if (forwardedHost) {
const proto = request.headers.get("x-forwarded-proto") || "https"
return new URL(
`${proto}://${forwardedHost}${internal.pathname}${internal.search}`,
)
}
const portlessUrl = process.env.PORTLESS_URL
if (portlessUrl) {
try {
const base = new URL(portlessUrl)
return new URL(`${base.origin}${internal.pathname}${internal.search}`)
} catch {}
}
return internal
}
/** Map portless proxy localhost redirects back to the current public origin. */
export function resolveAuthRedirectUrl(
redirectUrl: string | null,
origin: string,
): URL {
const fallback = new URL(origin)
if (!redirectUrl) return fallback
try {
const target = new URL(redirectUrl)
if (PROXY_LOCAL_HOSTS.has(target.hostname)) {
return new URL(`${target.pathname}${target.search}`, origin)
}
if (target.origin === origin) return target
return fallback
} catch {
return fallback
}
}
/**
* Validates if a string is a valid URL.
*/

View file

@ -1,14 +1,22 @@
import { getSessionCookie } from "better-auth/cookies"
import { NextResponse } from "next/server"
import { getPublicRequestUrl } from "@/lib/url-helpers"
function getAuthSessionCookie(request: Request): string | null {
return (
getSessionCookie(request) ??
getSessionCookie(request, { cookiePrefix: "better-auth-dev" })
)
}
export default async function proxy(request: Request) {
console.debug("[PROXY] === PROXY START ===")
const url = new URL(request.url)
const url = getPublicRequestUrl(request)
console.debug("[PROXY] Path:", url.pathname)
console.debug("[PROXY] Method:", request.method)
const sessionCookie = getSessionCookie(request)
const sessionCookie = getAuthSessionCookie(request)
console.debug("[PROXY] Session cookie exists:", !!sessionCookie)
// Always allow access to login and waitlist pages
@ -40,9 +48,9 @@ export default async function proxy(request: Request) {
console.debug(
"[PROXY] No session cookie and not on public path, redirecting to /login",
)
const url = new URL("/login", request.url)
url.searchParams.set("redirect", request.url)
return NextResponse.redirect(url)
const loginUrl = new URL("/login", url.origin)
loginUrl.searchParams.set("redirect", url.toString())
return NextResponse.redirect(loginUrl)
}
// TEMPORARILY DISABLED: Waitlist check

View file

@ -295,7 +295,7 @@
},
"packages/memory-graph": {
"name": "@supermemory/memory-graph",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"d3-force": "^3.0.0",
},

View file

@ -164,7 +164,7 @@ export const apiSchema = createSchema({
// Settings operations
"@get/settings": {
output: z.object({ settings: z.object({}).passthrough() }),
output: z.object({}).passthrough(),
},
"@patch/settings": {
input: SettingsRequestSchema,
@ -254,6 +254,17 @@ export const apiSchema = createSchema({
"@get/container-tags/list": {
output: ListContainerTagsResponseSchema,
},
"@get/container-tags/:containerTag/profile": {
output: z.object({
profile: z.object({
static: z.array(z.string()).optional(),
dynamic: z.array(z.string()).optional(),
}),
}),
params: z.object({
containerTag: z.string(),
}),
},
"@patch/container-tags/:containerTag": {
input: UpdateContainerTagSettingsRequestSchema,
output: ContainerTagSettingsUpdateSchema,

View file

@ -26,8 +26,9 @@ interface AuthContextType {
isRestoring: boolean
isSessionPending: boolean
setActiveOrg: (orgSlug: string) => Promise<void>
clearActiveOrg: () => void
clearActiveOrg: () => Promise<void>
updateOrgMetadata: (partial: Record<string, unknown>) => void
refetchActiveOrg: () => Promise<Organization | null>
refetchOrganizations: () => Promise<unknown>
}
@ -61,7 +62,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem(STORAGE_KEY, slug)
}, [])
const clearActiveOrg = useCallback(() => {
const clearActiveOrg = useCallback(async () => {
try {
await authClient.organization.setActive({ organizationId: null })
} catch {}
setOrg(null)
try {
localStorage.removeItem(STORAGE_KEY)
@ -81,6 +85,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})
}, [])
const refetchActiveOrg = useCallback(async () => {
const full = await authClient.organization.getFullOrganization()
const nextOrg = full?.data ?? null
setOrg(nextOrg)
return nextOrg
}, [])
useEffect(() => {
if (isSessionPending) return
@ -198,6 +209,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setActiveOrg,
clearActiveOrg,
updateOrgMetadata,
refetchActiveOrg,
refetchOrganizations,
}}
>

View file

@ -1,6 +1,6 @@
{
"name": "@supermemory/memory-graph",
"version": "0.2.1",
"version": "0.2.2",
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
"type": "module",
"main": "./src/index.tsx",

View file

@ -310,11 +310,7 @@ export const Legend = memo(function Legend({
const updateNodeCount = nodes.filter(isUpdateMemoryNode).length
const outerStyle: React.CSSProperties = {
position: "absolute",
zIndex: 20,
overflow: "hidden",
bottom: 16,
left: 16,
width: 214,
}

View file

@ -657,11 +657,15 @@ export function MemoryGraph({
justifyContent: "center",
}
const navControlsStyle: React.CSSProperties = {
const bottomLeftStackStyle: React.CSSProperties = {
position: "absolute",
bottom: isCompactViewport ? 148 : 72,
bottom: 16,
left: 16,
zIndex: 15,
zIndex: 20,
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 8,
}
return (
@ -718,29 +722,27 @@ export function MemoryGraph({
/>
)}
<div>
{containerSize.width > 0 && (
<div style={navControlsStyle}>
<NavigationControls
nodes={nodes}
compact={isCompactViewport}
onAutoFit={handleAutoFit}
onCenter={handleCenter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
zoomLevel={zoomDisplay}
colors={colors}
/>
</div>
)}
<Legend
colors={colors}
edges={edges}
hoveredNode={hoveredNode}
isLoading={isLoading}
nodes={nodes}
/>
</div>
{containerSize.width > 0 && (
<div style={bottomLeftStackStyle}>
<NavigationControls
nodes={nodes}
compact={isCompactViewport}
onAutoFit={handleAutoFit}
onCenter={handleCenter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
zoomLevel={zoomDisplay}
colors={colors}
/>
<Legend
colors={colors}
edges={edges}
hoveredNode={hoveredNode}
isLoading={isLoading}
nodes={nodes}
/>
</div>
)}
</div>
</div>
)

View file

@ -193,6 +193,9 @@ async def add_system_prompt(
},
)
if not memories:
return messages
if system_prompt_exists:
logger.debug("Added memories to existing system prompt")
return [

View file

@ -318,6 +318,65 @@ class TestMemoryInjection:
assert "User prefers Python" in system_message["content"]
@pytest.mark.asyncio
async def test_empty_memories_do_not_modify_messages(
self, mock_async_openai_client, mock_openai_response
):
"""No system prompt should be injected when the memory lookup returns nothing.
Previously, ``add_system_prompt`` would still modify messages even when
``memories`` resolved to an empty string: it appended `` \\n `` (whitespace) to
any existing system prompt, or prepended a new ``{"role": "system", "content": ""}``
message when none existed. Both outcomes pollute the conversation context with
meaningless tokens and can confuse the downstream model.
"""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
empty_response = {
"profile": {"static": [], "dynamic": []},
"searchResults": {"results": []},
}
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
mock_search.return_value = Mock()
mock_search.return_value.profile = empty_response["profile"]
mock_search.return_value.search_results = empty_response["searchResults"]
wrapped_client = with_supermemory(
mock_async_openai_client,
OpenAIMiddlewareOptions(
container_tag="user-123", custom_id="test-conv", mode="full"
),
)
# Case 1: no pre-existing system prompt — no system message should be prepended
user_only_messages = [{"role": "user", "content": "Hello"}]
await wrapped_client.chat.completions.create(
model="gpt-4", messages=user_only_messages
)
sent_messages = original_create.call_args[1]["messages"]
assert sent_messages == user_only_messages, (
"An empty-memory response must not prepend a blank system message"
)
original_create.reset_mock()
# Case 2: existing system prompt — it must not be modified
with_system = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
]
await wrapped_client.chat.completions.create(
model="gpt-4", messages=with_system
)
sent_messages = original_create.call_args[1]["messages"]
assert sent_messages[0]["content"] == "You are helpful.", (
"An empty-memory response must not append whitespace to the existing system prompt"
)
class TestMemoryStorage:
"""Test memory storage functionality."""

View file

@ -36,7 +36,7 @@ function DialogOverlay({
return (
<DialogPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-[4px]",
className,
)}
data-slot="dialog-overlay"