mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Build desktop Nova onboarding flow
This commit is contained in:
parent
26afe03765
commit
bd419f7f20
9 changed files with 1098 additions and 228 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
809
apps/desktop/app/onboarding/page.tsx
Normal file
809
apps/desktop/app/onboarding/page.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
103
apps/desktop/lib/onboarding.ts
Normal file
103
apps/desktop/lib/onboarding.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
82
apps/desktop/lib/tool-catalog.ts
Normal file
82
apps/desktop/lib/tool-catalog.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ export type DesktopToolStatus = {
|
|||
connected: boolean
|
||||
configPath: string
|
||||
configExists: boolean
|
||||
detectedPath?: string | null
|
||||
version?: string | null
|
||||
installHint: string
|
||||
detail: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue