diff --git a/apps/code/src/renderer/features/onboarding/components/CliCheckPanel.tsx b/apps/code/src/renderer/features/onboarding/components/CliCheckPanel.tsx new file mode 100644 index 000000000..9da6107a5 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/CliCheckPanel.tsx @@ -0,0 +1,58 @@ +import { CheckCircle, CircleNotch } from "@phosphor-icons/react"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type { ReactNode } from "react"; +import { PANEL_SHADOW } from "./onboardingStyles"; + +interface CliCheckPanelProps { + icon: ReactNode; + title: string; + isLoading: boolean; + statusBadge: ReactNode | null; + children?: ReactNode; +} + +export function CliCheckPanel({ + icon, + title, + isLoading, + statusBadge, + children, +}: CliCheckPanelProps) { + return ( + + + + + {icon} + + {title} + + + {isLoading ? ( + + ) : ( + statusBadge + )} + + {children} + + + ); +} + +interface InstalledBadgeProps { + label: string; +} + +export function InstalledBadge({ label }: InstalledBadgeProps) { + return ( + + + {label} + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx b/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx deleted file mode 100644 index 718876d31..000000000 --- a/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { - ArrowLeft, - ArrowRight, - ArrowSquareOut, - ArrowsClockwise, - Check, - CheckCircle, - CircleNotch, - Copy, - GitBranch, - GithubLogo, - Warning, -} from "@phosphor-icons/react"; -import { Box, Button, Flex, IconButton, Text } from "@radix-ui/themes"; -import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { EXTERNAL_LINKS } from "@utils/links"; -import { motion } from "framer-motion"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { OnboardingHogTip } from "./OnboardingHogTip"; -import { StepActions } from "./StepActions"; - -function CommandLine({ command }: { command: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - await navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [command]); - - return ( - - - - $ - - - {command} - - - - void handleCopy()} - aria-label="Copy command" - > - {copied ? : } - - - - ); -} - -interface CliInstallStepProps { - onComplete: (cliSkipped: boolean) => void; - onBack: () => void; -} - -export function CliInstallStep({ onComplete, onBack }: CliInstallStepProps) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [isCheckingGit, setIsCheckingGit] = useState(false); - const [isCheckingGh, setIsCheckingGh] = useState(false); - - const { data: gitStatus, isLoading: isLoadingGit } = useQuery( - trpc.git.getGitStatus.queryOptions(undefined, { staleTime: 30_000 }), - ); - const { data: ghStatus, isLoading: isLoadingGh } = useQuery( - trpc.git.getGhStatus.queryOptions(undefined, { staleTime: 30_000 }), - ); - - const gitInstalled = gitStatus?.installed ?? false; - const ghInstalled = ghStatus?.installed ?? false; - const ghAuthenticated = ghStatus?.authenticated ?? false; - const allReady = gitInstalled && ghInstalled && ghAuthenticated; - - const checkFiredRef = useRef(false); - useEffect(() => { - if (checkFiredRef.current || isLoadingGit || isLoadingGh) return; - checkFiredRef.current = true; - track(ANALYTICS_EVENTS.ONBOARDING_CLI_CHECK_COMPLETED, { - git_installed: gitInstalled, - gh_installed: ghInstalled, - gh_authenticated: ghAuthenticated, - }); - }, [isLoadingGit, isLoadingGh, gitInstalled, ghInstalled, ghAuthenticated]); - - const handleCheckGit = useCallback(async () => { - setIsCheckingGit(true); - await queryClient.invalidateQueries(trpc.git.getGitStatus.queryFilter()); - setIsCheckingGit(false); - }, [queryClient, trpc]); - - const handleCheckGh = useCallback(async () => { - setIsCheckingGh(true); - await queryClient.invalidateQueries(trpc.git.getGhStatus.queryFilter()); - setIsCheckingGh(false); - }, [queryClient, trpc]); - - return ( - - - - - - - - - Install required tools - - - These CLI tools are needed for code management and GitHub - workflows. - - - - - {/* Git box */} - - - - - - - - Git - - - {isLoadingGit && ( - - )} - {!isLoadingGit && gitInstalled && ( - - - - Installed - {gitStatus?.version - ? ` (${gitStatus.version})` - : ""} - - - )} - - {!isLoadingGit && !gitInstalled && ( - - - Install with Homebrew or Xcode Command Line Tools: - - - - - - - - - - - )} - - - - - {/* GitHub CLI box */} - - - - - - - - GitHub CLI - - - {isLoadingGh && ( - - )} - {!isLoadingGh && ghInstalled && ghAuthenticated && ( - - - - {ghStatus?.username - ? `Logged in as ${ghStatus.username}` - : "Authenticated"} - - - )} - {!isLoadingGh && ghInstalled && !ghAuthenticated && ( - - - - Not logged in - - - )} - - {!isLoadingGh && !ghInstalled && ( - - - Install with Homebrew: - - - - - - - - )} - {!isLoadingGh && ghInstalled && !ghAuthenticated && ( - - - Run this in your terminal to log in: - - - - - - - )} - - - - - - - - - - - - {allReady ? ( - - ) : ( - - )} - - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/components/ConnectGitHubStep.tsx b/apps/code/src/renderer/features/onboarding/components/ConnectGitHubStep.tsx new file mode 100644 index 000000000..43bbee7a5 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/ConnectGitHubStep.tsx @@ -0,0 +1,122 @@ +import { useUserGithubIntegrations } from "@hooks/useIntegrations"; +import { + ArrowLeft, + ArrowRight, + CheckCircle, + Cloud, + GitPullRequest, +} from "@phosphor-icons/react"; +import { Button, Flex, Text } from "@radix-ui/themes"; +import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; +import type { OnboardingStepCompletedProperties } from "@shared/types/analytics"; +import { motion } from "framer-motion"; +import { GitHubConnectPanel } from "./GitHubConnectPanel"; +import { OnboardingHogTip } from "./OnboardingHogTip"; +import { OptionalBadge } from "./OptionalBadge"; +import { StepActions } from "./StepActions"; + +type StepContext = Pick; + +interface ConnectGitHubStepProps { + onNext: (context?: StepContext) => void; + onBack: () => void; +} + +export function ConnectGitHubStep({ onNext, onBack }: ConnectGitHubStepProps) { + const { data: githubUserIntegrations = [] } = useUserGithubIntegrations(); + const handleContinue = () => { + onNext({ github_connected: githubUserIntegrations.length > 0 }); + }; + + return ( + + + + + + + + + + Connect GitHub + + + + + Unlocks the parts of PostHog Code that leave your machine. + + + + + + + + + + Run tasks in cloud sandboxes instead of your machine. + + + + + + Push branches and open pull requests from agents. + + + + + + Review PR comments and reply to threads from inside the + app. + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/GitHubConnectPanel.tsx b/apps/code/src/renderer/features/onboarding/components/GitHubConnectPanel.tsx new file mode 100644 index 000000000..08119f7a6 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/GitHubConnectPanel.tsx @@ -0,0 +1,531 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { + describeGithubConnectError, + invalidateGithubQueries, + useGithubConnect, +} from "@features/integrations/hooks/useGithubUserConnect"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { + useUserGithubIntegrations, + useUserRepositoryIntegration, +} from "@hooks/useIntegrations"; +import { + ArrowSquareOut, + ArrowsClockwise, + CheckCircle, + GearSix, + GithubLogo, + Plus, +} from "@phosphor-icons/react"; +import { + AlertDialog, + Box, + Button, + DropdownMenu, + Flex, + Skeleton, + Spinner, + Text, +} from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { + ANALYTICS_EVENTS, + type OnboardingGithubConnectFlow, +} from "@shared/types/analytics"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { track } from "@utils/analytics"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useProjectsWithIntegrations } from "../hooks/useProjectsWithIntegrations"; +import { OptionalBadge } from "./OptionalBadge"; +import { PANEL_SHADOW } from "./onboardingStyles"; + +function getPanelMessage(opts: { + hasConnectError: boolean; + connectError: Parameters[0]; + timedOut: boolean; + isConnecting: boolean; +}): string { + if (opts.hasConnectError) + return describeGithubConnectError(opts.connectError); + if (opts.timedOut) { + return "We didn't hear back from GitHub. If the browser tab was closed, click Connect again."; + } + if (opts.isConnecting) return "Waiting for GitHub..."; + return "Unlocks cloud runs, branch pushes, and PR review on this account."; +} + +export function GitHubConnectPanel() { + const queryClient = useQueryClient(); + const currentProjectId = useAuthStateValue((state) => state.projectId); + const { projects, projectsWithGithub, isLoading } = + useProjectsWithIntegrations(); + const manuallySelectedProjectId = useOnboardingStore( + (state) => state.selectedProjectId, + ); + const setSelectedProjectId = useOnboardingStore( + (state) => state.selectProjectId, + ); + const selectedProjectId = useMemo(() => { + if (manuallySelectedProjectId !== null) return manuallySelectedProjectId; + return currentProjectId ?? projects[0]?.id ?? null; + }, [manuallySelectedProjectId, currentProjectId, projects]); + const selectedProject = useMemo( + () => projects.find((p) => p.id === selectedProjectId), + [projects, selectedProjectId], + ); + + const { + error: connectError, + isConnecting, + isTimedOut: timedOut, + hasError: hasConnectError, + connect: handleConnectGitHub, + reset: resetConnect, + } = useGithubConnect({ + projectId: selectedProjectId, + projectHasTeamIntegration: selectedProject?.hasGithubIntegration ?? null, + onConnected: () => track(ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECTED), + }); + const canTakeAction = !isConnecting && !timedOut && !hasConnectError; + + const initiateConnect = ( + flowType: OnboardingGithubConnectFlow, + isRetry = false, + ) => { + track(ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_STARTED, { + flow_type: flowType, + is_retry: isRetry, + }); + void handleConnectGitHub(); + }; + + const failureFingerprintRef = useRef(null); + useEffect(() => { + if (!hasConnectError && !timedOut) { + failureFingerprintRef.current = null; + return; + } + const fingerprint = timedOut ? "timeout" : (connectError?.code ?? "error"); + if (failureFingerprintRef.current === fingerprint) return; + failureFingerprintRef.current = fingerprint; + track(ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_FAILED, { + reason: timedOut ? "timeout" : "error", + error_type: connectError?.code ?? undefined, + }); + }, [hasConnectError, timedOut, connectError]); + + const defaultPanelMessage = getPanelMessage({ + hasConnectError, + connectError, + timedOut, + isConnecting, + }); + + const { + data: githubUserIntegrations = [], + isLoading: githubUserIntegrationsLoading, + } = useUserGithubIntegrations(); + const hasGitIntegration = githubUserIntegrations.length > 0; + const { failedInstallationIds, reposByInstallationId } = + useUserRepositoryIntegration(); + const anyIntegrationStale = githubUserIntegrations.some((i) => + failedInstallationIds.includes(i.installation_id), + ); + + const alternativeConnectedProjects = useMemo(() => { + if (hasGitIntegration) return []; + if (!projectsWithGithub.length) return []; + return projectsWithGithub.filter((p) => p.id !== selectedProjectId); + }, [hasGitIntegration, projectsWithGithub, selectedProjectId]); + const [selectedAlternativeId, setSelectedAlternativeId] = useState< + number | null + >(null); + const selectedAlternative = useMemo(() => { + if (!alternativeConnectedProjects.length) return null; + return ( + alternativeConnectedProjects.find( + (p) => p.id === selectedAlternativeId, + ) ?? alternativeConnectedProjects[0] + ); + }, [alternativeConnectedProjects, selectedAlternativeId]); + + const apiClient = useOptionalAuthenticatedClient(); + const [disconnectTarget, setDisconnectTarget] = useState<{ + installationId: string; + accountName: string; + } | null>(null); + const [reconnectingInstallationId, setReconnectingInstallationId] = useState< + string | null + >(null); + const disconnectMutation = useMutation({ + mutationFn: async (opts: { installationId: string; silent?: boolean }) => { + if (!apiClient) throw new Error("Not authenticated"); + await apiClient.disconnectGithubUserIntegration(opts.installationId); + return { silent: opts.silent ?? false }; + }, + onSuccess: ({ silent }) => { + setDisconnectTarget(null); + invalidateGithubQueries(queryClient, selectedProjectId); + if (!silent) toast.success("GitHub disconnected."); + }, + onError: (e) => { + toast.error( + e instanceof Error ? e.message : "Failed to disconnect GitHub.", + ); + }, + }); + + return ( + <> + + + + + + + + Connect GitHub + + + {isLoading || githubUserIntegrationsLoading ? ( + + ) : hasGitIntegration ? ( + anyIntegrationStale ? ( + + Reconnect needed + + ) : ( + + + + {githubUserIntegrations.length > 1 + ? `Connected (${githubUserIntegrations.length})` + : "Connected"} + + + ) + ) : ( + + )} + + {!hasGitIntegration && + !isLoading && + !githubUserIntegrationsLoading && + (selectedProject?.hasGithubIntegration && canTakeAction ? ( + + GitHub is already set up on{" "} + {selectedProject.name}. + Sign in with one click to link your account, no admin approval + needed. + + ) : selectedAlternative && selectedProject && canTakeAction ? ( + + GitHub is already connected on{" "} + {alternativeConnectedProjects.length > 1 ? ( + + + + + + {alternativeConnectedProjects.map((p) => ( + setSelectedAlternativeId(p.id)} + > + {p.name} + + {p.organization.name} + + + ))} + + + ) : ( + <> + + {selectedAlternative.name} + {" "} + ({selectedAlternative.organization.name}) + + )} + . + + ) : ( + + {defaultPanelMessage} + + ))} + + {hasGitIntegration ? ( + + {githubUserIntegrations.map((integration) => { + const installationId = integration.installation_id; + const accountName = integration.account?.name ?? "GitHub"; + const installRepos = reposByInstallationId[installationId]; + const isLoadingInstallRepos = installRepos === undefined; + const isStale = failedInstallationIds.includes(installationId); + const isReconnecting = + reconnectingInstallationId === installationId; + return ( + + + + + {accountName} + + + {integration.account?.type === "Organization" + ? "org" + : "personal"} + + + {isStale ? ( + + Reconnect needed + + ) : ( + + {isLoadingInstallRepos + ? "Loading…" + : installRepos.length === 1 + ? "1 repo" + : `${installRepos.length} repos`} + + )} + + + {isStale && ( + + )} + + + + + ); + })} + + + + + + ) : !isLoading && !githubUserIntegrationsLoading ? ( + selectedProject?.hasGithubIntegration && canTakeAction ? ( + + ) : selectedAlternative && selectedProject && canTakeAction ? ( + + + + + ) : ( + + + {hasConnectError && ( + + )} + + ) + ) : null} + + + + { + if (!next && !disconnectMutation.isPending) { + setDisconnectTarget(null); + } + }} + > + + + Disconnect{" "} + {disconnectTarget ? disconnectTarget.accountName : "GitHub"} + + + This removes your personal GitHub authorization from PostHog. You + can reconnect at any time. The GitHub App itself stays installed in + your org — uninstall it on GitHub if you want to remove that too. + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx deleted file mode 100644 index 3fd3b060c..000000000 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ /dev/null @@ -1,755 +0,0 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; -import { - describeGithubConnectError, - invalidateGithubQueries, - useGithubConnect, -} from "@features/integrations/hooks/useGithubUserConnect"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { - useUserGithubIntegrations, - useUserRepositoryIntegration, -} from "@hooks/useIntegrations"; -import { - ArrowLeft, - ArrowRight, - ArrowSquareOut, - ArrowsClockwise, - CheckCircle, - CircleNotch, - FolderOpen, - GearSix, - GitBranch, - Plus, -} from "@phosphor-icons/react"; -import { cn } from "@posthog/quill"; -import { - AlertDialog, - Box, - Button, - DropdownMenu, - Flex, - Skeleton, - Spinner, - Text, -} from "@radix-ui/themes"; -import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; -import { trpcClient } from "@renderer/trpc/client"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { AnimatePresence, motion } from "framer-motion"; -import { useMemo, useState } from "react"; -import { toast } from "sonner"; -import type { DetectedRepo } from "../hooks/useOnboardingFlow"; -import { useProjectsWithIntegrations } from "../hooks/useProjectsWithIntegrations"; -import { OnboardingHogTip } from "./OnboardingHogTip"; -import { StepActions } from "./StepActions"; - -const PANEL_SHADOW = "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)"; - -function getPanelMessage(opts: { - hasConnectError: boolean; - connectError: Parameters[0]; - timedOut: boolean; - isConnecting: boolean; -}): string { - if (opts.hasConnectError) - return describeGithubConnectError(opts.connectError); - if (opts.timedOut) { - return "We didn't hear back from GitHub. If the browser tab was closed, click Connect again."; - } - if (opts.isConnecting) return "Waiting for GitHub..."; - return "Optional. Lets cloud agents work on this repo and open pull requests for you."; -} - -interface GitIntegrationStepProps { - onNext: () => void; - onBack: () => void; - selectedDirectory: string; - detectedRepo: DetectedRepo | null; - isDetectingRepo: boolean; - onDirectoryChange: (path: string) => void; -} - -export function GitIntegrationStep({ - onNext, - onBack, - selectedDirectory, - detectedRepo, - isDetectingRepo, - onDirectoryChange, -}: GitIntegrationStepProps) { - const currentProjectId = useAuthStateValue((state) => state.projectId); - const selectProjectMutation = useSelectProjectMutation(); - - const queryClient = useQueryClient(); - const { projects, projectsWithGithub, isLoading } = - useProjectsWithIntegrations(); - - const manuallySelectedProjectId = useOnboardingStore( - (state) => state.selectedProjectId, - ); - const setSelectedProjectId = useOnboardingStore( - (state) => state.selectProjectId, - ); - - const selectedProjectId = useMemo(() => { - if (manuallySelectedProjectId !== null) { - return manuallySelectedProjectId; - } - return currentProjectId ?? projects[0]?.id ?? null; - }, [manuallySelectedProjectId, currentProjectId, projects]); - - const selectedProject = useMemo( - () => projects.find((p) => p.id === selectedProjectId), - [projects, selectedProjectId], - ); - - const { - error: connectError, - isConnecting, - isTimedOut: timedOut, - hasError: hasConnectError, - connect: handleConnectGitHub, - reset: resetConnect, - } = useGithubConnect({ - projectId: selectedProjectId, - projectHasTeamIntegration: selectedProject?.hasGithubIntegration ?? null, - onConnected: () => track(ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECTED), - }); - const canTakeAction = !isConnecting && !timedOut && !hasConnectError; - const defaultPanelMessage = getPanelMessage({ - hasConnectError, - connectError, - timedOut, - isConnecting, - }); - - const { - data: githubUserIntegrations = [], - isLoading: githubUserIntegrationsLoading, - } = useUserGithubIntegrations(); - const hasGitIntegration = githubUserIntegrations.length > 0; - const { repositories, failedInstallationIds, reposByInstallationId } = - useUserRepositoryIntegration(); - const anyIntegrationStale = githubUserIntegrations.some((i) => - failedInstallationIds.includes(i.installation_id), - ); - - const alternativeConnectedProjects = useMemo(() => { - if (hasGitIntegration) return []; - if (!projectsWithGithub.length) return []; - return projectsWithGithub.filter((p) => p.id !== selectedProjectId); - }, [hasGitIntegration, projectsWithGithub, selectedProjectId]); - - const [selectedAlternativeId, setSelectedAlternativeId] = useState< - number | null - >(null); - - const selectedAlternative = useMemo(() => { - if (!alternativeConnectedProjects.length) return null; - return ( - alternativeConnectedProjects.find( - (p) => p.id === selectedAlternativeId, - ) ?? alternativeConnectedProjects[0] - ); - }, [alternativeConnectedProjects, selectedAlternativeId]); - - const repoMatchesGitHub = useMemo(() => { - if (!detectedRepo || repositories.length === 0) return false; - return repositories.some( - (r) => r.toLowerCase() === detectedRepo.fullName.toLowerCase(), - ); - }, [detectedRepo, repositories]); - - const apiClient = useOptionalAuthenticatedClient(); - const [disconnectTarget, setDisconnectTarget] = useState<{ - installationId: string; - accountName: string; - } | null>(null); - const [reconnectingInstallationId, setReconnectingInstallationId] = useState< - string | null - >(null); - const disconnectMutation = useMutation({ - mutationFn: async (opts: { installationId: string; silent?: boolean }) => { - if (!apiClient) { - throw new Error("Not authenticated"); - } - await apiClient.disconnectGithubUserIntegration(opts.installationId); - return { silent: opts.silent ?? false }; - }, - onSuccess: ({ silent }) => { - setDisconnectTarget(null); - invalidateGithubQueries(queryClient, selectedProjectId); - if (!silent) toast.success("GitHub disconnected."); - }, - onError: (e) => { - toast.error( - e instanceof Error ? e.message : "Failed to disconnect GitHub.", - ); - }, - }); - - const handleContinue = () => { - if (selectedProjectId && selectedProjectId !== currentProjectId) { - selectProjectMutation.mutate(selectedProjectId); - } - onNext(); - }; - - return ( - - - - - {/* Header + content */} - - - - - Give your agents access to code - - - Pick a repository to run local tasks on this machine. - Connect GitHub to send tasks to cloud agents. - - - - - {/* Local folder picker */} - - - - - - - - Choose your repository - - - - Select a single repository folder, not a parent folder - that contains multiple repos. - - - - - {isDetectingRepo && ( - - - - - Detecting repository... - - - - )} - {!isDetectingRepo && - selectedDirectory && - detectedRepo && ( - - - - - {repoMatchesGitHub - ? `Linked to ${detectedRepo.fullName} on GitHub` - : `Detected ${detectedRepo.fullName}`} - - - - )} - {!isDetectingRepo && - selectedDirectory && - !detectedRepo && ( - - - No git remote detected. You can still continue. - - - )} - - - - - - {/* GitHub integration */} - {selectedDirectory && ( - - - - - - - - - Connect GitHub - - - {isLoading || githubUserIntegrationsLoading ? ( - - ) : hasGitIntegration ? ( - anyIntegrationStale ? ( - - Reconnect needed - - ) : ( - - - - {githubUserIntegrations.length > 1 - ? `Connected (${githubUserIntegrations.length})` - : "Connected"} - - - ) - ) : ( - - Optional - - )} - - {!hasGitIntegration && - !isLoading && - !githubUserIntegrationsLoading && - (selectedProject?.hasGithubIntegration && - canTakeAction ? ( - - GitHub is already set up on{" "} - - {selectedProject.name} - - . Sign in with one click to link your account, no - admin approval needed. - - ) : selectedAlternative && - selectedProject && - canTakeAction ? ( - - GitHub is already connected on{" "} - {alternativeConnectedProjects.length > 1 ? ( - - - - - - {alternativeConnectedProjects.map((p) => ( - - setSelectedAlternativeId(p.id) - } - > - - {p.name} - - - {p.organization.name} - - - ))} - - - ) : ( - <> - - {selectedAlternative.name} - {" "} - ({selectedAlternative.organization.name}) - - )} - . - - ) : ( - - {defaultPanelMessage} - - ))} - - {hasGitIntegration ? ( - - {githubUserIntegrations.map((integration) => { - const installationId = integration.installation_id; - const accountName = - integration.account?.name ?? "GitHub"; - const installRepos = - reposByInstallationId[installationId]; - const isLoadingInstallRepos = - installRepos === undefined; - const isStale = - failedInstallationIds.includes(installationId); - const isReconnecting = - reconnectingInstallationId === installationId; - return ( - - - - - {accountName} - - - {integration.account?.type === - "Organization" - ? "org" - : "personal"} - - - {isStale ? ( - - Reconnect needed - - ) : ( - - {isLoadingInstallRepos - ? "Loading…" - : installRepos.length === 1 - ? "1 repo" - : `${installRepos.length} repos`} - - )} - - - {isStale && ( - - )} - - - - - ); - })} - - - - - - ) : !isLoading && !githubUserIntegrationsLoading ? ( - selectedProject?.hasGithubIntegration && - canTakeAction ? ( - - ) : selectedAlternative && - selectedProject && - canTakeAction ? ( - - - - - ) : ( - - - {hasConnectError && ( - - )} - - ) - ) : null} - - - - )} - - - {/* Hog tip */} - - - - - - - - - - { - if (!next && !disconnectMutation.isPending) { - setDisconnectTarget(null); - } - }} - > - - - Disconnect{" "} - {disconnectTarget ? disconnectTarget.accountName : "GitHub"} - - - This removes your personal GitHub authorization from PostHog. You - can reconnect at any time. The GitHub App itself stays installed - in your org — uninstall it on GitHub if you want to remove that - too. - - - - - - - - - - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/components/InstallCliStep.tsx b/apps/code/src/renderer/features/onboarding/components/InstallCliStep.tsx new file mode 100644 index 000000000..d988d2701 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/InstallCliStep.tsx @@ -0,0 +1,326 @@ +import { Tooltip } from "@components/ui/Tooltip"; +import { + ArrowLeft, + ArrowRight, + ArrowSquareOut, + ArrowsClockwise, + Check, + Copy, + GitBranch, + GithubLogo, + Warning, +} from "@phosphor-icons/react"; +import { Button, Flex, IconButton, Text } from "@radix-ui/themes"; +import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { + ANALYTICS_EVENTS, + type OnboardingStepCompletedProperties, +} from "@shared/types/analytics"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { track } from "@utils/analytics"; +import { EXTERNAL_LINKS } from "@utils/links"; +import { motion } from "framer-motion"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CliCheckPanel, InstalledBadge } from "./CliCheckPanel"; +import { OnboardingHogTip } from "./OnboardingHogTip"; +import { OptionalBadge } from "./OptionalBadge"; +import { StepActions } from "./StepActions"; + +function CommandLine({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [command]); + + return ( + + + + $ + + + {command} + + + + void handleCopy()} + aria-label="Copy command" + > + {copied ? : } + + + + ); +} + +type StepContext = Pick< + OnboardingStepCompletedProperties, + "git_installed" | "gh_installed" | "gh_authenticated" +>; + +interface InstallCliStepProps { + onNext: (context?: StepContext) => void; + onBack: () => void; +} + +export function InstallCliStep({ onNext, onBack }: InstallCliStepProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [isCheckingGit, setIsCheckingGit] = useState(false); + const [isCheckingGh, setIsCheckingGh] = useState(false); + const { data: gitStatus, isLoading: isLoadingGit } = useQuery( + trpc.git.getGitStatus.queryOptions(undefined, { staleTime: 30_000 }), + ); + const { data: ghStatus, isLoading: isLoadingGh } = useQuery( + trpc.git.getGhStatus.queryOptions(undefined, { staleTime: 30_000 }), + ); + const gitInstalled = gitStatus?.installed ?? false; + const ghInstalled = ghStatus?.installed ?? false; + const ghAuthenticated = ghStatus?.authenticated ?? false; + + const checkFiredRef = useRef(false); + useEffect(() => { + if (checkFiredRef.current) return; + if (gitStatus === undefined || ghStatus === undefined) return; + checkFiredRef.current = true; + track(ANALYTICS_EVENTS.ONBOARDING_CLI_CHECK_COMPLETED, { + git_installed: gitInstalled, + gh_installed: ghInstalled, + gh_authenticated: ghAuthenticated, + }); + }, [gitStatus, ghStatus, gitInstalled, ghInstalled, ghAuthenticated]); + + const handleCheckGit = useCallback(async () => { + setIsCheckingGit(true); + await queryClient.invalidateQueries(trpc.git.getGitStatus.queryFilter()); + setIsCheckingGit(false); + }, [queryClient, trpc]); + + const handleCheckGh = useCallback(async () => { + setIsCheckingGh(true); + await queryClient.invalidateQueries(trpc.git.getGhStatus.queryFilter()); + setIsCheckingGh(false); + }, [queryClient, trpc]); + + const handleContinue = () => { + onNext({ + git_installed: gitInstalled, + gh_installed: ghInstalled, + gh_authenticated: ghAuthenticated, + }); + }; + + return ( + + + + + + + + + + Install CLI tools + + + + + Agents use these to manage branches and open pull requests + on your behalf. + + + + + + } + title="Git" + isLoading={isLoadingGit} + statusBadge={ + gitInstalled ? ( + + ) : null + } + > + {!isLoadingGit && !gitInstalled && ( + + + Install with Homebrew or Xcode Command Line Tools: + + + + + + + + + + + )} + + + + + } + title="GitHub CLI" + isLoading={isLoadingGh} + statusBadge={ + ghInstalled && ghAuthenticated ? ( + + ) : ghInstalled ? ( + + + + Not logged in + + + ) : null + } + > + {!isLoadingGh && !ghInstalled && ( + + + Install with Homebrew: + + + + + + + + )} + {!isLoadingGh && ghInstalled && !ghAuthenticated && ( + + + Run this in your terminal to log in: + + + + + + + )} + + + + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 985f5826f..33949aa4d 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -6,7 +6,10 @@ import { useUserGithubIntegrations } from "@hooks/useIntegrations"; import { ArrowRight, SignOut } from "@phosphor-icons/react"; import { Button, Flex } from "@radix-ui/themes"; import { IS_DEV } from "@shared/constants/environment"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { + ANALYTICS_EVENTS, + type OnboardingStepCompletedProperties, +} from "@shared/types/analytics"; import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; @@ -14,10 +17,11 @@ import { useEffect, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useOnboardingFlow } from "../hooks/useOnboardingFlow"; -import { CliInstallStep } from "./CliInstallStep"; -import { GitIntegrationStep } from "./GitIntegrationStep"; +import { ConnectGitHubStep } from "./ConnectGitHubStep"; +import { InstallCliStep } from "./InstallCliStep"; import { InviteCodeStep } from "./InviteCodeStep"; import { ProjectSelectStep } from "./ProjectSelectStep"; +import { SelectRepoStep } from "./SelectRepoStep"; import { StepIndicator } from "./StepIndicator"; import { WelcomeScreen } from "./WelcomeScreen"; @@ -79,7 +83,12 @@ export function OnboardingFlow() { return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [currentStep]); - const trackStepCompleted = () => { + type StepContext = Omit< + OnboardingStepCompletedProperties, + "step_id" | "step_index" | "total_steps" | "duration_seconds" + >; + + const trackStepCompleted = (context?: StepContext) => { track(ANALYTICS_EVENTS.ONBOARDING_STEP_COMPLETED, { step_id: currentStep, step_index: currentIndex, @@ -87,6 +96,7 @@ export function OnboardingFlow() { duration_seconds: Math.round( (Date.now() - stepEnteredAtRef.current) / 1000, ), + ...context, }); }; @@ -101,8 +111,8 @@ export function OnboardingFlow() { stepEnteredAtRef.current = Date.now(); }; - const handleNext = () => { - trackStepCompleted(); + const handleNext = (context?: StepContext) => { + trackStepCompleted(context); trackStepViewed(currentIndex + 1); next(); }; @@ -112,15 +122,17 @@ export function OnboardingFlow() { back(); }; - useHotkeys("right", handleNext, { enableOnFormTags: false }, [handleNext]); + useHotkeys("right", () => handleNext(), { enableOnFormTags: false }, [ + handleNext, + ]); useHotkeys("left", handleBack, { enableOnFormTags: false }, [handleBack]); - const handleComplete = (cliSkipped: boolean) => { - if (cliSkipped) { + const handleComplete = (repoSkipped: boolean) => { + if (repoSkipped) { track(ANALYTICS_EVENTS.ONBOARDING_STEP_SKIPPED, { step_id: currentStep, step_index: currentIndex, - reason: "tools_not_installed", + reason: "no_repo_selected", }); } else { trackStepCompleted(); @@ -130,7 +142,7 @@ export function OnboardingFlow() { (Date.now() - flowStartedAtRef.current) / 1000, ), github_connected: githubUserIntegrations.length > 0, - cli_skipped: cliSkipped, + repo_skipped: repoSkipped, }); completeOnboarding(); navigateToTaskInput(); @@ -235,9 +247,9 @@ export function OnboardingFlow() { )} - {currentStep === "github" && ( + {currentStep === "connect-github" && ( - + )} @@ -268,7 +273,29 @@ export function OnboardingFlow() { transition={{ duration: 0.3 }} className="min-h-0 w-full flex-1" > - + + + )} + + {currentStep === "select-repo" && ( + + )} diff --git a/apps/code/src/renderer/features/onboarding/components/OptionalBadge.tsx b/apps/code/src/renderer/features/onboarding/components/OptionalBadge.tsx new file mode 100644 index 000000000..1d41c42f1 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/OptionalBadge.tsx @@ -0,0 +1,7 @@ +export function OptionalBadge() { + return ( + + Optional + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/SelectRepoStep.tsx b/apps/code/src/renderer/features/onboarding/components/SelectRepoStep.tsx new file mode 100644 index 000000000..cd8d8c766 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/SelectRepoStep.tsx @@ -0,0 +1,237 @@ +import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; +import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; +import { + ArrowLeft, + ArrowRight, + CheckCircle, + CircleNotch, + FolderOpen, + Lightbulb, +} from "@phosphor-icons/react"; +import { cn } from "@posthog/quill"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; +import { AnimatePresence, motion } from "framer-motion"; +import { useMemo } from "react"; +import type { DetectedRepo } from "../hooks/useOnboardingFlow"; +import { OnboardingHogTip } from "./OnboardingHogTip"; +import { OptionalBadge } from "./OptionalBadge"; +import { PANEL_SHADOW } from "./onboardingStyles"; +import { StepActions } from "./StepActions"; + +interface SelectRepoStepProps { + onComplete: (skipped: boolean) => void; + onBack: () => void; + selectedDirectory: string; + detectedRepo: DetectedRepo | null; + isDetectingRepo: boolean; + onDirectoryChange: (path: string) => void; +} + +export function SelectRepoStep({ + onComplete, + onBack, + selectedDirectory, + detectedRepo, + isDetectingRepo, + onDirectoryChange, +}: SelectRepoStepProps) { + const { repositories } = useUserRepositoryIntegration(); + + const repoMatchesGitHub = useMemo(() => { + if (!detectedRepo || repositories.length === 0) return false; + return repositories.some( + (r) => r.toLowerCase() === detectedRepo.fullName.toLowerCase(), + ); + }, [detectedRepo, repositories]); + + return ( + + + + + + + + + + Pick a repo to get started + + + + + We'll scan it and suggest some first things to work on. You + can also skip this and start from a blank task. + + + + + + + + + + + + Choose your repository + + + + Select a single repository folder, not a parent folder + that contains multiple repos. + + + + + {isDetectingRepo && ( + + + + + Detecting repository... + + + + )} + {!isDetectingRepo && + selectedDirectory && + detectedRepo && ( + + + + + {repoMatchesGitHub + ? `Linked to ${detectedRepo.fullName} on GitHub` + : `Detected ${detectedRepo.fullName}`} + + + + )} + {!isDetectingRepo && + selectedDirectory && + !detectedRepo && ( + + + No git remote detected. You can still continue. + + + )} + + + + + + + + + + Once you pick a repo we'll look for things like stale + feature flags, missing tracking, and other low-effort wins + to start from. + + + + + + + + + + + + {selectedDirectory ? ( + + ) : ( + + )} + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/onboardingStyles.ts b/apps/code/src/renderer/features/onboarding/components/onboardingStyles.ts new file mode 100644 index 000000000..1bd2b9479 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/onboardingStyles.ts @@ -0,0 +1,2 @@ +export const PANEL_SHADOW = + "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)"; diff --git a/apps/code/src/renderer/features/onboarding/types.ts b/apps/code/src/renderer/features/onboarding/types.ts index ef03bd3fc..66a118598 100644 --- a/apps/code/src/renderer/features/onboarding/types.ts +++ b/apps/code/src/renderer/features/onboarding/types.ts @@ -2,13 +2,15 @@ export type OnboardingStep = | "welcome" | "project-select" | "invite-code" - | "github" - | "install-cli"; + | "connect-github" + | "install-cli" + | "select-repo"; export const ONBOARDING_STEPS: OnboardingStep[] = [ "welcome", "project-select", "invite-code", - "github", + "connect-github", "install-cli", + "select-repo", ]; diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 58d12542e..0a4da5c97 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -324,10 +324,11 @@ export type OnboardingStepId = | "welcome" | "project-select" | "invite-code" - | "github" - | "install-cli"; + | "connect-github" + | "install-cli" + | "select-repo"; -type OnboardingSkipReason = "tools_not_installed" | "dev_skip"; +type OnboardingSkipReason = "no_repo_selected" | "dev_skip"; export interface OnboardingStepViewedProperties { step_id: OnboardingStepId; @@ -340,6 +341,10 @@ export interface OnboardingStepCompletedProperties { step_index: number; total_steps: number; duration_seconds: number; + github_connected?: boolean; + git_installed?: boolean; + gh_installed?: boolean; + gh_authenticated?: boolean; } export interface OnboardingStepSkippedProperties { @@ -376,7 +381,22 @@ export interface OnboardingCliCheckCompletedProperties { export interface OnboardingCompletedProperties { duration_seconds: number; github_connected: boolean; - cli_skipped: boolean; + repo_skipped: boolean; +} + +export type OnboardingGithubConnectFlow = + | "team_existing" + | "team_alternative" + | "user_new"; + +export interface OnboardingGithubConnectStartedProperties { + flow_type: OnboardingGithubConnectFlow; + is_retry: boolean; +} + +export interface OnboardingGithubConnectFailedProperties { + reason: "timeout" | "error"; + error_type?: string; } export interface OnboardingAbandonedProperties { @@ -681,6 +701,8 @@ export const ANALYTICS_EVENTS = { ONBOARDING_PROJECT_SELECTED: "Onboarding project selected", ONBOARDING_INVITE_CODE_SUBMITTED: "Onboarding invite code submitted", ONBOARDING_FOLDER_SELECTED: "Onboarding folder selected", + ONBOARDING_GITHUB_CONNECT_STARTED: "Onboarding github connect started", + ONBOARDING_GITHUB_CONNECT_FAILED: "Onboarding github connect failed", ONBOARDING_GITHUB_CONNECTED: "Onboarding github connected", ONBOARDING_CLI_CHECK_COMPLETED: "Onboarding cli check completed", ONBOARDING_COMPLETED: "Onboarding completed", @@ -797,6 +819,8 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.ONBOARDING_PROJECT_SELECTED]: OnboardingProjectSelectedProperties; [ANALYTICS_EVENTS.ONBOARDING_INVITE_CODE_SUBMITTED]: OnboardingInviteCodeSubmittedProperties; [ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED]: OnboardingFolderSelectedProperties; + [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_STARTED]: OnboardingGithubConnectStartedProperties; + [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_FAILED]: OnboardingGithubConnectFailedProperties; [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECTED]: never; [ANALYTICS_EVENTS.ONBOARDING_CLI_CHECK_COMPLETED]: OnboardingCliCheckCompletedProperties; [ANALYTICS_EVENTS.ONBOARDING_COMPLETED]: OnboardingCompletedProperties;