Build desktop Nova onboarding flow

This commit is contained in:
Sreeram Sreedhar 2026-06-23 18:20:41 -07:00
parent 26afe03765
commit bd419f7f20
9 changed files with 1098 additions and 228 deletions

View file

@ -18,6 +18,32 @@ In development, the desktop UI and Rust auth commands default to the local API a
`http://localhost:8787`. Set `NEXT_PUBLIC_BACKEND_URL` / `SUPERMEMORY_API_URL`
if you need a different API.
## Manual onboarding verification
Run the API locally on `localhost:8787`, then start the desktop app:
```sh
bun run tauri dev
```
Use this path before shipping auth or onboarding changes:
1. Clear `localStorage["supermemory-desktop-onboarding-v1"]` if you need a
fresh first-run state.
2. Sign in with Google or email from the desktop login page.
3. Confirm the browser callback returns to the desktop app and the first
authenticated route is `/onboarding`, not the web console.
4. In the tools step, rescan and confirm Claude Code, Codex, and Cursor show
detected paths when installed or configured.
5. Preview each tool connection and verify the diff targets the expected config:
`~/.claude.json`, `$CODEX_HOME/config.toml` or `~/.codex/config.toml`, and
`~/.cursor/mcp.json`.
6. Apply one connection and confirm the app writes the MCP server, creates a
`.smbak` backup when the config already existed, and reports the tool as
connected after refresh.
7. Mount or skip the filesystem step, finish onboarding, relaunch the app, and
confirm completed or skipped onboarding does not appear again.
SMFS integration uses the first available binary in this order:
1. `SUPERMEMORY_DESKTOP_SMFS_BIN`

View file

