diff --git a/apps/frontend/src/renderer/components/onboarding/CompletionStep.test.tsx b/apps/frontend/src/renderer/components/onboarding/CompletionStep.test.tsx new file mode 100644 index 000000000..4197f7e06 --- /dev/null +++ b/apps/frontend/src/renderer/components/onboarding/CompletionStep.test.tsx @@ -0,0 +1,119 @@ +/** + * @vitest-environment jsdom + */ +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CompletionStep } from './CompletionStep'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const translations: Record = { + 'completion.title': "You're All Set!", + 'completion.subtitle': 'Auto-Coding is ready', + 'completion.setupComplete': 'Setup Complete', + 'completion.setupCompleteDescription': 'Your environment is configured.', + 'completion.whatsNext': "What's Next?", + 'completion.createTask.title': 'Create a Task', + 'completion.createTask.description': 'Start by creating your first task.', + 'completion.createTask.action': 'Open Task Creator', + 'completion.customizeSettings.title': 'Customize Settings', + 'completion.customizeSettings.description': 'Fine-tune your preferences.', + 'completion.customizeSettings.action': 'Open Settings', + 'completion.exploreDocs.title': 'Explore Documentation', + 'completion.exploreDocs.description': 'Learn more about advanced features.', + 'completion.finish': 'Finish & Start Building', + 'completion.rerunHint': 'You can re-run this wizard from Settings', + 'completion.readiness.title': 'Environment readiness', + 'completion.readiness.states.checking': 'Checking {{item}}...', + 'completion.readiness.states.ready': '{{item}} ready', + 'completion.readiness.states.warning': '{{item}} needs attention', + 'completion.readiness.states.skipped': '{{item}} disabled', + 'completion.readiness.items.codexAuth': 'Codex account', + 'completion.readiness.items.codexCli': 'Codex CLI', + 'completion.readiness.items.memory': 'Memory database' + }; + const template = translations[key] || key; + return template.replace('{{item}}', options?.item ?? ''); + } + }) +})); + +vi.mock('../../stores/settings-store', () => ({ + useSettingsStore: () => ({ + settings: { memoryEnabled: true }, + profiles: [] + }) +})); + +vi.mock('../../stores/claude-profile-store', () => ({ + useClaudeProfileStore: () => ({ + profiles: [] + }) +})); + +const mockElectronAPI = { + checkCodexCodeVersion: vi.fn(), + getCodexProfiles: vi.fn(), + getMemoryInfrastructureStatus: vi.fn() +}; + +Object.defineProperty(globalThis, 'electronAPI', { + value: mockElectronAPI, + writable: true +}); + +describe('CompletionStep readiness checks', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockElectronAPI.checkCodexCodeVersion.mockResolvedValue({ + success: true, + data: { + installed: '0.128.0', + latest: '0.128.0', + isOutdated: false, + path: '/usr/local/bin/codex' + } + }); + mockElectronAPI.getCodexProfiles.mockResolvedValue({ + success: true, + data: { + activeProfileId: 'codex-work', + profiles: [{ id: 'codex-work', name: 'Work', authenticated: true }] + } + }); + mockElectronAPI.getMemoryInfrastructureStatus.mockResolvedValue({ + success: true, + data: { + ready: true, + memory: { + kuzuInstalled: true, + databasePath: '/Users/test/.auto-coding/memory', + databaseExists: true, + databases: [] + } + } + }); + }); + + it('checks the selected Codex runtime and memory readiness before finish', async () => { + render( + + ); + + await waitFor(() => { + expect(mockElectronAPI.getCodexProfiles).toHaveBeenCalled(); + expect(mockElectronAPI.checkCodexCodeVersion).toHaveBeenCalled(); + expect(mockElectronAPI.getMemoryInfrastructureStatus).toHaveBeenCalled(); + }); + + expect(screen.getByText('Environment readiness')).toBeInTheDocument(); + expect(screen.getByText('Codex account ready')).toBeInTheDocument(); + expect(screen.getByText('Codex CLI ready')).toBeInTheDocument(); + expect(screen.getByText('Memory database ready')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/renderer/components/onboarding/CompletionStep.tsx b/apps/frontend/src/renderer/components/onboarding/CompletionStep.tsx index bcbefb079..1efe6aa2f 100644 --- a/apps/frontend/src/renderer/components/onboarding/CompletionStep.tsx +++ b/apps/frontend/src/renderer/components/onboarding/CompletionStep.tsx @@ -4,24 +4,30 @@ import { FileText, Settings, BookOpen, - ArrowRight + ArrowRight, + AlertTriangle, + Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; +import { useSettingsStore } from '../../stores/settings-store'; +import { useClaudeProfileStore } from '../../stores/claude-profile-store'; interface CompletionStepProps { - onFinish: () => void; - onOpenTaskCreator?: () => void; - onOpenSettings?: () => void; + readonly authRuntime?: 'anthropic' | 'codex'; + readonly onFinish: () => void; + readonly onOpenTaskCreator?: () => void; + readonly onOpenSettings?: () => void; } interface NextStepCardProps { - icon: React.ReactNode; - title: string; - description: string; - action?: () => void; - actionLabel?: string; + readonly icon: React.ReactNode; + readonly title: string; + readonly description: string; + readonly action?: () => void; + readonly actionLabel?: string; } function NextStepCard({ icon, title, description, action, actionLabel }: NextStepCardProps) { @@ -53,12 +59,262 @@ function NextStepCard({ icon, title, description, action, actionLabel }: NextSte ); } +type ReadinessState = 'checking' | 'ready' | 'warning' | 'skipped'; +type ReadinessSubject = 'claudeAuth' | 'claudeCli' | 'codexAuth' | 'codexCli' | 'memory'; +type Translate = (key: string, options?: Record) => string; + +interface ReadinessItem { + id: string; + state: ReadinessState; + label: string; +} + +interface RuntimeContext { + authRuntime: 'anthropic' | 'codex'; + apiProfiles: unknown[]; + claudeProfiles: Array<{ oauthToken?: string; configDir?: string }>; + memoryEnabled: boolean; + t: Translate; +} + +function getElectronAPI(): Window['electronAPI'] { + return (globalThis as typeof globalThis & { electronAPI: Window['electronAPI'] }).electronAPI; +} + +function translateReadiness(t: Translate, subject: ReadinessSubject, state: ReadinessState): string { + const item = t(`completion.readiness.items.${subject}`); + return t(`completion.readiness.states.${state}`, { item }); +} + +function buildItem( + id: string, + subject: ReadinessSubject, + ready: boolean, + t: Translate +): ReadinessItem { + const state: ReadinessState = ready ? 'ready' : 'warning'; + + return { + id, + state, + label: translateReadiness(t, subject, state) + }; +} + +function getReadinessIcon(state: ReadinessState) { + if (state === 'checking') { + return ; + } + + if (state === 'ready' || state === 'skipped') { + return ; + } + + return ; +} + +function ReadinessRow({ item }: Readonly<{ item: ReadinessItem }>) { + const icon = getReadinessIcon(item.state); + + return ( +
+ {icon} + {item.label} +
+ ); +} + +function isCodexProfileAuthenticated(profile: unknown): boolean { + if (!profile || typeof profile !== 'object') return false; + const candidate = profile as { + authenticated?: boolean; + isAuthenticated?: boolean; + configDir?: string; + }; + return Boolean(candidate.authenticated || candidate.isAuthenticated || candidate.configDir); +} + +function createInitialReadinessItems( + authRuntime: 'anthropic' | 'codex', + memoryEnabled: boolean, + t: Translate +): ReadinessItem[] { + const authSubject = authRuntime === 'codex' ? 'codexAuth' : 'claudeAuth'; + const cliSubject = authRuntime === 'codex' ? 'codexCli' : 'claudeCli'; + + return [ + { + id: 'auth', + state: 'checking', + label: translateReadiness(t, authSubject, 'checking') + }, + { + id: 'cli', + state: 'checking', + label: translateReadiness(t, cliSubject, 'checking') + }, + { + id: 'memory', + state: memoryEnabled ? 'checking' : 'skipped', + label: translateReadiness(t, 'memory', memoryEnabled ? 'checking' : 'skipped') + } + ]; +} + +async function getCodexAuthItem(t: Translate): Promise { + try { + const result = await getElectronAPI().getCodexProfiles(); + const profiles = result.success && result.data ? result.data.profiles : []; + return buildItem( + 'auth', + 'codexAuth', + profiles.some(isCodexProfileAuthenticated), + t + ); + } catch { + return buildItem('auth', 'codexAuth', false, t); + } +} + +function getClaudeAuthItem(context: RuntimeContext): ReadinessItem { + const hasApiProfile = context.apiProfiles.length > 0; + const hasClaudeProfile = context.claudeProfiles.some((profile) => + Boolean(profile.oauthToken || profile.configDir) + ); + + return buildItem( + 'auth', + 'claudeAuth', + hasApiProfile || hasClaudeProfile, + context.t + ); +} + +async function getCliItem( + id: string, + subject: ReadinessSubject, + checkVersion: () => Promise<{ success: boolean; data?: { installed?: string | null } }>, + t: Translate +): Promise { + try { + const result = await checkVersion(); + return buildItem(id, subject, Boolean(result.success && result.data?.installed), t); + } catch { + return buildItem(id, subject, false, t); + } +} + +async function getRuntimeItems(context: RuntimeContext): Promise { + if (context.authRuntime === 'codex') { + const [authItem, cliItem] = await Promise.all([ + getCodexAuthItem(context.t), + getCliItem( + 'cli', + 'codexCli', + () => getElectronAPI().checkCodexCodeVersion(), + context.t + ) + ]); + + return [authItem, cliItem]; + } + + const cliItem = await getCliItem( + 'cli', + 'claudeCli', + () => getElectronAPI().checkClaudeCodeVersion(), + context.t + ); + + return [getClaudeAuthItem(context), cliItem]; +} + +async function getMemoryItem( + memoryEnabled: boolean, + t: Translate +): Promise { + if (!memoryEnabled) { + return { + id: 'memory', + state: 'skipped', + label: translateReadiness(t, 'memory', 'skipped') + }; + } + + try { + const result = await getElectronAPI().getMemoryInfrastructureStatus(); + return buildItem('memory', 'memory', Boolean(result.success && result.data?.ready), t); + } catch { + return buildItem('memory', 'memory', false, t); + } +} + +async function collectReadinessItems(context: RuntimeContext): Promise { + const [runtimeItems, memoryItem] = await Promise.all([ + getRuntimeItems(context), + getMemoryItem(context.memoryEnabled, context.t) + ]); + + return [...runtimeItems, memoryItem]; +} + +function CompletionReadiness({ authRuntime }: Readonly<{ authRuntime: 'anthropic' | 'codex' }>) { + const { t } = useTranslation('onboarding'); + const { settings, profiles: apiProfiles } = useSettingsStore(); + const { profiles: claudeProfiles } = useClaudeProfileStore(); + const memoryEnabled = settings.memoryEnabled !== false; + const [items, setItems] = useState( + createInitialReadinessItems(authRuntime, memoryEnabled, t) + ); + + useEffect(() => { + let cancelled = false; + const context: RuntimeContext = { + authRuntime, + apiProfiles, + claudeProfiles, + memoryEnabled, + t + }; + + const runChecks = async () => { + const nextItems = await collectReadinessItems(context); + if (!cancelled) { + setItems(nextItems); + } + }; + + runChecks(); + + return () => { + cancelled = true; + }; + }, [apiProfiles, authRuntime, claudeProfiles, memoryEnabled, t]); + + return ( + + +
+ + {t('completion.readiness.title')} +
+
+ {items.map((item) => ( + + ))} +
+
+
+ ); +} + /** * Completion step component for the onboarding wizard. * Displays a success message with suggestions for next steps * and a prominent "Finish" button to complete the wizard. */ export function CompletionStep({ + authRuntime = 'anthropic', onFinish, onOpenTaskCreator, onOpenSettings @@ -127,6 +383,8 @@ export function CompletionStep({ + + {/* Next Steps Section */}
diff --git a/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx b/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx index 9b84b2c8d..456a2efeb 100644 --- a/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx +++ b/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx @@ -239,6 +239,7 @@ export function OnboardingWizard({ case 'completion': return (