From fd9de042753f84fc0456811f3ef8bc2ac31c421a Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 12 Apr 2026 19:56:10 +0200 Subject: [PATCH] feat: add first-launch Claude Code onboarding --- docs/ABSTRACTIONS.md | 5 + docs/ARCHITECTURE.md | 2 + src/App.test.tsx | 3 + src/App.tsx | 28 ++++- .../ClaudeCodeOnboardingPrompt.test.tsx | 56 ++++++++++ src/components/ClaudeCodeOnboardingPrompt.tsx | 101 ++++++++++++++++++ src/hooks/useClaudeCodeOnboarding.test.ts | 54 ++++++++++ src/hooks/useClaudeCodeOnboarding.ts | 33 ++++++ 8 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 src/components/ClaudeCodeOnboardingPrompt.test.tsx create mode 100644 src/components/ClaudeCodeOnboardingPrompt.tsx create mode 100644 src/hooks/useClaudeCodeOnboarding.test.ts create mode 100644 src/hooks/useClaudeCodeOnboarding.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 095152f9..a061b3d2 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b2e65530..25365075 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/src/App.test.tsx b/src/App.test.tsx index 2052c785..1e0c7c34 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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 () => { diff --git a/src/App.tsx b/src/App.tsx index 06c8f3e5..ba781798 100644 --- a/src/App.tsx +++ b/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 } + if (!noteWindowParams && onboarding.state.status === 'ready' && claudeCodeOnboarding.showPrompt) { + return ( + + ) + } + // 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['status'] + onContinue: () => void +}) { + return ( +
+ +
+ ) +} + /** Loading spinner view - extracted from main App component */ function LoadingView() { return ( diff --git a/src/components/ClaudeCodeOnboardingPrompt.test.tsx b/src/components/ClaudeCodeOnboardingPrompt.test.tsx new file mode 100644 index 00000000..eaa08062 --- /dev/null +++ b/src/components/ClaudeCodeOnboardingPrompt.test.tsx @@ -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() + + 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() + + 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() + + 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() + + fireEvent.click(screen.getByTestId('claude-onboarding-continue')) + + expect(onContinue).toHaveBeenCalledOnce() + }) + + it('disables continue while detection is still running', () => { + render() + + expect(screen.getByText('Checking for Claude Code')).toBeInTheDocument() + expect(screen.getByTestId('claude-onboarding-continue')).toBeDisabled() + }) +}) diff --git a/src/components/ClaudeCodeOnboardingPrompt.tsx b/src/components/ClaudeCodeOnboardingPrompt.tsx new file mode 100644 index 00000000..a5244081 --- /dev/null +++ b/src/components/ClaudeCodeOnboardingPrompt.tsx @@ -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: , + 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: , + title: 'Claude Code not detected', + } + } + + return { + accentClassName: 'bg-slate-100 text-slate-600', + description: 'Checking whether Claude Code is available on this machine.', + icon: , + title: 'Checking for Claude Code', + } +} + +export function ClaudeCodeOnboardingPrompt({ + status, + onContinue, +}: ClaudeCodeOnboardingPromptProps) { + const copy = getPromptCopy(status) + + return ( +
+ + +
+ {copy.icon} +
+
+ + {copy.title} + +

+ {status === 'installed' && '✅ '} + {status === 'missing' && '🤖 '} + {copy.description} +

+
+
+ + + {status === 'missing' && ( +

+ Install Claude Code to enable AI-powered note management. +

+ )} +
+ + + {status === 'missing' && ( + + )} + + +
+
+ ) +} diff --git a/src/hooks/useClaudeCodeOnboarding.test.ts b/src/hooks/useClaudeCodeOnboarding.test.ts new file mode 100644 index 00000000..cbc3ff3f --- /dev/null +++ b/src/hooks/useClaudeCodeOnboarding.test.ts @@ -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 = {} + 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') + }) +}) diff --git a/src/hooks/useClaudeCodeOnboarding.ts b/src/hooks/useClaudeCodeOnboarding.ts new file mode 100644 index 00000000..d0856599 --- /dev/null +++ b/src/hooks/useClaudeCodeOnboarding.ts @@ -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, + } +}