feat: add first-launch Claude Code onboarding
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
28
src/App.tsx
28
src/App.tsx
@@ -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 (
|
||||
|
||||
56
src/components/ClaudeCodeOnboardingPrompt.test.tsx
Normal file
56
src/components/ClaudeCodeOnboardingPrompt.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
101
src/components/ClaudeCodeOnboardingPrompt.tsx
Normal file
101
src/components/ClaudeCodeOnboardingPrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/hooks/useClaudeCodeOnboarding.test.ts
Normal file
54
src/hooks/useClaudeCodeOnboarding.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
33
src/hooks/useClaudeCodeOnboarding.ts
Normal file
33
src/hooks/useClaudeCodeOnboarding.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user