From b5f7d2eadad728f214ca0dbf9d3609d11a38d1e0 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Mon, 2 Mar 2026 22:58:50 +0300 Subject: [PATCH] refactor(components): reorganize onboarding/provider auth/sidebar indicator into domain features - Move onboarding out of root-level components into a dedicated feature module: - add src/components/onboarding/view/Onboarding.tsx - split onboarding UI into focused subcomponents: - OnboardingStepProgress - GitConfigurationStep - AgentConnectionsStep - AgentConnectionCard - add onboarding-local types and utils for provider status and validation helpers - Move multi-provider login modal into a dedicated provider-auth feature: - add src/components/provider-auth/view/ProviderLoginModal.tsx - add src/components/provider-auth/types.ts - keep provider-specific command/title behavior and Gemini setup guidance - preserve compatibility for both onboarding flow and settings login flow - Move TaskIndicator into the sidebar domain: - add src/components/sidebar/view/subcomponents/TaskIndicator.tsx - update SidebarProjectItem to consume local sidebar TaskIndicator - Update integration points to the new structure: - ProtectedRoute now imports onboarding from onboarding feature - Settings now imports ProviderLoginModal directly (remove legacy cast wrapper) - git panel consumers now import shared GitDiffViewer by explicit name - Rename git shared diff view to clearer domain naming: - replace shared DiffViewer with shared GitDiffViewer - update FileChangeItem and CommitHistoryItem imports accordingly - Remove superseded root-level legacy components: - delete src/components/LoginModal.jsx - delete src/components/Onboarding.jsx - delete src/components/TaskIndicator.jsx - delete old src/components/git-panel/view/shared/DiffViewer.tsx - Result: - clearer feature boundaries (auth vs onboarding vs provider-auth vs sidebar) - easier navigation and ownership by domain - preserved runtime behavior with improved readability and modularity --- src/components/NextTaskBanner.jsx | 3 - src/components/Onboarding.jsx | 567 ------------------ src/components/TaskIndicator.jsx | 108 ---- src/components/auth/view/ProtectedRoute.tsx | 2 +- .../ProviderSelectionEmptyState.tsx | 2 +- .../git-panel/view/changes/FileChangeItem.tsx | 2 +- .../view/history/CommitHistoryItem.tsx | 2 +- .../{DiffViewer.tsx => GitDiffViewer.tsx} | 0 src/components/onboarding/view/Onboarding.tsx | 289 +++++++++ .../subcomponents/AgentConnectionCard.tsx | 60 ++ .../subcomponents/AgentConnectionsStep.tsx | 73 +++ .../subcomponents/GitConfigurationStep.tsx | 69 +++ .../subcomponents/OnboardingStepProgress.tsx | 53 ++ src/components/onboarding/view/types.ts | 12 + src/components/onboarding/view/utils.ts | 29 + src/components/provider-auth/types.ts | 1 + .../view/ProviderLoginModal.tsx} | 169 +++--- src/components/settings/view/Settings.tsx | 21 +- .../view/subcomponents/SidebarProjectItem.tsx | 2 +- .../view/subcomponents/TaskIndicator.tsx | 123 ++++ 20 files changed, 815 insertions(+), 772 deletions(-) delete mode 100644 src/components/NextTaskBanner.jsx delete mode 100644 src/components/Onboarding.jsx delete mode 100644 src/components/TaskIndicator.jsx rename src/components/git-panel/view/shared/{DiffViewer.tsx => GitDiffViewer.tsx} (100%) create mode 100644 src/components/onboarding/view/Onboarding.tsx create mode 100644 src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx create mode 100644 src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx create mode 100644 src/components/onboarding/view/subcomponents/GitConfigurationStep.tsx create mode 100644 src/components/onboarding/view/subcomponents/OnboardingStepProgress.tsx create mode 100644 src/components/onboarding/view/types.ts create mode 100644 src/components/onboarding/view/utils.ts create mode 100644 src/components/provider-auth/types.ts rename src/components/{LoginModal.jsx => provider-auth/view/ProviderLoginModal.tsx} (57%) create mode 100644 src/components/sidebar/view/subcomponents/TaskIndicator.tsx diff --git a/src/components/NextTaskBanner.jsx b/src/components/NextTaskBanner.jsx deleted file mode 100644 index 12c4ac47..00000000 --- a/src/components/NextTaskBanner.jsx +++ /dev/null @@ -1,3 +0,0 @@ -import NextTaskBanner from './task-master/view/NextTaskBanner'; - -export default NextTaskBanner; diff --git a/src/components/Onboarding.jsx b/src/components/Onboarding.jsx deleted file mode 100644 index 556e3c77..00000000 --- a/src/components/Onboarding.jsx +++ /dev/null @@ -1,567 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react'; -import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo'; -import LoginModal from './LoginModal'; -import { authenticatedFetch } from '../utils/api'; -import { useAuth } from './auth/context/AuthContext'; -import { IS_PLATFORM } from '../constants/config'; - -const Onboarding = ({ onComplete }) => { - const [currentStep, setCurrentStep] = useState(0); - const [gitName, setGitName] = useState(''); - const [gitEmail, setGitEmail] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(''); - - const [activeLoginProvider, setActiveLoginProvider] = useState(null); - const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' }); - - const [claudeAuthStatus, setClaudeAuthStatus] = useState({ - authenticated: false, - email: null, - loading: true, - error: null - }); - - const [cursorAuthStatus, setCursorAuthStatus] = useState({ - authenticated: false, - email: null, - loading: true, - error: null - }); - - const [codexAuthStatus, setCodexAuthStatus] = useState({ - authenticated: false, - email: null, - loading: true, - error: null - }); - - const [geminiAuthStatus, setGeminiAuthStatus] = useState({ - authenticated: false, - email: null, - loading: true, - error: null - }); - - const { user } = useAuth(); - - const prevActiveLoginProviderRef = useRef(undefined); - - useEffect(() => { - loadGitConfig(); - }, []); - - const loadGitConfig = async () => { - try { - const response = await authenticatedFetch('/api/user/git-config'); - if (response.ok) { - const data = await response.json(); - if (data.gitName) setGitName(data.gitName); - if (data.gitEmail) setGitEmail(data.gitEmail); - } - } catch (error) { - console.error('Error loading git config:', error); - } - }; - - useEffect(() => { - const prevProvider = prevActiveLoginProviderRef.current; - prevActiveLoginProviderRef.current = activeLoginProvider; - - const isInitialMount = prevProvider === undefined; - const isModalClosing = prevProvider !== null && activeLoginProvider === null; - - if (isInitialMount || isModalClosing) { - checkClaudeAuthStatus(); - checkCursorAuthStatus(); - checkCodexAuthStatus(); - checkGeminiAuthStatus(); - } - }, [activeLoginProvider]); - - const checkProviderAuthStatus = async (provider, setter) => { - try { - const response = await authenticatedFetch(`/api/cli/${provider}/status`); - if (response.ok) { - const data = await response.json(); - setter({ - authenticated: data.authenticated, - email: data.email, - loading: false, - error: data.error || null - }); - } else { - setter({ - authenticated: false, - email: null, - loading: false, - error: 'Failed to check authentication status' - }); - } - } catch (error) { - console.error(`Error checking ${provider} auth status:`, error); - setter({ - authenticated: false, - email: null, - loading: false, - error: error.message - }); - } - }; - - const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus); - const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus); - const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus); - const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus); - - const handleClaudeLogin = () => setActiveLoginProvider('claude'); - const handleCursorLogin = () => setActiveLoginProvider('cursor'); - const handleCodexLogin = () => setActiveLoginProvider('codex'); - const handleGeminiLogin = () => setActiveLoginProvider('gemini'); - - const handleLoginComplete = (exitCode) => { - if (exitCode === 0) { - if (activeLoginProvider === 'claude') { - checkClaudeAuthStatus(); - } else if (activeLoginProvider === 'cursor') { - checkCursorAuthStatus(); - } else if (activeLoginProvider === 'codex') { - checkCodexAuthStatus(); - } else if (activeLoginProvider === 'gemini') { - checkGeminiAuthStatus(); - } - } - }; - - const handleNextStep = async () => { - setError(''); - - // Step 0: Git config validation and submission - if (currentStep === 0) { - if (!gitName.trim() || !gitEmail.trim()) { - setError('Both git name and email are required'); - return; - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(gitEmail)) { - setError('Please enter a valid email address'); - return; - } - - setIsSubmitting(true); - try { - // Save git config to backend (which will also apply git config --global) - const response = await authenticatedFetch('/api/user/git-config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gitName, gitEmail }) - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to save git configuration'); - } - - setCurrentStep(currentStep + 1); - } catch (err) { - setError(err.message); - } finally { - setIsSubmitting(false); - } - return; - } - - setCurrentStep(currentStep + 1); - }; - - const handlePrevStep = () => { - setError(''); - setCurrentStep(currentStep - 1); - }; - - const handleFinish = async () => { - setIsSubmitting(true); - setError(''); - - try { - const response = await authenticatedFetch('/api/user/complete-onboarding', { - method: 'POST' - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to complete onboarding'); - } - - if (onComplete) { - onComplete(); - } - } catch (err) { - setError(err.message); - } finally { - setIsSubmitting(false); - } - }; - - const steps = [ - { - title: 'Git Configuration', - description: 'Set up your git identity for commits', - icon: GitBranch, - required: true - }, - { - title: 'Connect Agents', - description: 'Connect your AI coding assistants', - icon: LogIn, - required: false - } - ]; - - const renderStepContent = () => { - switch (currentStep) { - case 0: - return ( -
-
-
- -
-

Git Configuration

-

- Configure your git identity to ensure proper attribution for your commits -

-
- -
-
- - setGitName(e.target.value)} - className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="John Doe" - required - disabled={isSubmitting} - /> -

- This will be used as: git config --global user.name -

-
- -
- - setGitEmail(e.target.value)} - className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="john@example.com" - required - disabled={isSubmitting} - /> -

- This will be used as: git config --global user.email -

-
-
-
- ); - - case 1: - return ( -
-
-

Connect Your AI Agents

-

- Login to one or more AI coding assistants. All are optional. -

-
- - {/* Agent Cards Grid */} -
- {/* Claude */} -
-
-
-
- -
-
-
- Claude Code - {claudeAuthStatus.authenticated && } -
-
- {claudeAuthStatus.loading ? 'Checking...' : - claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'} -
-
-
- {!claudeAuthStatus.authenticated && !claudeAuthStatus.loading && ( - - )} -
-
- - {/* Cursor */} -
-
-
-
- -
-
-
- Cursor - {cursorAuthStatus.authenticated && } -
-
- {cursorAuthStatus.loading ? 'Checking...' : - cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'} -
-
-
- {!cursorAuthStatus.authenticated && !cursorAuthStatus.loading && ( - - )} -
-
- - {/* Codex */} -
-
-
-
- -
-
-
- OpenAI Codex - {codexAuthStatus.authenticated && } -
-
- {codexAuthStatus.loading ? 'Checking...' : - codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'} -
-
-
- {!codexAuthStatus.authenticated && !codexAuthStatus.loading && ( - - )} -
-
- - {/* Gemini */} -
-
-
-
- -
-
-
- Gemini - {geminiAuthStatus.authenticated && } -
-
- {geminiAuthStatus.loading ? 'Checking...' : - geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'} -
-
-
- {!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && ( - - )} -
-
-
- -
-

You can configure these later in Settings.

-
-
- ); - - default: - return null; - } - }; - - const isStepValid = () => { - switch (currentStep) { - case 0: - return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail); - case 1: - return true; - default: - return false; - } - }; - - return ( - <> -
-
- {/* Progress Steps */} -
-
- {steps.map((step, index) => ( - -
-
- {index < currentStep ? ( - - ) : typeof step.icon === 'function' ? ( - - ) : ( - - )} -
-
-

- {step.title} -

- {step.required && ( - Required - )} -
-
- {index < steps.length - 1 && ( -
- )} - - ))} -
-
- - {/* Main Card */} -
- {renderStepContent()} - - {/* Error Message */} - {error && ( -
-

{error}

-
- )} - - {/* Navigation Buttons */} -
- - -
- {currentStep < steps.length - 1 ? ( - - ) : ( - - )} -
-
-
-
-
- - {activeLoginProvider && ( - setActiveLoginProvider(null)} - provider={activeLoginProvider} - project={selectedProject} - onComplete={handleLoginComplete} - isOnboarding={true} - /> - )} - - ); -}; - -export default Onboarding; diff --git a/src/components/TaskIndicator.jsx b/src/components/TaskIndicator.jsx deleted file mode 100644 index 9b12b48a..00000000 --- a/src/components/TaskIndicator.jsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { CheckCircle, Settings, X, AlertCircle } from 'lucide-react'; -import { cn } from '../lib/utils'; - -/** - * TaskIndicator Component - * - * Displays TaskMaster status for projects in the sidebar with appropriate - * icons and colors based on the project's TaskMaster configuration state. - */ -const TaskIndicator = ({ - status = 'not-configured', - size = 'sm', - className = '', - showLabel = false -}) => { - const getIndicatorConfig = () => { - switch (status) { - case 'fully-configured': - return { - icon: CheckCircle, - color: 'text-green-500 dark:text-green-400', - bgColor: 'bg-green-50 dark:bg-green-950', - label: 'TaskMaster Ready', - title: 'TaskMaster fully configured with MCP server' - }; - - case 'taskmaster-only': - return { - icon: Settings, - color: 'text-blue-500 dark:text-blue-400', - bgColor: 'bg-blue-50 dark:bg-blue-950', - label: 'TaskMaster Init', - title: 'TaskMaster initialized, MCP server needs setup' - }; - - case 'mcp-only': - return { - icon: AlertCircle, - color: 'text-amber-500 dark:text-amber-400', - bgColor: 'bg-amber-50 dark:bg-amber-950', - label: 'MCP Ready', - title: 'MCP server configured, TaskMaster needs initialization' - }; - - case 'not-configured': - case 'error': - default: - return { - icon: X, - color: 'text-gray-400 dark:text-gray-500', - bgColor: 'bg-gray-50 dark:bg-gray-900', - label: 'No TaskMaster', - title: 'TaskMaster not configured' - }; - } - }; - - const config = getIndicatorConfig(); - const Icon = config.icon; - - const sizeClasses = { - xs: 'w-3 h-3', - sm: 'w-4 h-4', - md: 'w-5 h-5', - lg: 'w-6 h-6' - }; - - const paddingClasses = { - xs: 'p-0.5', - sm: 'p-1', - md: 'p-1.5', - lg: 'p-2' - }; - - if (showLabel) { - return ( -
- - {config.label} -
- ); - } - - return ( -
- -
- ); -}; - -export default TaskIndicator; \ No newline at end of file diff --git a/src/components/auth/view/ProtectedRoute.tsx b/src/components/auth/view/ProtectedRoute.tsx index 10431e5c..bd76482a 100644 --- a/src/components/auth/view/ProtectedRoute.tsx +++ b/src/components/auth/view/ProtectedRoute.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from 'react'; -import Onboarding from '../../Onboarding'; import { IS_PLATFORM } from '../../../constants/config'; import { useAuth } from '../context/AuthContext'; import AuthLoadingScreen from './AuthLoadingScreen'; import LoginForm from './LoginForm'; +import Onboarding from '../../onboarding/view/Onboarding'; import SetupForm from './SetupForm'; type ProtectedRouteProps = { diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index e17b9a4b..6c26a391 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { Check, ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; -import NextTaskBanner from '../../../NextTaskBanner.jsx'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants'; import type { ProjectSession, SessionProvider } from '../../../../types/app'; +import { NextTaskBanner } from '../../../task-master'; interface ProviderSelectionEmptyStateProps { selectedSession: ProjectSession | null; diff --git a/src/components/git-panel/view/changes/FileChangeItem.tsx b/src/components/git-panel/view/changes/FileChangeItem.tsx index e07a563b..7f5982d2 100644 --- a/src/components/git-panel/view/changes/FileChangeItem.tsx +++ b/src/components/git-panel/view/changes/FileChangeItem.tsx @@ -1,7 +1,7 @@ import { ChevronRight, Trash2 } from 'lucide-react'; import type { FileStatusCode } from '../../types/types'; import { getStatusBadgeClass, getStatusLabel } from '../../utils/gitPanelUtils'; -import GitDiffViewer from '../shared/DiffViewer'; +import GitDiffViewer from '../shared/GitDiffViewer'; type FileChangeItemProps = { filePath: string; diff --git a/src/components/git-panel/view/history/CommitHistoryItem.tsx b/src/components/git-panel/view/history/CommitHistoryItem.tsx index 937bd90a..1d941492 100644 --- a/src/components/git-panel/view/history/CommitHistoryItem.tsx +++ b/src/components/git-panel/view/history/CommitHistoryItem.tsx @@ -1,7 +1,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react'; import type { GitCommitSummary } from '../../types/types'; -import GitDiffViewer from '../shared/DiffViewer'; +import GitDiffViewer from '../shared/GitDiffViewer'; type CommitHistoryItemProps = { diff --git a/src/components/git-panel/view/shared/DiffViewer.tsx b/src/components/git-panel/view/shared/GitDiffViewer.tsx similarity index 100% rename from src/components/git-panel/view/shared/DiffViewer.tsx rename to src/components/git-panel/view/shared/GitDiffViewer.tsx diff --git a/src/components/onboarding/view/Onboarding.tsx b/src/components/onboarding/view/Onboarding.tsx new file mode 100644 index 00000000..8b49d705 --- /dev/null +++ b/src/components/onboarding/view/Onboarding.tsx @@ -0,0 +1,289 @@ +import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; +import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal'; +import AgentConnectionsStep from './subcomponents/AgentConnectionsStep'; +import GitConfigurationStep from './subcomponents/GitConfigurationStep'; +import OnboardingStepProgress from './subcomponents/OnboardingStepProgress'; +import type { CliProvider, ProviderStatusMap } from './types'; +import { + cliProviders, + createInitialProviderStatuses, + gitEmailPattern, + readErrorMessageFromResponse, + selectedProject, +} from './utils'; + +type OnboardingProps = { + onComplete?: () => void | Promise; +}; + +export default function Onboarding({ onComplete }: OnboardingProps) { + const [currentStep, setCurrentStep] = useState(0); + const [gitName, setGitName] = useState(''); + const [gitEmail, setGitEmail] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [activeLoginProvider, setActiveLoginProvider] = useState(null); + const [providerStatuses, setProviderStatuses] = useState(createInitialProviderStatuses); + + const previousActiveLoginProviderRef = useRef(undefined); + + const checkProviderAuthStatus = useCallback(async (provider: CliProvider) => { + try { + const response = await authenticatedFetch(`/api/cli/${provider}/status`); + if (!response.ok) { + setProviderStatuses((previous) => ({ + ...previous, + [provider]: { + authenticated: false, + email: null, + loading: false, + error: 'Failed to check authentication status', + }, + })); + return; + } + + const payload = (await response.json()) as { + authenticated?: boolean; + email?: string | null; + error?: string | null; + }; + + setProviderStatuses((previous) => ({ + ...previous, + [provider]: { + authenticated: Boolean(payload.authenticated), + email: payload.email ?? null, + loading: false, + error: payload.error ?? null, + }, + })); + } catch (caughtError) { + console.error(`Error checking ${provider} auth status:`, caughtError); + setProviderStatuses((previous) => ({ + ...previous, + [provider]: { + authenticated: false, + email: null, + loading: false, + error: caughtError instanceof Error ? caughtError.message : 'Unknown error', + }, + })); + } + }, []); + + const refreshAllProviderStatuses = useCallback(async () => { + await Promise.all(cliProviders.map((provider) => checkProviderAuthStatus(provider))); + }, [checkProviderAuthStatus]); + + const loadGitConfig = useCallback(async () => { + try { + const response = await authenticatedFetch('/api/user/git-config'); + if (!response.ok) { + return; + } + + const payload = (await response.json()) as { gitName?: string; gitEmail?: string }; + if (payload.gitName) { + setGitName(payload.gitName); + } + if (payload.gitEmail) { + setGitEmail(payload.gitEmail); + } + } catch (caughtError) { + console.error('Error loading git config:', caughtError); + } + }, []); + + useEffect(() => { + void loadGitConfig(); + void refreshAllProviderStatuses(); + }, [loadGitConfig, refreshAllProviderStatuses]); + + useEffect(() => { + const previousProvider = previousActiveLoginProviderRef.current; + previousActiveLoginProviderRef.current = activeLoginProvider; + + const isInitialMount = previousProvider === undefined; + const didCloseModal = previousProvider !== null && activeLoginProvider === null; + + // Refresh statuses once on mount and again after the login modal is closed. + if (isInitialMount || didCloseModal) { + void refreshAllProviderStatuses(); + } + }, [activeLoginProvider, refreshAllProviderStatuses]); + + const handleProviderLoginOpen = (provider: CliProvider) => { + setActiveLoginProvider(provider); + }; + + const handleLoginComplete = (exitCode: number) => { + if (exitCode === 0 && activeLoginProvider) { + void checkProviderAuthStatus(activeLoginProvider); + } + }; + + const handleNextStep = async () => { + setErrorMessage(''); + + if (currentStep !== 0) { + setCurrentStep((previous) => previous + 1); + return; + } + + if (!gitName.trim() || !gitEmail.trim()) { + setErrorMessage('Both git name and email are required.'); + return; + } + + if (!gitEmailPattern.test(gitEmail)) { + setErrorMessage('Please enter a valid email address.'); + return; + } + + setIsSubmitting(true); + try { + const response = await authenticatedFetch('/api/user/git-config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gitName, gitEmail }), + }); + + if (!response.ok) { + const message = await readErrorMessageFromResponse(response, 'Failed to save git configuration'); + throw new Error(message); + } + + setCurrentStep((previous) => previous + 1); + } catch (caughtError) { + setErrorMessage(caughtError instanceof Error ? caughtError.message : 'Failed to save git configuration'); + } finally { + setIsSubmitting(false); + } + }; + + const handlePreviousStep = () => { + setErrorMessage(''); + setCurrentStep((previous) => previous - 1); + }; + + const handleFinish = async () => { + setIsSubmitting(true); + setErrorMessage(''); + + try { + const response = await authenticatedFetch('/api/user/complete-onboarding', { method: 'POST' }); + if (!response.ok) { + const message = await readErrorMessageFromResponse(response, 'Failed to complete onboarding'); + throw new Error(message); + } + + await onComplete?.(); + } catch (caughtError) { + setErrorMessage(caughtError instanceof Error ? caughtError.message : 'Failed to complete onboarding'); + } finally { + setIsSubmitting(false); + } + }; + + const isCurrentStepValid = currentStep === 0 + ? Boolean(gitName.trim() && gitEmail.trim() && gitEmailPattern.test(gitEmail)) + : true; + + return ( + <> +
+
+ + +
+ {currentStep === 0 ? ( + + ) : ( + + )} + + {errorMessage && ( +
+

{errorMessage}

+
+ )} + +
+ + +
+ {currentStep < 1 ? ( + + ) : ( + + )} +
+
+
+
+
+ + {activeLoginProvider && ( + setActiveLoginProvider(null)} + provider={activeLoginProvider} + project={selectedProject} + onComplete={handleLoginComplete} + isOnboarding={true} + /> + )} + + ); +} diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx new file mode 100644 index 00000000..cdd99c39 --- /dev/null +++ b/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx @@ -0,0 +1,60 @@ +import { Check } from 'lucide-react'; +import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; +import type { CliProvider, ProviderAuthStatus } from '../types'; + +type AgentConnectionCardProps = { + provider: CliProvider; + title: string; + status: ProviderAuthStatus; + connectedClassName: string; + iconContainerClassName: string; + loginButtonClassName: string; + onLogin: () => void; +}; + +export default function AgentConnectionCard({ + provider, + title, + status, + connectedClassName, + iconContainerClassName, + loginButtonClassName, + onLogin, +}: AgentConnectionCardProps) { + const containerClassName = status.authenticated ? connectedClassName : 'border-border bg-card'; + + const statusText = status.loading + ? 'Checking...' + : status.authenticated + ? status.email || 'Connected' + : status.error || 'Not connected'; + + return ( +
+
+
+
+ +
+ +
+
+ {title} + {status.authenticated && } +
+
{statusText}
+
+
+ + {!status.authenticated && !status.loading && ( + + )} +
+
+ ); +} diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx new file mode 100644 index 00000000..bdcfb538 --- /dev/null +++ b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx @@ -0,0 +1,73 @@ +import AgentConnectionCard from './AgentConnectionCard'; +import type { CliProvider, ProviderStatusMap } from '../types'; + +type AgentConnectionsStepProps = { + providerStatuses: ProviderStatusMap; + onOpenProviderLogin: (provider: CliProvider) => void; +}; + +const providerCards = [ + { + provider: 'claude' as const, + title: 'Claude Code', + connectedClassName: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800', + iconContainerClassName: 'bg-blue-100 dark:bg-blue-900/30', + loginButtonClassName: 'bg-blue-600 hover:bg-blue-700', + }, + { + provider: 'cursor' as const, + title: 'Cursor', + connectedClassName: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800', + iconContainerClassName: 'bg-purple-100 dark:bg-purple-900/30', + loginButtonClassName: 'bg-purple-600 hover:bg-purple-700', + }, + { + provider: 'codex' as const, + title: 'OpenAI Codex', + connectedClassName: 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600', + iconContainerClassName: 'bg-gray-100 dark:bg-gray-800', + loginButtonClassName: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600', + }, + { + provider: 'gemini' as const, + title: 'Gemini', + connectedClassName: 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800', + iconContainerClassName: 'bg-teal-100 dark:bg-teal-900/30', + loginButtonClassName: 'bg-teal-600 hover:bg-teal-700', + }, +]; + +export default function AgentConnectionsStep({ + providerStatuses, + onOpenProviderLogin, +}: AgentConnectionsStepProps) { + return ( +
+
+

Connect Your AI Agents

+

+ Login to one or more AI coding assistants. All are optional. +

+
+ +
+ {providerCards.map((providerCard) => ( + onOpenProviderLogin(providerCard.provider)} + /> + ))} +
+ +
+

You can configure these later in Settings.

+
+
+ ); +} diff --git a/src/components/onboarding/view/subcomponents/GitConfigurationStep.tsx b/src/components/onboarding/view/subcomponents/GitConfigurationStep.tsx new file mode 100644 index 00000000..5ab23f36 --- /dev/null +++ b/src/components/onboarding/view/subcomponents/GitConfigurationStep.tsx @@ -0,0 +1,69 @@ +import { GitBranch, Mail, User } from 'lucide-react'; + +type GitConfigurationStepProps = { + gitName: string; + gitEmail: string; + isSubmitting: boolean; + onGitNameChange: (value: string) => void; + onGitEmailChange: (value: string) => void; +}; + +export default function GitConfigurationStep({ + gitName, + gitEmail, + isSubmitting, + onGitNameChange, + onGitEmailChange, +}: GitConfigurationStepProps) { + return ( +
+
+
+ +
+

Git Configuration

+

+ Configure your git identity to ensure proper attribution for commits. +

+
+ +
+
+ + onGitNameChange(event.target.value)} + className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="John Doe" + required + disabled={isSubmitting} + /> +

Saved as `git config --global user.name`.

+
+ +
+ + onGitEmailChange(event.target.value)} + className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="john@example.com" + required + disabled={isSubmitting} + /> +

