feat: add first-launch Claude Code onboarding

This commit is contained in:
lucaronin
2026-04-12 19:56:10 +02:00
parent a13e36a504
commit fd9de04275
8 changed files with 281 additions and 1 deletions

View File

@@ -527,6 +527,11 @@ Per-vault settings stored locally and scoped by vault path:
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
`useClaudeCodeOnboarding(enabled)` adds a separate first-launch agent step:
- Reads a local dismissal flag for the Claude Code prompt
- Only shows after vault onboarding has already resolved to a ready state
- Persists dismissal locally once the user continues
### Remote Git Operations
Tolaria delegates remote auth to the user's system git setup:

View File

@@ -432,6 +432,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
- **Open an existing folder** → system file picker
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
Once a vault is ready, `useClaudeCodeOnboarding` can show a one-time `ClaudeCodeOnboardingPrompt`. That prompt reuses `useClaudeCodeStatus` so first launch surfaces whether the `claude` CLI is installed, offers the install link when it is missing, and stores local dismissal so the prompt does not repeat on every launch.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public starter repo URL and delegates the actual clone to the git backend.
### Remote Clone & Auth Model

View File

@@ -123,6 +123,8 @@ vi.mock('@blocknote/mantine/style.css', () => ({}))
import App from './App'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -133,6 +135,7 @@ describe('App', () => {
localStorage.removeItem('laputa-view-mode')
localStorage.removeItem('tolaria_welcome_dismissed')
localStorage.removeItem('laputa_welcome_dismissed')
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
})
it('renders the four-panel layout', async () => {

View File

@@ -16,10 +16,12 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { ClaudeCodeOnboardingPrompt } from './components/ClaudeCodeOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useClaudeCodeOnboarding } from './hooks/useClaudeCodeOnboarding'
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
@@ -124,6 +126,8 @@ function App() {
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
const claudeCodeOnboarding = useClaudeCodeOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
@@ -174,7 +178,6 @@ function App() {
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -640,6 +643,15 @@ function App() {
return <LoadingView />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && claudeCodeOnboarding.showPrompt) {
return (
<ClaudeCodeOnboardingView
status={claudeCodeStatus}
onContinue={claudeCodeOnboarding.dismissPrompt}
/>
)
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required') {
return (
@@ -821,6 +833,20 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i
)
}
function ClaudeCodeOnboardingView({
status,
onContinue,
}: {
status: ReturnType<typeof useClaudeCodeStatus>['status']
onContinue: () => void
}) {
return (
<div className="app-shell">
<ClaudeCodeOnboardingPrompt status={status} onContinue={onContinue} />
</div>
)
}
/** Loading spinner view - extracted from main App component */
function LoadingView() {
return (

View File

@@ -0,0 +1,56 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ClaudeCodeOnboardingPrompt } from './ClaudeCodeOnboardingPrompt'
const openExternalUrl = vi.fn()
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
}))
describe('ClaudeCodeOnboardingPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the detected state with a continue action', () => {
render(<ClaudeCodeOnboardingPrompt status="installed" onContinue={vi.fn()} />)
expect(screen.getByText('Claude Code detected')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue')
expect(screen.queryByTestId('claude-onboarding-install')).not.toBeInTheDocument()
})
it('shows the install path when Claude Code is missing', () => {
render(<ClaudeCodeOnboardingPrompt status="missing" onContinue={vi.fn()} />)
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
expect(screen.getByText('Install Claude Code to enable AI-powered note management.')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-install')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue without it')
})
it('opens the Claude Code install page', () => {
render(<ClaudeCodeOnboardingPrompt status="missing" onContinue={vi.fn()} />)
fireEvent.click(screen.getByTestId('claude-onboarding-install'))
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
})
it('calls onContinue from the detected state', () => {
const onContinue = vi.fn()
render(<ClaudeCodeOnboardingPrompt status="installed" onContinue={onContinue} />)
fireEvent.click(screen.getByTestId('claude-onboarding-continue'))
expect(onContinue).toHaveBeenCalledOnce()
})
it('disables continue while detection is still running', () => {
render(<ClaudeCodeOnboardingPrompt status="checking" onContinue={vi.fn()} />)
expect(screen.getByText('Checking for Claude Code')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toBeDisabled()
})
})

View File

@@ -0,0 +1,101 @@
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import { openExternalUrl } from '../utils/url'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
const CLAUDE_CODE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
interface ClaudeCodeOnboardingPromptProps {
status: ClaudeCodeStatus
onContinue: () => void
}
function getPromptCopy(status: ClaudeCodeStatus) {
if (status === 'installed') {
return {
accentClassName: 'bg-emerald-100 text-emerald-700',
description: "Tolaria's AI features are ready to use.",
icon: <CheckCircle2 className="size-7" />,
title: 'Claude Code detected',
}
}
if (status === 'missing') {
return {
accentClassName: 'bg-amber-100 text-amber-700',
description: 'Tolaria works best with an AI coding agent installed.',
icon: <Bot className="size-7" />,
title: 'Claude Code not detected',
}
}
return {
accentClassName: 'bg-slate-100 text-slate-600',
description: 'Checking whether Claude Code is available on this machine.',
icon: <Loader2 className="size-7 animate-spin" />,
title: 'Checking for Claude Code',
}
}
export function ClaudeCodeOnboardingPrompt({
status,
onContinue,
}: ClaudeCodeOnboardingPromptProps) {
const copy = getPromptCopy(status)
return (
<div
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
data-testid="claude-onboarding-screen"
>
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
<CardHeader className="items-center gap-5 text-center">
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
{copy.icon}
</div>
<div className="space-y-2">
<CardTitle className="text-3xl tracking-tight">
{copy.title}
</CardTitle>
<p className="text-sm leading-6 text-muted-foreground" data-testid="claude-onboarding-description">
{status === 'installed' && '✅ '}
{status === 'missing' && '🤖 '}
{copy.description}
</p>
</div>
</CardHeader>
<CardContent className="space-y-3 text-center">
{status === 'missing' && (
<p className="text-sm leading-6 text-muted-foreground">
Install Claude Code to enable AI-powered note management.
</p>
)}
</CardContent>
<CardFooter className="justify-center gap-3">
{status === 'missing' && (
<Button
type="button"
variant="outline"
onClick={() => void openExternalUrl(CLAUDE_CODE_INSTALL_URL)}
data-testid="claude-onboarding-install"
>
Install Claude Code
<ArrowUpRight className="size-4" />
</Button>
)}
<Button
type="button"
onClick={onContinue}
disabled={status === 'checking'}
data-testid="claude-onboarding-continue"
>
{status === 'missing' ? 'Continue without it' : status === 'installed' ? 'Continue' : 'Checking…'}
</Button>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useClaudeCodeOnboarding } from './useClaudeCodeOnboarding'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
describe('useClaudeCodeOnboarding', () => {
beforeEach(() => {
localStorage.clear()
})
it('shows the prompt when enabled and not dismissed', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
expect(result.current.showPrompt).toBe(true)
})
it('hides the prompt when onboarding is disabled', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(false))
expect(result.current.showPrompt).toBe(false)
})
it('starts hidden when the prompt was already dismissed', () => {
localStorage.setItem(DISMISSED_KEY, '1')
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
expect(result.current.showPrompt).toBe(false)
})
it('persists dismissal and hides the prompt', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
act(() => {
result.current.dismissPrompt()
})
expect(result.current.showPrompt).toBe(false)
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
})
})

View File

@@ -0,0 +1,33 @@
import { useCallback, useState } from 'react'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
function wasDismissed(): boolean {
try {
return localStorage.getItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY) === '1'
} catch {
return false
}
}
function markDismissed(): void {
try {
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
} catch {
// localStorage may be unavailable in restricted contexts
}
}
export function useClaudeCodeOnboarding(enabled: boolean) {
const [dismissed, setDismissed] = useState(() => wasDismissed())
const dismissPrompt = useCallback(() => {
markDismissed()
setDismissed(true)
}, [])
return {
dismissPrompt,
showPrompt: enabled && !dismissed,
}
}