@ -3,10 +3,7 @@
import { useQuery } from "@tanstack/react-query"
import {
ArrowRight,
Bot,
CheckCircle2,
Clock,
Code2,
FileText,
Link2,
Lightbulb,
@ -16,20 +13,11 @@ import {
Search,
SendHorizontal,
Sparkles,
Terminal,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { listDocuments, type DocumentWithMemories } from "@/lib/api"
import type { SearchResult } from "@/lib/search"
import { OPEN_MEMORY_EVENT, type SpotlightMemory } from "@/lib/spotlight"
import {
connectDesktopTool,
detectDesktopTools,
type DesktopToolId,
type DesktopToolPreview,
type DesktopToolStatus,
previewConnectDesktopTool,
} from "@/lib/tools"
import { SearchCommand, useCommandK } from "@/components/search-command"
type MemoryPreview = {
@ -160,9 +148,6 @@ export default function DashboardPage() {
</button>
</div>
</div>
<ToolOnboardingPanel />
<section className="mx-auto w-full max-w-2xl space-y-2">
<div className="flex items-center justify-between px-1">
<p className="font-medium text-[10px] text-fg-faint uppercase tracking-[0.12em]">
@ -221,180 +206,6 @@ export default function DashboardPage() {
)
}
function ToolOnboardingPanel() {
const [tools, setTools] = useState<DesktopToolStatus[]>([])
const [toolsError, setToolsError] = useState<string | null>(null)
const [toolsBusy, setToolsBusy] = useState<string | null>(null)
const [toolPreview, setToolPreview] = useState<DesktopToolPreview | null>(
null,
)
const [loading, setLoading] = useState(true)
const connectedCount = tools.filter((tool) => tool.connected).length
const detectedCount = tools.filter((tool) => tool.detected).length
const refreshTools = useCallback(async () => {
setToolsError(null)
setLoading(true)
try {
setTools(await detectDesktopTools())
} catch (err) {
setToolsError(formatUnknownError(err, "Could not detect local tools"))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refreshTools()
}, [refreshTools])
async function previewConnect(toolId: DesktopToolId) {
setToolsError(null)
setToolsBusy(toolId)
try {
setToolPreview(await previewConnectDesktopTool(toolId))
} catch (err) {
setToolsError(formatUnknownError(err, "Could not prepare setup"))
} finally {
setToolsBusy(null)
}
}
async function applyPreview() {
if (!toolPreview) return
setToolsError(null)
setToolsBusy(toolPreview.tool.id)
try {
await connectDesktopTool(toolPreview.tool.id)
setToolPreview(null)
await refreshTools()
} catch (err) {
setToolsError(formatUnknownError(err, "Could not connect tool"))
} finally {
setToolsBusy(null)
}
}
return (
<section className="mx-auto w-full max-w-3xl rounded-2xl border border-white/[0.06] bg-[#0B1018]/56 p-4 shadow-[0_18px_70px_rgba(0,0,0,0.22)] backdrop-blur-xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="flex items-center gap-2 font-medium text-sm text-white">
<Bot className="size-4 text-[#8BC6FF]" />
Connect AI tools
</p>
<p className="text-fg-subtle text-xs">
Detected {detectedCount} local tools. {connectedCount} connected.
</p>
</div>
<button
type="button"
onClick={refreshTools}
disabled={loading || toolsBusy !== null}
className="inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-white/[0.08] px-3 text-[12px] text-fg-muted transition-colors hover:bg-white/[0.05] hover:text-white disabled:opacity-60"
>
{loading ? "Scanning..." : "Rescan"}
</button>
</div>
<div className="mt-4 grid gap-2 md:grid-cols-3">
{tools.map((tool) => {
const Icon = toolIcon(tool.id)
return (
<div
key={tool.id}
className="flex min-h-36 flex-col justify-between rounded-xl border border-white/[0.06] bg-black/15 p-3"
>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#00173C] text-[#8BC6FF] ring-1 ring-[#2261CA33]">
<Icon className="size-4" />
</span>
<div className="min-w-0">
<p className="truncate font-medium text-sm text-white">
{tool.name}
</p>
<p className="text-[11px] text-fg-faint">
{toolStatusLabel(tool)}
</p>
</div>
</div>
{tool.connected ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-300" />
) : null}
</div>
<p className="line-clamp-2 text-[11px] text-fg-subtle">
{tool.detail}
</p>
</div>
<button
type="button"
onClick={() => previewConnect(tool.id)}
disabled={toolsBusy !== null || tool.connected}
className="mt-4 inline-flex h-8 items-center justify-center rounded-lg bg-white px-3 font-medium text-[#0B1018] text-[12px] transition-transform hover:scale-[1.02] disabled:cursor-default disabled:bg-white/[0.08] disabled:text-fg-faint disabled:hover:scale-100"
>
{tool.connected
? "Connected"
: toolsBusy === tool.id
? "Preparing..."
: tool.detected
? "Connect"
: "Set up"}
</button>
</div>
)
})}
</div>
{toolsError ? (
<p className="mt-3 text-destructive text-xs">{toolsError}</p>
) : null}
{toolPreview ? (
<div className="mt-4 rounded-xl border border-[#2261CA33] bg-[#00173C]/42 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-sm text-white">
Connect {toolPreview.tool.name}
</p>
<p
className="truncate font-mono text-[11px] text-fg-subtle"
title={toolPreview.configPath}
>
{toolPreview.configPath}
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => setToolPreview(null)}
disabled={toolsBusy !== null}
className="h-8 rounded-lg border border-white/[0.08] px-3 text-[12px] text-fg-muted transition-colors hover:bg-white/[0.05] hover:text-white disabled:opacity-60"
>
Cancel
</button>
<button
type="button"
onClick={applyPreview}
disabled={toolsBusy !== null}
className="h-8 rounded-lg bg-white px-3 font-medium text-[#0B1018] text-[12px] disabled:opacity-60"
>
{toolsBusy ? "Applying..." : "Apply"}
</button>
</div>
</div>
<pre className="mt-3 max-h-52 overflow-auto rounded-lg border border-white/[0.06] bg-black/25 p-3 whitespace-pre-wrap text-[11px] text-fg-muted">
{toolPreview.diff}
</pre>
</div>
) : null}
</section>
)
}
function ComposerAction({
icon: Icon,
label,
@ -416,31 +227,6 @@ function ComposerAction({
)
}
function toolIcon(toolId: DesktopToolId) {
switch (toolId) {
case "claude-code":
return Bot
case "codex":
return Terminal
case "cursor":
return Code2
}
}
function toolStatusLabel(tool: DesktopToolStatus) {
if (tool.connected) return "Connected"
if (tool.detected) return "Detected"
return "Not detected"
}
function formatUnknownError(error: unknown, fallback: string) {
return error instanceof Error
? error.message
: typeof error === "string"
? error
: fallback
}
function RecentMemoryRow({
document,
active,

View file

@ -32,6 +32,7 @@ import {
storeToken,
verifyMagicLinkToken,
} from "@/lib/auth"
import { postAuthRedirectPath } from "@/lib/onboarding"
export default function LoginPage() {
const router = useRouter()
@ -55,7 +56,7 @@ export default function LoginPage() {
setIsSubmitting(false)
try {
await getSession()
router.replace("/")
router.replace(postAuthRedirectPath())
} catch (err) {
setError(formatError(err, "Could not validate browser sign-in"))
}
@ -92,7 +93,7 @@ export default function LoginPage() {
try {
await storeToken(token)
router.replace("/")
router.replace(postAuthRedirectPath())
} catch (err) {
setError(formatError(err, "Could not sign in"))
} finally {

View file

@ -0,0 +1,809 @@
"use client"
import { LogoFull } from "@ui/assets/Logo"
import { cn } from "@lib/utils"
import {
ArrowRight,
Check,
CheckCircle2,
ChevronLeft,
FolderOpen,
Loader2,
RefreshCcw,
X,
} from "lucide-react"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState, type ReactNode } from "react"
import { Titlebar } from "@/components/titlebar"
import { getSession, type AuthSession } from "@/lib/auth"
import {
completeDesktopOnboarding,
getDesktopOnboardingStatus,
skipDesktopOnboarding,
type DesktopOnboardingStep,
updateDesktopOnboardingStatus,
} from "@/lib/onboarding"
import {
getDefaultSmfsContainerTag,
getSmfsState,
mountSmfs,
type SmfsStatus,
} from "@/lib/smfs"
import {
sortDesktopToolCards,
toDesktopToolCard,
type DesktopToolCard,
} from "@/lib/tool-catalog"
import {
connectDesktopTool,
detectDesktopTools,
type DesktopToolId,
type DesktopToolPreview,
previewConnectDesktopTool,
} from "@/lib/tools"
const STEPS: DesktopOnboardingStep[] = [
"welcome",
"tools",
"filesystem",
"done",
]
const STEP_LABELS: Record<DesktopOnboardingStep, string> = {
welcome: "Welcome",
tools: "Tools",
filesystem: "Files",
done: "Done",
}
export default function DesktopOnboardingPage() {
const router = useRouter()
const [session, setSession] = useState<AuthSession | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [step, setStep] = useState<DesktopOnboardingStep>("welcome")
const [connectedTools, setConnectedTools] = useState<DesktopToolId[]>([])
useEffect(() => {
let cancelled = false
getSession()
.then((nextSession) => {
if (cancelled) return
const status = getDesktopOnboardingStatus()
setSession(nextSession)
setStep(status.lastStep === "done" ? "welcome" : status.lastStep)
setConnectedTools(status.connectedTools)
setAuthChecked(true)
})
.catch(() => {
if (cancelled) return
router.replace("/login")
})
return () => {
cancelled = true
}
}, [router])
const goToStep = useCallback((nextStep: DesktopOnboardingStep) => {
setStep(nextStep)
updateDesktopOnboardingStatus({ lastStep: nextStep })
}, [])
const complete = useCallback(
(nextConnectedTools = connectedTools) => {
completeDesktopOnboarding(nextConnectedTools)
router.replace("/")
},
[connectedTools, router],
)
const skip = useCallback(() => {
skipDesktopOnboarding(step)
router.replace("/")
}, [router, step])
if (!authChecked || !session) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#05080D] text-[#737B86] text-sm">
<Loader2 className="mr-2 size-4 animate-spin" />
Loading onboarding...
</div>
)
}
return (
<div className="flex h-screen flex-col overflow-hidden bg-[#05080D] text-[#FAFAFA]">
<Titlebar title="Supermemory setup" />
<div className="relative min-h-0 flex-1 overflow-auto">
<NovaBackground />
<header className="relative z-10 grid grid-cols-[1fr_auto_1fr] items-center gap-4 px-6 py-4 md:px-10">
<LogoFull className="h-5 justify-self-start text-[#FAFAFA] md:h-6" />
<StepIndicator step={step} />
<button
type="button"
onClick={skip}
className="justify-self-end rounded-full px-3 py-1.5 text-[#737373] text-xs transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA]"
>
Skip
</button>
</header>
<main className="relative z-10 mx-auto flex min-h-[calc(100vh-9rem)] w-full max-w-6xl items-center px-4 py-8 md:px-10">
{step === "welcome" ? (
<WelcomeStep
session={session}
onContinue={() => goToStep("tools")}
onSkip={skip}
/>
) : null}
{step === "tools" ? (
<ToolsStep
connectedTools={connectedTools}
onConnectedToolsChange={setConnectedTools}
onBack={() => goToStep("welcome")}
onContinue={() => goToStep("filesystem")}
/>
) : null}
{step === "filesystem" ? (
<FilesystemStep
onBack={() => goToStep("tools")}
onContinue={() => goToStep("done")}
/>
) : null}
{step === "done" ? (
<DoneStep
connectedCount={connectedTools.length}
onBack={() => goToStep("filesystem")}
onFinish={() => complete()}
/>
) : null}
</main>
</div>
</div>
)
}
function WelcomeStep({
session,
onContinue,
onSkip,
}: {
session: AuthSession
onContinue: () => void
onSkip: () => void
}) {
return (
<section className="mx-auto grid w-full max-w-5xl gap-8 md:grid-cols-[minmax(0,1fr)_360px] md:items-center">
<div className="space-y-5">
<p className="inline-flex items-center gap-2 rounded-full border border-[#2261CA33] bg-[#00173C]/80 px-3 py-1 font-medium text-[#8BC6FF] text-[11px]">
<CheckCircle2 className="size-3.5" />
Authenticated
</p>
<div className="space-y-3">
<h1 className="max-w-3xl text-balance font-medium text-4xl leading-tight tracking-tight md:text-5xl">
Set up Supermemory on this Mac.
</h1>
<p className="max-w-2xl text-[#A1A1AA] text-sm leading-6 md:text-base">
Connect local AI tools, mount your memory folder, and make Nova
available everywhere you work.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={onContinue}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-white px-4 font-medium text-[#05080D] text-sm transition-transform hover:scale-[1.01]"
>
Start setup
<ArrowRight className="size-4" />
</button>
<button
type="button"
onClick={onSkip}
className="inline-flex h-10 items-center justify-center rounded-xl border border-white/[0.08] px-4 text-[#A1A1AA] text-sm transition-colors hover:bg-white/[0.05] hover:text-white"
>
Skip for now
</button>
</div>
</div>
<div className="rounded-2xl border border-white/[0.07] bg-[#0B1018]/70 p-5 shadow-[0_24px_80px_rgba(0,0,0,0.35)]">
<p className="font-medium text-sm text-white">Signed in as</p>
<p className="mt-1 truncate text-[#A1A1AA] text-sm">
{session.email ?? session.userId}
</p>
<div className="mt-5 grid gap-3">
<SetupBenefit label="Detect Claude Code, Codex, and Cursor" />
<SetupBenefit label="Preview every config change before writing" />
<SetupBenefit label="Create backups before touching local files" />
<SetupBenefit label="Mount your Supermemory filesystem folder" />
</div>
</div>
</section>
)
}
function ToolsStep({
connectedTools,
onConnectedToolsChange,
onBack,
onContinue,
}: {
connectedTools: DesktopToolId[]
onConnectedToolsChange: (tools: DesktopToolId[]) => void
onBack: () => void
onContinue: () => void
}) {
const [tools, setTools] = useState<DesktopToolCard[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busyTool, setBusyTool] = useState<DesktopToolId | null>(null)
const [preview, setPreview] = useState<DesktopToolPreview | null>(null)
const detectedCount = tools.filter((tool) => tool.detected).length
const connectedCount = tools.filter((tool) => tool.connected).length
const refreshTools = useCallback(async () => {
setError(null)
setLoading(true)
try {
const statuses = await detectDesktopTools()
const cards = sortDesktopToolCards(statuses.map(toDesktopToolCard))
setTools(cards)
const nextConnected = cards
.filter((tool) => tool.connected)
.map((tool) => tool.id)
onConnectedToolsChange(nextConnected)
updateDesktopOnboardingStatus({ connectedTools: nextConnected })
} catch (err) {
setError(formatUnknownError(err, "Could not scan local tools"))
} finally {
setLoading(false)
}
}, [onConnectedToolsChange])
useEffect(() => {
refreshTools()
}, [refreshTools])
async function previewTool(toolId: DesktopToolId) {
setError(null)
setBusyTool(toolId)
try {
setPreview(await previewConnectDesktopTool(toolId))
} catch (err) {
setError(formatUnknownError(err, "Could not preview setup"))
} finally {
setBusyTool(null)
}
}
async function applyPreview() {
if (!preview) return
setError(null)
setBusyTool(preview.tool.id)
try {
await connectDesktopTool(preview.tool.id)
setPreview(null)
await refreshTools()
} catch (err) {
setError(formatUnknownError(err, "Could not connect tool"))
} finally {
setBusyTool(null)
}
}
return (
<section className="mx-auto w-full max-w-6xl space-y-6">
<StepHeader
eyebrow="Local tools"
title="Connect your AI tools"
description="Supermemory scans this Mac and connects the clients you use. Nothing is written until you approve the diff."
meta={`${detectedCount} detected · ${connectedCount} connected`}
action={
<button
type="button"
onClick={refreshTools}
disabled={loading || busyTool !== null}
className="inline-flex h-9 items-center justify-center gap-2 rounded-xl border border-white/[0.08] px-3 text-[#A1A1AA] text-sm transition-colors hover:bg-white/[0.05] hover:text-white disabled:opacity-60"
>
<RefreshCcw className={cn("size-4", loading && "animate-spin")} />
Rescan
</button>
}
/>
<div className="grid gap-3 md:grid-cols-3">
{tools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
busy={busyTool === tool.id}
onPreview={() => previewTool(tool.id)}
/>
))}
</div>
{error ? <p className="text-red-300 text-sm">{error}</p> : null}
{preview ? (
<DiffPreview
preview={preview}
busy={busyTool !== null}
onCancel={() => setPreview(null)}
onApply={applyPreview}
/>
) : null}
<StepActions
onBack={onBack}
onContinue={onContinue}
continueLabel={connectedTools.length > 0 ? "Continue" : "Skip tools"}
/>
</section>
)
}
function FilesystemStep({
onBack,
onContinue,
}: {
onBack: () => void
onContinue: () => void
}) {
const [tag, setTag] = useState("sm_fs_desktop")
const [status, setStatus] = useState<SmfsStatus | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const [defaultTag, statuses] = await Promise.all([
getDefaultSmfsContainerTag(),
getSmfsState(),
])
setTag(defaultTag)
setStatus(statuses[0] ?? null)
} catch (err) {
setError(formatUnknownError(err, "Could not load filesystem status"))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
async function mount() {
setError(null)
setBusy(true)
try {
setStatus(await mountSmfs(tag))
} catch (err) {
setError(formatUnknownError(err, "Could not mount memory folder"))
} finally {
setBusy(false)
}
}
const mounted = status?.state === "mounted" || status?.state === "external"
return (
<section className="mx-auto w-full max-w-4xl space-y-6">
<StepHeader
eyebrow="Memory folder"
title="Mount your Supermemory filesystem"
description="SMFS turns a Supermemory space into a local folder, so your context is visible to editors and command-line tools."
meta={status ? formatSmfsState(status.state) : "Not mounted"}
/>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_320px]">
<div className="rounded-2xl border border-white/[0.07] bg-[#0B1018]/70 p-5">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-xl bg-[#00173C] text-[#8BC6FF] ring-1 ring-[#2261CA33]">
<FolderOpen className="size-5" />
</span>
<div className="min-w-0">
<p className="font-medium text-white">Filesystem space</p>
<p className="truncate font-mono text-[#A1A1AA] text-xs">
{status?.tag ?? tag}
</p>
</div>
</div>
<div className="mt-5 grid gap-3 text-sm">
<InfoRow label="State" value={formatSmfsState(status?.state)} />
<InfoRow label="Mount path" value={status?.mountPath || "..."} />
<InfoRow label="Binary" value={status?.binaryPath || "..."} />
</div>
{error ? <p className="mt-4 text-red-300 text-sm">{error}</p> : null}
</div>
<div className="rounded-2xl border border-white/[0.07] bg-[#0B1018]/70 p-5">
<p className="font-medium text-sm text-white">Recommended</p>
<p className="mt-2 text-[#A1A1AA] text-sm leading-6">
Mount now if you want a local memory folder. You can also do this
later from Settings.
</p>
<button
type="button"
onClick={mounted ? onContinue : mount}
disabled={loading || busy}
className="mt-5 inline-flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-white px-4 font-medium text-[#05080D] text-sm disabled:opacity-60"
>
{busy || loading ? (
<Loader2 className="size-4 animate-spin" />
) : mounted ? (
<Check className="size-4" />
) : null}
{mounted ? "Mounted" : "Mount folder"}
</button>
</div>
</div>
<StepActions
onBack={onBack}
onContinue={onContinue}
continueLabel={mounted ? "Continue" : "Skip for now"}
/>
</section>
)
}
function DoneStep({
connectedCount,
onBack,
onFinish,
}: {
connectedCount: number
onBack: () => void
onFinish: () => void
}) {
return (
<section className="mx-auto w-full max-w-3xl text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-[#00173C] text-[#8BC6FF] ring-1 ring-[#2261CA33]">
<CheckCircle2 className="size-7" />
</div>
<h1 className="mt-6 text-balance font-medium text-4xl tracking-tight">
Desktop setup is ready.
</h1>
<p className="mx-auto mt-3 max-w-xl text-[#A1A1AA] text-sm leading-6">
{connectedCount > 0
? `${connectedCount} tool${connectedCount === 1 ? "" : "s"} connected. You can add more from Settings anytime.`
: "No tools connected yet. You can come back from Settings whenever you are ready."}
</p>
<div className="mt-8 flex justify-center gap-3">
<button
type="button"
onClick={onBack}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-white/[0.08] px-4 text-[#A1A1AA] text-sm transition-colors hover:bg-white/[0.05] hover:text-white"
>
<ChevronLeft className="size-4" />
Back
</button>
<button
type="button"
onClick={onFinish}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-white px-4 font-medium text-[#05080D] text-sm"
>
Open Supermemory
<ArrowRight className="size-4" />
</button>
</div>
</section>
)
}
function ToolCard({
tool,
busy,
onPreview,
}: {
tool: DesktopToolCard
busy: boolean
onPreview: () => void
}) {
const Icon = tool.icon
return (
<div className="flex min-h-[280px] flex-col rounded-2xl border border-white/[0.07] bg-[#0B1018]/70 p-4 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.45)]">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[#00173C] text-[#8BC6FF] ring-1 ring-[#2261CA33]">
<Icon className="size-5" />
</span>
<div className="min-w-0">
<p className="truncate font-medium text-white">{tool.name}</p>
<p className="text-[#737373] text-xs">{toolStatusLabel(tool)}</p>
</div>
</div>
{tool.connected ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-400/10 px-2 py-1 text-[11px] text-emerald-300">
<span className="size-1.5 rounded-full bg-emerald-300" />
Active
</span>
) : null}
</div>
<p className="mt-4 line-clamp-2 text-[#A1A1AA] text-sm leading-5">
{tool.tagline}
</p>
<div className="mt-4 grid gap-2 text-xs">
<InfoRow label="Config" value={tool.configPath} />
<InfoRow
label="Detected"
value={
tool.detectedPath ?? (tool.detected ? "Detected" : "Not found")
}
/>
{tool.version ? <InfoRow label="Version" value={tool.version} /> : null}
</div>
<p className="mt-4 text-[#737373] text-xs leading-5">
{tool.restartHint}
</p>
<button
type="button"
onClick={onPreview}
disabled={busy || tool.connected}
className="mt-auto inline-flex h-9 items-center justify-center rounded-xl bg-white px-3 font-medium text-[#05080D] text-sm disabled:cursor-default disabled:bg-white/[0.08] disabled:text-[#737373]"
>
{tool.connected
? "Connected"
: busy
? "Preparing..."
: tool.primaryAction === "connect"
? "Connect"
: "Set up"}
</button>
</div>
)
}
function DiffPreview({
preview,
busy,
onCancel,
onApply,
}: {
preview: DesktopToolPreview
busy: boolean
onCancel: () => void
onApply: () => void
}) {
return (
<div className="rounded-2xl border border-[#2261CA33] bg-[#00173C]/42 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-white">Review config change</p>
<p
className="mt-1 truncate font-mono text-[#A1A1AA] text-xs"
title={preview.configPath}
>
{preview.configPath}
</p>
<p className="mt-1 text-[#737373] text-xs">
Backup: {preview.backupPath ?? "created only if the file exists"}
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="inline-flex size-9 items-center justify-center rounded-xl border border-white/[0.08] text-[#A1A1AA] hover:bg-white/[0.05] hover:text-white disabled:opacity-60"
aria-label="Cancel"
>
<X className="size-4" />
</button>
<button
type="button"
onClick={onApply}
disabled={busy}
className="inline-flex h-9 items-center justify-center gap-2 rounded-xl bg-white px-4 font-medium text-[#05080D] text-sm disabled:opacity-60"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
Apply
</button>
</div>
</div>
<pre className="mt-4 max-h-64 overflow-auto rounded-xl border border-white/[0.06] bg-black/25 p-3 whitespace-pre-wrap text-[#C8D0DA] text-xs">
{preview.diff}
</pre>
</div>
)
}
function StepHeader({
eyebrow,
title,
description,
meta,
action,
}: {
eyebrow: string
title: string
description: string
meta?: string
action?: ReactNode
}) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-[#8BC6FF] text-[11px] uppercase tracking-[0.14em]">
{eyebrow}
</p>
<h1 className="mt-2 text-balance font-medium text-3xl tracking-tight md:text-4xl">
{title}
</h1>
<p className="mt-2 max-w-2xl text-[#A1A1AA] text-sm leading-6">
{description}
</p>
{meta ? <p className="mt-2 text-[#737373] text-xs">{meta}</p> : null}
</div>
{action}
</div>
)
}
function StepActions({
onBack,
onContinue,
continueLabel,
}: {
onBack: () => void
onContinue: () => void
continueLabel: string
}) {
return (
<div className="flex justify-between gap-3">
<button
type="button"
onClick={onBack}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-white/[0.08] px-4 text-[#A1A1AA] text-sm transition-colors hover:bg-white/[0.05] hover:text-white"
>
<ChevronLeft className="size-4" />
Back
</button>
<button
type="button"
onClick={onContinue}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-white px-4 font-medium text-[#05080D] text-sm"
>
{continueLabel}
<ArrowRight className="size-4" />
</button>
</div>
)
}
function StepIndicator({ step }: { step: DesktopOnboardingStep }) {
const currentIdx = STEPS.indexOf(step)
return (
<div className="hidden items-start sm:flex">
{STEPS.map((item, index) => {
const isDone = index < currentIdx
const isCurrent = index === currentIdx
const isLast = index === STEPS.length - 1
return (
<div key={item} className="flex items-start">
<div className="flex min-w-[64px] flex-col items-center gap-1.5">
<span
className={cn(
"flex size-3 items-center justify-center rounded-full",
isCurrent
? "border-2 border-[#4BA0FA]"
: isDone
? "bg-[#4BA0FA]"
: "border border-[#2E353D]",
)}
>
{isCurrent ? (
<span className="size-1.5 rounded-full bg-[#4BA0FA]" />
) : null}
</span>
<span
className={cn(
"font-medium text-[11px] leading-none",
isCurrent
? "text-[#FAFAFA]"
: isDone
? "text-[#737373]"
: "text-[#525D6E]",
)}
>
{STEP_LABELS[item]}
</span>
</div>
{!isLast ? (
<span
className={cn(
"mt-[5px] h-px min-w-7",
isDone ? "bg-[#4BA0FA]/60" : "bg-[#2E353D]",
)}
/>
) : null}
</div>
)
})}
</div>
)
}
function SetupBenefit({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 text-[#C8D0DA] text-sm">
<Check className="size-4 text-[#8BC6FF]" />
{label}
</div>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<p className="text-[#737373] text-[11px] uppercase tracking-[0.08em]">
{label}
</p>
<p className="mt-0.5 truncate text-[#C8D0DA] text-xs" title={value}>
{value}
</p>
</div>
)
}
function NovaBackground() {
return (
<>
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
backgroundSize: "28px 28px",
maskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
WebkitMaskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
}}
/>
</>
)
}
function toolStatusLabel(tool: DesktopToolCard) {
if (tool.connected) return "Connected"
if (tool.detected) return "Detected"
return "Not detected"
}
function formatSmfsState(state?: SmfsStatus["state"]) {
switch (state) {
case "degraded":
return "Mounted with warnings"
case "external":
return "External mount"
case "error":
return "Error"
case "missing-binary":
return "Missing binary"
case "mounted":
return "Mounted"
case "unmounted":
return "Unmounted"
default:
return "Loading"
}
}
function formatUnknownError(error: unknown, fallback: string) {
return error instanceof Error
? error.message
: typeof error === "string"
? error
: fallback
}

View file

@ -3,6 +3,7 @@
import { useRouter } from "next/navigation"
import { type ReactNode, useEffect, useState } from "react"
import { getSession } from "@/lib/auth"
import { shouldShowDesktopOnboarding } from "@/lib/onboarding"
type AuthStatus = "loading" | "authenticated" | "unauthenticated"
@ -35,6 +36,11 @@ export function AuthGuard({ children }: { children: ReactNode }) {
useEffect(() => {
if (status === "unauthenticated") {
router.replace("/login")
return
}
if (status === "authenticated" && shouldShowDesktopOnboarding()) {
router.replace("/onboarding")
}
}, [status, router])

View file

@ -0,0 +1,103 @@
"use client"
import type { DesktopToolId } from "@/lib/tools"
export type DesktopOnboardingStep = "welcome" | "tools" | "filesystem" | "done"
export type DesktopOnboardingStatus = {
version: "v1"
completed: boolean
skipped: boolean
completedAt?: string
skippedAt?: string
lastStep: DesktopOnboardingStep
connectedTools: DesktopToolId[]
}
const STORAGE_KEY = "supermemory-desktop-onboarding-v1"
const DEFAULT_STATUS: DesktopOnboardingStatus = {
version: "v1",
completed: false,
skipped: false,
lastStep: "welcome",
connectedTools: [],
}
function storageAvailable() {
return (
typeof window !== "undefined" && typeof window.localStorage !== "undefined"
)
}
export function getDesktopOnboardingStatus(): DesktopOnboardingStatus {
if (!storageAvailable()) return DEFAULT_STATUS
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_STATUS
const parsed = JSON.parse(raw) as Partial<DesktopOnboardingStatus>
if (parsed.version !== "v1") return DEFAULT_STATUS
return {
...DEFAULT_STATUS,
...parsed,
connectedTools: Array.isArray(parsed.connectedTools)
? parsed.connectedTools
: [],
}
} catch {
return DEFAULT_STATUS
}
}
export function saveDesktopOnboardingStatus(
status: DesktopOnboardingStatus,
): void {
if (!storageAvailable()) return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(status))
} catch {}
}
export function updateDesktopOnboardingStatus(
patch: Partial<DesktopOnboardingStatus>,
): DesktopOnboardingStatus {
const next: DesktopOnboardingStatus = {
...getDesktopOnboardingStatus(),
...patch,
version: "v1",
}
saveDesktopOnboardingStatus(next)
return next
}
export function shouldShowDesktopOnboarding() {
const status = getDesktopOnboardingStatus()
return !status.completed && !status.skipped
}
export function postAuthRedirectPath() {
return shouldShowDesktopOnboarding() ? "/onboarding" : "/"
}
export function completeDesktopOnboarding(
connectedTools: DesktopToolId[] = [],
) {
return updateDesktopOnboardingStatus({
completed: true,
skipped: false,
completedAt: new Date().toISOString(),
lastStep: "done",
connectedTools,
})
}
export function skipDesktopOnboarding(lastStep: DesktopOnboardingStep) {
return updateDesktopOnboardingStatus({
completed: false,
skipped: true,
skippedAt: new Date().toISOString(),
lastStep,
})
}

View file

@ -0,0 +1,82 @@
"use client"
import { Bot, Code2, Terminal, type LucideIcon } from "lucide-react"
import type { DesktopToolId, DesktopToolStatus } from "@/lib/tools"
export type DesktopToolCatalogEntry = {
id: DesktopToolId
catalogId: "claude_code" | "codex" | "cursor"
name: string
tagline: string
description: string
docsUrl: string
icon: LucideIcon
restartHint: string
}
export type DesktopToolCard = DesktopToolCatalogEntry &
DesktopToolStatus & {
primaryAction: "connect" | "set-up" | "connected"
}
export const DESKTOP_TOOL_CATALOG: Record<
DesktopToolId,
DesktopToolCatalogEntry
> = {
"claude-code": {
id: "claude-code",
catalogId: "claude_code",
name: "Claude Code",
tagline: "Remembers your conventions, decisions, and project context",
description:
"Connect the Supermemory MCP server so Claude Code can search and save memories while you work.",
docsUrl: "https://supermemory.ai/docs/integrations/claude-code",
icon: Bot,
restartHint: "Restart Claude Code after changing MCP config.",
},
codex: {
id: "codex",
catalogId: "codex",
name: "Codex",
tagline: "Persistent memory for the Codex CLI",
description:
"Add Supermemory to Codex MCP servers so coding sessions can recall durable context.",
docsUrl: "https://supermemory.ai/docs/integrations/codex",
icon: Terminal,
restartHint: "Restart Codex after changing ~/.codex/config.toml.",
},
cursor: {
id: "cursor",
catalogId: "cursor",
name: "Cursor",
tagline: "Persistent memory, session hooks, and MCP tools inside Cursor",
description:
"Write the Supermemory MCP server into Cursor's MCP config for one-click memory access.",
docsUrl: "https://supermemory.ai/docs/supermemory-mcp/setup",
icon: Code2,
restartHint: "Restart Cursor so MCP changes are loaded.",
},
}
export function toDesktopToolCard(status: DesktopToolStatus): DesktopToolCard {
const catalog = DESKTOP_TOOL_CATALOG[status.id]
return {
...catalog,
...status,
name: catalog.name,
primaryAction: status.connected
? "connected"
: status.detected
? "connect"
: "set-up",
}
}
export function sortDesktopToolCards(cards: DesktopToolCard[]) {
return [...cards].sort((a, b) => {
const aRank = a.connected ? 0 : a.detected ? 1 : 2
const bRank = b.connected ? 0 : b.detected ? 1 : 2
if (aRank !== bRank) return aRank - bRank
return a.name.localeCompare(b.name)
})
}

View file

@ -11,6 +11,8 @@ export type DesktopToolStatus = {
connected: boolean
configPath: string
configExists: boolean
detectedPath?: string | null
version?: string | null
installHint: string
detail: string
}

View file

@ -27,6 +27,8 @@ pub struct ToolStatus {
connected: bool,
config_path: String,
config_exists: bool,
detected_path: Option<String>,
version: Option<String>,
install_hint: &'static str,
detail: String,
}
@ -127,7 +129,9 @@ fn status_for_tool(tool: ToolId) -> Result<ToolStatus, String> {
let config_path = config_path(tool)?;
let config_exists = config_path.exists();
let connected = is_connected(tool, &config_path)?;
let detected = is_detected(tool);
let detected_path = detected_path(tool);
let detected = detected_path.is_some();
let version = detected_path.as_deref().and_then(version_for_tool);
Ok(ToolStatus {
id: tool.id(),
@ -136,8 +140,16 @@ fn status_for_tool(tool: ToolId) -> Result<ToolStatus, String> {
connected,
config_path: path_to_string(&config_path),
config_exists,
detected_path: detected_path.as_deref().map(path_to_string),
version,
install_hint: tool.install_hint(),
detail: status_detail(tool, detected, connected, config_exists),
detail: status_detail(
tool,
detected,
connected,
config_exists,
detected_path.as_deref(),
),
})
}
@ -338,19 +350,59 @@ fn make_diff(before: &str, after: &str) -> String {
diff
}
fn is_detected(tool: ToolId) -> bool {
fn detected_path(tool: ToolId) -> Option<PathBuf> {
match tool {
ToolId::ClaudeCode => find_binary("claude").is_some() || home_path(".claude").exists(),
ToolId::Codex => find_binary("codex").is_some() || home_path(".codex").exists(),
ToolId::Cursor => {
PathBuf::from("/Applications/Cursor.app").exists()
|| find_binary("cursor").is_some()
|| home_path(".cursor").exists()
}
ToolId::ClaudeCode => find_binary("claude")
.or_else(|| existing_path(home_path(".claude.json")))
.or_else(|| existing_path(home_path(".claude"))),
ToolId::Codex => find_binary("codex")
.or_else(|| {
env::var("CODEX_HOME")
.ok()
.map(PathBuf::from)
.and_then(existing_path)
})
.or_else(|| existing_path(home_path(".codex"))),
ToolId::Cursor => existing_path(PathBuf::from("/Applications/Cursor.app"))
.or_else(|| find_binary("cursor"))
.or_else(|| existing_path(home_path(".cursor"))),
}
}
fn status_detail(tool: ToolId, detected: bool, connected: bool, config_exists: bool) -> String {
fn existing_path(path: PathBuf) -> Option<PathBuf> {
path.exists().then_some(path)
}
fn version_for_tool(detected_path: &Path) -> Option<String> {
if !detected_path.is_file() {
return None;
}
let output = Command::new(detected_path).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let raw = if output.stdout.is_empty() {
String::from_utf8_lossy(&output.stderr)
} else {
String::from_utf8_lossy(&output.stdout)
};
let first_line = raw.lines().next()?.trim();
if first_line.is_empty() {
return None;
}
Some(first_line.to_string())
}
fn status_detail(
tool: ToolId,
detected: bool,
connected: bool,
config_exists: bool,
detected_path: Option<&Path>,
) -> String {
if connected {
return "Connected to the Supermemory MCP server.".to_string();
}
@ -360,6 +412,9 @@ fn status_detail(tool: ToolId, detected: bool, connected: bool, config_exists: b
}
if detected {
if let Some(path) = detected_path {
return format!("Detected at {}; ready to connect.", path_to_string(path));
}
return "Detected locally; ready to connect.".to_string();
}