Saved as `git config --global user.email`.

+
+
+
+ ); +} diff --git a/src/components/onboarding/view/subcomponents/OnboardingStepProgress.tsx b/src/components/onboarding/view/subcomponents/OnboardingStepProgress.tsx new file mode 100644 index 00000000..7476664f --- /dev/null +++ b/src/components/onboarding/view/subcomponents/OnboardingStepProgress.tsx @@ -0,0 +1,53 @@ +import { Check, GitBranch, LogIn } from 'lucide-react'; + +type OnboardingStepProgressProps = { + currentStep: number; +}; + +const onboardingSteps = [ + { title: 'Git Configuration', icon: GitBranch, required: true }, + { title: 'Connect Agents', icon: LogIn, required: false }, +]; + +export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) { + return ( +
+
+ {onboardingSteps.map((step, index) => { + const isCompleted = index < currentStep; + const isActive = index === currentStep; + const Icon = step.icon; + + return ( +
+
+
+ {isCompleted ? : } +
+ +
+

+ {step.title} +

+ {step.required && Required} +
+
+ + {index < onboardingSteps.length - 1 && ( +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/onboarding/view/types.ts b/src/components/onboarding/view/types.ts new file mode 100644 index 00000000..46800813 --- /dev/null +++ b/src/components/onboarding/view/types.ts @@ -0,0 +1,12 @@ +import type { CliProvider } from '../../provider-auth/types'; + +export type { CliProvider }; + +export type ProviderAuthStatus = { + authenticated: boolean; + email: string | null; + loading: boolean; + error: string | null; +}; + +export type ProviderStatusMap = Record; diff --git a/src/components/onboarding/view/utils.ts b/src/components/onboarding/view/utils.ts new file mode 100644 index 00000000..adb3c4bc --- /dev/null +++ b/src/components/onboarding/view/utils.ts @@ -0,0 +1,29 @@ +import { IS_PLATFORM } from '../../../constants/config'; +import type { CliProvider, ProviderStatusMap } from './types'; + +export const cliProviders: CliProvider[] = ['claude', 'cursor', 'codex', 'gemini']; + +export const gitEmailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const selectedProject = { + name: 'default', + displayName: 'default', + fullPath: IS_PLATFORM ? '/workspace' : '', + path: IS_PLATFORM ? '/workspace' : '', +}; + +export const createInitialProviderStatuses = (): ProviderStatusMap => ({ + claude: { authenticated: false, email: null, loading: true, error: null }, + cursor: { authenticated: false, email: null, loading: true, error: null }, + codex: { authenticated: false, email: null, loading: true, error: null }, + gemini: { authenticated: false, email: null, loading: true, error: null }, +}); + +export const readErrorMessageFromResponse = async (response: Response, fallback: string) => { + try { + const payload = (await response.json()) as { error?: string }; + return payload.error || fallback; + } catch { + return fallback; + } +}; diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts new file mode 100644 index 00000000..e39a9796 --- /dev/null +++ b/src/components/provider-auth/types.ts @@ -0,0 +1 @@ +export type CliProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; diff --git a/src/components/LoginModal.jsx b/src/components/provider-auth/view/ProviderLoginModal.tsx similarity index 57% rename from src/components/LoginModal.jsx rename to src/components/provider-auth/view/ProviderLoginModal.tsx index 9106140f..5dad1c2c 100644 --- a/src/components/LoginModal.jsx +++ b/src/components/provider-auth/view/ProviderLoginModal.tsx @@ -1,78 +1,109 @@ -import { X, ExternalLink, KeyRound } from 'lucide-react'; -import StandaloneShell from './standalone-shell/view/StandaloneShell'; -import { IS_PLATFORM } from '../constants/config'; +import { ExternalLink, KeyRound, X } from 'lucide-react'; +import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; +import { IS_PLATFORM } from '../../../constants/config'; +import type { CliProvider } from '../types'; -/** - * Reusable login modal component for Claude, Cursor, Codex, and Gemini CLI authentication - * - * @param {Object} props - * @param {boolean} props.isOpen - Whether the modal is visible - * @param {Function} props.onClose - Callback when modal is closed - * @param {'claude'|'cursor'|'codex'|'gemini'} props.provider - Which CLI provider to authenticate with - * @param {Object} props.project - Project object containing name and path information - * @param {Function} props.onComplete - Callback when login process completes (receives exitCode) - * @param {string} props.customCommand - Optional custom command to override defaults - * @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow) - */ -function LoginModal({ +type LoginModalProject = { + name?: string; + displayName?: string; + fullPath?: string; + path?: string; + [key: string]: unknown; +}; + +type ProviderLoginModalProps = { + isOpen: boolean; + onClose: () => void; + provider?: CliProvider; + project?: LoginModalProject | null; + onComplete?: (exitCode: number) => void; + customCommand?: string; + isAuthenticated?: boolean; + isOnboarding?: boolean; +}; + +const getProviderCommand = ({ + provider, + customCommand, + isAuthenticated, + isOnboarding, +}: { + provider: CliProvider; + customCommand?: string; + isAuthenticated: boolean; + isOnboarding: boolean; +}) => { + if (customCommand) { + return customCommand; + } + + if (provider === 'claude') { + if (isAuthenticated) { + return 'claude setup-token --dangerously-skip-permissions'; + } + return isOnboarding + ? 'claude /exit --dangerously-skip-permissions' + : 'claude /login --dangerously-skip-permissions'; + } + + if (provider === 'cursor') { + return 'cursor-agent login'; + } + + if (provider === 'codex') { + return IS_PLATFORM ? 'codex login --device-auth' : 'codex login'; + } + + return 'gemini status'; +}; + +const getProviderTitle = (provider: CliProvider) => { + if (provider === 'claude') return 'Claude CLI Login'; + if (provider === 'cursor') return 'Cursor CLI Login'; + if (provider === 'codex') return 'Codex CLI Login'; + return 'Gemini CLI Configuration'; +}; + +const normalizeProject = (project?: LoginModalProject | null) => { + const normalizedName = project?.name || 'default'; + const normalizedFullPath = project?.fullPath ?? project?.path ?? (IS_PLATFORM ? '/workspace' : ''); + + return { + name: normalizedName, + displayName: project?.displayName || normalizedName, + fullPath: normalizedFullPath, + path: project?.path ?? normalizedFullPath, + }; +}; + +export default function ProviderLoginModal({ isOpen, onClose, provider = 'claude', - project, + project = null, onComplete, customCommand, isAuthenticated = false, - isOnboarding = false -}) { - if (!isOpen) return null; + isOnboarding = false, +}: ProviderLoginModalProps) { + if (!isOpen) { + return null; + } - const getCommand = () => { - if (customCommand) return customCommand; + const command = getProviderCommand({ provider, customCommand, isAuthenticated, isOnboarding }); + const title = getProviderTitle(provider); + const shellProject = normalizeProject(project); - switch (provider) { - case 'claude': - return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions'; - case 'cursor': - return 'cursor-agent login'; - case 'codex': - return IS_PLATFORM ? 'codex login --device-auth' : 'codex login'; - case 'gemini': - // No explicit interactive login command for gemini CLI exists yet similar to Claude, so we'll just check status or instruct the user to configure `.gemini.json` - return 'gemini status'; - default: - return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions'; - } - }; - - const getTitle = () => { - switch (provider) { - case 'claude': - return 'Claude CLI Login'; - case 'cursor': - return 'Cursor CLI Login'; - case 'codex': - return 'Codex CLI Login'; - case 'gemini': - return 'Gemini CLI Configuration'; - default: - return 'CLI Login'; - } - }; - - const handleComplete = (exitCode) => { - if (onComplete) { - onComplete(exitCode); - } - // Keep modal open so users can read login output and close explicitly. + const handleComplete = (exitCode: number) => { + onComplete?.(exitCode); + // Keep the modal open so users can read terminal output before closing. }; return (
-

- {getTitle()} -

+

{title}

+
{provider === 'gemini' ? (
@@ -88,12 +120,10 @@ function LoginModal({
-

- Setup Gemini API Access -

+

Setup Gemini API Access

- The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first. + The Gemini CLI requires an API key to function. Configure it in your terminal first.

@@ -103,7 +133,7 @@ function LoginModal({ 1
-

Get your API Key

+

Get your API key

) : ( - + )}
); } - -export default LoginModal; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 2401dd7b..88cea76f 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -1,6 +1,6 @@ import { Settings as SettingsIcon, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import LoginModal from '../../LoginModal'; +import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal'; import { Button } from '../../../shared/view/ui'; import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal'; import CodexMcpFormModal from '../view/modals/CodexMcpFormModal'; @@ -11,18 +11,7 @@ import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSetting import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab'; import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab'; import { useSettingsController } from '../hooks/useSettingsController'; -import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types'; - -type LoginModalProps = { - isOpen: boolean; - onClose: () => void; - provider: AgentProvider | ''; - project: SettingsProject | null; - onComplete: (exitCode: number) => void; - isAuthenticated: boolean; -}; - -const LoginModalComponent = LoginModal as unknown as (props: LoginModalProps) => JSX.Element; +import type { SettingsProps } from '../types/types'; function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) { const { t } = useTranslation('settings'); @@ -225,11 +214,11 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
- setShowLoginModal(false)} - provider={loginProvider} + provider={loginProvider || 'claude'} project={selectedProject} onComplete={handleLoginComplete} isAuthenticated={isAuthenticated} diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx index 94dc7919..82b85a0f 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx @@ -2,7 +2,7 @@ import { Button } from '../../../../shared/view/ui'; import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react'; import type { TFunction } from 'i18next'; import { cn } from '../../../../lib/utils'; -import TaskIndicator from '../../../TaskIndicator'; +import TaskIndicator from './TaskIndicator'; import type { Project, ProjectSession, SessionProvider } from '../../../../types/app'; import type { MCPServerStatus, SessionWithProvider, TouchHandlerFactory } from '../../types/types'; import { getTaskIndicatorStatus } from '../../utils/utils'; diff --git a/src/components/sidebar/view/subcomponents/TaskIndicator.tsx b/src/components/sidebar/view/subcomponents/TaskIndicator.tsx new file mode 100644 index 00000000..1bcf3169 --- /dev/null +++ b/src/components/sidebar/view/subcomponents/TaskIndicator.tsx @@ -0,0 +1,123 @@ +import { AlertCircle, CheckCircle, Settings, X } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { cn } from '../../../../lib/utils'; + +type TaskIndicatorStatus = + | 'fully-configured' + | 'taskmaster-only' + | 'mcp-only' + | 'not-configured' + | 'error'; + +type TaskIndicatorSize = 'xs' | 'sm' | 'md' | 'lg'; + +type TaskIndicatorProps = { + status?: TaskIndicatorStatus; + size?: TaskIndicatorSize; + className?: string; + showLabel?: boolean; +}; + +type IndicatorConfig = { + icon: LucideIcon; + colorClassName: string; + backgroundClassName: string; + label: string; + title: string; +}; + +const sizeClassNames: Record = { + xs: 'w-3 h-3', + sm: 'w-4 h-4', + md: 'w-5 h-5', + lg: 'w-6 h-6', +}; + +const paddingClassNames: Record = { + xs: 'p-0.5', + sm: 'p-1', + md: 'p-1.5', + lg: 'p-2', +}; + +const getIndicatorConfig = (status: TaskIndicatorStatus): IndicatorConfig => { + // Keep color and label mapping centralized so status display remains consistent in sidebar UIs. + if (status === 'fully-configured') { + return { + icon: CheckCircle, + colorClassName: 'text-green-500 dark:text-green-400', + backgroundClassName: 'bg-green-50 dark:bg-green-950', + label: 'TaskMaster Ready', + title: 'TaskMaster fully configured with MCP server', + }; + } + + if (status === 'taskmaster-only') { + return { + icon: Settings, + colorClassName: 'text-blue-500 dark:text-blue-400', + backgroundClassName: 'bg-blue-50 dark:bg-blue-950', + label: 'TaskMaster Init', + title: 'TaskMaster initialized, MCP server needs setup', + }; + } + + if (status === 'mcp-only') { + return { + icon: AlertCircle, + colorClassName: 'text-amber-500 dark:text-amber-400', + backgroundClassName: 'bg-amber-50 dark:bg-amber-950', + label: 'MCP Ready', + title: 'MCP server configured, TaskMaster needs initialization', + }; + } + + return { + icon: X, + colorClassName: 'text-gray-400 dark:text-gray-500', + backgroundClassName: 'bg-gray-50 dark:bg-gray-900', + label: 'No TaskMaster', + title: 'TaskMaster not configured', + }; +}; + +export default function TaskIndicator({ + status = 'not-configured', + size = 'sm', + className = '', + showLabel = false, +}: TaskIndicatorProps) { + const indicatorConfig = getIndicatorConfig(status); + const Icon = indicatorConfig.icon; + + if (showLabel) { + return ( +
+ + {indicatorConfig.label} +
+ ); + } + + return ( +
+ +
+ ); +}