diff --git a/apps/code/package.json b/apps/code/package.json index d250aaaa1..ef4a87dad 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -147,6 +147,9 @@ "@trpc/client": "^11.12.0", "@trpc/server": "^11.12.0", "@trpc/tanstack-react-query": "^11.12.0", + "@tsparticles/engine": "^3.9.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.9.1", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-serialize": "^0.13.0", "@xterm/addon-web-links": "^0.11.0", diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index 388e05ef2..4c5111087 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -212,6 +212,14 @@ export const commitInput = z.object({ export type CommitInput = z.infer; +// Git CLI status +export const gitStatusOutput = z.object({ + installed: z.boolean(), + version: z.string().nullable(), +}); + +export type GitStatusOutput = z.infer; + // GitHub CLI status export const ghStatusOutput = z.object({ installed: z.boolean(), diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 03c63fca7..7b190b888 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { promisify } from "node:util"; import { execGh } from "@posthog/git/gh"; import { getAllBranches, @@ -53,6 +55,7 @@ import type { GitHubIssue, GitRepoInfo, GitStateSnapshot, + GitStatusOutput, GitSyncStatus, OpenPrOutput, PrActionType, @@ -683,6 +686,17 @@ export class GitService extends TypedEventEmitter { }; } + public async getGitStatus(): Promise { + const execFileAsync = promisify(execFile); + try { + const { stdout } = await execFileAsync("git", ["--version"]); + const version = stdout.trim().replace("git version ", ""); + return { installed: true, version }; + } catch { + return { installed: false, version: null }; + } + } + public async getGhStatus(): Promise { const versionResult = await execGh(["--version"]); if (versionResult.exitCode !== 0) { @@ -699,7 +713,9 @@ export class GitService extends TypedEventEmitter { const authResult = await execGh(["auth", "status"]); const authenticated = authResult.exitCode === 0; const authOutput = `${authResult.stdout}\n${authResult.stderr}`; - const usernameMatch = authOutput.match(/Logged in to github.com as (\S+)/); + const usernameMatch = authOutput.match( + /Logged in to github.com (?:as |account )(\S+)/, + ); return { installed: true, diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 2f3d3baa2..d29d79e8a 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -51,6 +51,7 @@ import { ghAuthTokenOutput, ghStatusOutput, gitStateSnapshotSchema, + gitStatusOutput, openPrInput, openPrOutput, prStatusInput, @@ -269,6 +270,10 @@ export const gitRouter = router({ getService().sync(input.directoryPath, input.remote), ), + getGitStatus: publicProcedure + .output(gitStatusOutput) + .query(() => getService().getGitStatus()), + getGhStatus: publicProcedure .output(ghStatusOutput) .query(() => getService().getGhStatus()), diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 89d5b148f..e0a2f2581 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -156,8 +156,16 @@ function App() { ); } - // Four-phase rendering: auth → access gate → onboarding → main app + // Rendering: onboarding → auth → access gate → main app const renderContent = () => { + if (!hasCompletedOnboarding) { + return ( + + + + ); + } + if (!isAuthenticated) { return ( @@ -189,14 +197,6 @@ function App() { ); } - if (!hasCompletedOnboarding) { - return ( - - - - ); - } - return ( { if (typeof repo === "string") return repo; return (repo.full_name ?? repo.name ?? "").toLowerCase(); diff --git a/apps/code/src/renderer/assets/images/bw-logo.png b/apps/code/src/renderer/assets/images/bw-logo.png deleted file mode 100644 index c17130774..000000000 Binary files a/apps/code/src/renderer/assets/images/bw-logo.png and /dev/null differ diff --git a/apps/code/src/renderer/assets/images/cave-hero.jpg b/apps/code/src/renderer/assets/images/cave-hero.jpg deleted file mode 100644 index 05e58d269..000000000 Binary files a/apps/code/src/renderer/assets/images/cave-hero.jpg and /dev/null differ diff --git a/apps/code/src/renderer/assets/images/hedgehogs/builder-hog-03.png b/apps/code/src/renderer/assets/images/hedgehogs/builder-hog-03.png new file mode 100644 index 000000000..f935563f3 Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/builder-hog-03.png differ diff --git a/apps/code/src/renderer/assets/images/hedgehogs/clickthat-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/clickthat-hog.png new file mode 100644 index 000000000..d07a36187 Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/clickthat-hog.png differ diff --git a/apps/code/src/renderer/assets/images/hedgehogs/detective-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/detective-hog.png new file mode 100644 index 000000000..4ba2b7499 Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/detective-hog.png differ diff --git a/apps/code/src/renderer/assets/images/explorer-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/explorer-hog.png similarity index 100% rename from apps/code/src/renderer/assets/images/explorer-hog.png rename to apps/code/src/renderer/assets/images/hedgehogs/explorer-hog.png diff --git a/apps/code/src/renderer/assets/images/hedgehogs/feature-flag-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/feature-flag-hog.png new file mode 100644 index 000000000..efdfafe05 Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/feature-flag-hog.png differ diff --git a/apps/code/src/renderer/assets/images/graphs-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/graphs-hog.png similarity index 100% rename from apps/code/src/renderer/assets/images/graphs-hog.png rename to apps/code/src/renderer/assets/images/hedgehogs/graphs-hog.png diff --git a/apps/code/src/renderer/assets/images/hedgehogs/happy-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/happy-hog.png new file mode 100644 index 000000000..854cc44d5 Binary files /dev/null and b/apps/code/src/renderer/assets/images/hedgehogs/happy-hog.png differ diff --git a/apps/code/src/renderer/assets/images/logomark.svg b/apps/code/src/renderer/assets/images/logomark.svg deleted file mode 100644 index ebd58692e..000000000 --- a/apps/code/src/renderer/assets/images/logomark.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/apps/code/src/renderer/assets/images/tree-bg.svg b/apps/code/src/renderer/assets/images/tree-bg.svg deleted file mode 100644 index bb0fc4ac1..000000000 --- a/apps/code/src/renderer/assets/images/tree-bg.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/code/src/renderer/assets/images/wordmark-white.svg b/apps/code/src/renderer/assets/images/wordmark-white.svg new file mode 100644 index 000000000..08c1e772d --- /dev/null +++ b/apps/code/src/renderer/assets/images/wordmark-white.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/code/src/renderer/components/DotPatternBackground.tsx b/apps/code/src/renderer/components/DotPatternBackground.tsx new file mode 100644 index 000000000..5c8ae0433 --- /dev/null +++ b/apps/code/src/renderer/components/DotPatternBackground.tsx @@ -0,0 +1,45 @@ +import { useId } from "react"; + +const DOT_FILL = "var(--gray-6)"; + +interface DotPatternBackgroundProps { + style?: React.CSSProperties; +} + +export function DotPatternBackground({ style }: DotPatternBackgroundProps) { + const patternId = useId(); + + return ( + + ); +} diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index f49e76d66..1aefdf978 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -9,6 +9,7 @@ import { CommandCenterView } from "@features/command-center/components/CommandCe import { InboxView } from "@features/inbox/components/InboxView"; import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; import { SettingsDialog } from "@features/settings/components/SettingsDialog"; +import { SetupView } from "@features/setup/components/SetupView"; import { MainSidebar } from "@features/sidebar/components/MainSidebar"; import { SkillsView } from "@features/skills/components/SkillsView"; import { TaskDetail } from "@features/task-detail/components/TaskDetail"; @@ -80,6 +81,8 @@ export function MainLayout() { {view.type === "command-center" && } {view.type === "skills" && } + + {view.type === "setup" && } diff --git a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx index 3ed465303..87be74f41 100644 --- a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx +++ b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx @@ -1,85 +1,19 @@ import { DraggableTitleBar } from "@components/DraggableTitleBar"; import { ZenHedgehog } from "@components/ZenHedgehog"; -import { - useLoginMutation, - useSignupMutation, -} from "@features/auth/hooks/authMutations"; -import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; -import { Callout, Flex, Spinner, Text, Theme } from "@radix-ui/themes"; +import { Flex, Theme } from "@radix-ui/themes"; import codeLogo from "@renderer/assets/images/code.svg"; -import logomark from "@renderer/assets/images/logomark.svg"; -import { trpcClient } from "@renderer/trpc/client"; -import { REGION_LABELS } from "@shared/constants/oauth"; -import type { CloudRegion } from "@shared/types/oauth"; -import { RegionSelect } from "./RegionSelect"; - -export const getErrorMessage = (error: unknown) => { - if (!error) { - return null; - } - if (!(error instanceof Error)) { - return "Failed to authenticate"; - } - const message = error.message; - - if (message === "2FA_REQUIRED") { - return null; // 2FA dialog will handle this - } - - if (message.includes("access_denied")) { - return "Authorization cancelled."; - } - - if (message.includes("timed out")) { - return "Authorization timed out. Please try again."; - } - - if (message.includes("SSO login required")) { - return message; - } - - return message; -}; +import { useThemeStore } from "@stores/themeStore"; +import { OAuthControls } from "./OAuthControls"; export function AuthScreen() { - const staleRegion = useAuthUiStateStore((state) => state.staleRegion); - const selectedRegion = useAuthUiStateStore((state) => state.selectedRegion); - const setSelectedRegion = useAuthUiStateStore( - (state) => state.setSelectedRegion, - ); - const authMode = useAuthUiStateStore((state) => state.authMode); - const setAuthMode = useAuthUiStateStore((state) => state.setAuthMode); - const loginMutation = useLoginMutation(); - const signupMutation = useSignupMutation(); - const region: CloudRegion = selectedRegion ?? staleRegion ?? "us"; - - const handleAuth = () => { - if (authMode === "login") { - loginMutation.mutate(region); - } else { - signupMutation.mutate(region); - } - }; - - const handleRegionChange = (value: CloudRegion) => { - setSelectedRegion(value); - loginMutation.reset(); - signupMutation.reset(); - }; - - const handleCancel = async () => { - loginMutation.reset(); - signupMutation.reset(); - await trpcClient.oauth.cancelFlow.mutate(); - }; - - const isPending = loginMutation.isPending || signupMutation.isPending; - const isLoading = isPending; - const error = loginMutation.error || signupMutation.error; - const errorMessage = getErrorMessage(error); + const isDarkMode = useThemeStore((state) => state.isDarkMode); return ( - + @@ -88,7 +22,7 @@ export function AuthScreen() { style={{ position: "absolute", inset: 0, - backgroundColor: "rgb(243, 244, 240)", + backgroundColor: "var(--color-background)", }} /> @@ -102,7 +36,7 @@ export function AuthScreen() { right: 0, bottom: 0, width: "50%", - backgroundColor: "rgb(243, 244, 240)", + backgroundColor: "var(--color-background)", }} > @@ -141,138 +75,7 @@ export function AuthScreen() { }} /> - {/* Error */} - {errorMessage && ( - - {errorMessage} - - )} - - {/* Pending state */} - {isPending && ( - - Waiting for authorization... - - )} - - {/* Primary CTA */} - - - - Redirects to PostHog.com - - - - {/* Region + secondary links */} - - - - - {authMode === "login" ? ( - <> - - Don't have an account?{" "} - - - - ) : ( - <> - - Already have an account?{" "} - - - - )} - - + diff --git a/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx b/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx index 03e90d675..da8685fb1 100644 --- a/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx +++ b/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx @@ -6,7 +6,9 @@ import { import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; import { Callout, Flex, Spinner, Text, Theme } from "@radix-ui/themes"; import phWordmark from "@renderer/assets/images/wordmark.svg"; +import phWordmarkWhite from "@renderer/assets/images/wordmark-white.svg"; import zenHedgehog from "@renderer/assets/images/zen.png"; +import { useThemeStore } from "@stores/themeStore"; export function InviteCodeScreen() { const code = useAuthUiStateStore((state) => state.inviteCode); @@ -23,10 +25,15 @@ export function InviteCodeScreen() { }); }; + const isDarkMode = useThemeStore((state) => state.isDarkMode); const errorMessage = redeemMutation.error?.message ?? null; return ( - + @@ -35,7 +42,7 @@ export function InviteCodeScreen() { style={{ position: "absolute", inset: 0, - backgroundColor: "rgb(243, 244, 240)", + backgroundColor: "var(--color-background)", }} /> @@ -84,7 +91,7 @@ export function InviteCodeScreen() { > {/* Logo */} PostHog + {errorMessage && ( + + {errorMessage} + + )} + + {isPending && ( + + Waiting for authorization... + + )} + + + + + {/* + Redirects to PostHog.com + */} + + + + {/* + {authMode === "login" ? ( + <> + + Don't have an account?{" "} + + + + ) : ( + <> + + Already have an account?{" "} + + + + )} + */} + + + ); +} diff --git a/apps/code/src/renderer/features/auth/components/RegionSelect.tsx b/apps/code/src/renderer/features/auth/components/RegionSelect.tsx index 4d8f3fc5e..fb55bad73 100644 --- a/apps/code/src/renderer/features/auth/components/RegionSelect.tsx +++ b/apps/code/src/renderer/features/auth/components/RegionSelect.tsx @@ -20,7 +20,7 @@ export function RegionSelect({ if (!expanded) { return ( - + {regionLabel} {" \u00B7 "} @@ -47,7 +47,7 @@ export function RegionSelect({ } return ( - + s.staleCloudRegion); + const [region, setRegion] = useState(staleRegion ?? "us"); + const [authMode, setAuthMode] = useState("login"); + const { loginWithOAuth, signupWithOAuth } = useAuthStore(); + + const loginMutation = useMutation({ + mutationFn: async () => { + await loginWithOAuth(region); + }, + }); + + const signupMutation = useMutation({ + mutationFn: async () => { + await signupWithOAuth(region); + }, + }); + + const handleAuth = () => { + if (authMode === "login") { + loginMutation.mutate(); + } else { + signupMutation.mutate(); + } + }; + + const handleRegionChange = (value: CloudRegion) => { + setRegion(value); + loginMutation.reset(); + signupMutation.reset(); + }; + + const handleCancel = async () => { + loginMutation.reset(); + signupMutation.reset(); + await trpcClient.oauth.cancelFlow.mutate(); + }; + + const isPending = loginMutation.isPending || signupMutation.isPending; + const error = loginMutation.error || signupMutation.error; + const errorMessage = getErrorMessage(error); + + return { + region, + authMode, + setAuthMode, + handleAuth, + handleRegionChange, + handleCancel, + isPending, + errorMessage, + }; +} diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 76f36966b..0b705f4a3 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -45,6 +45,7 @@ interface AuthStoreState { hasCompletedOnboarding: boolean; selectedPlan: "free" | "pro" | null; selectedOrgId: string | null; + checkCodeAccess: () => Promise; redeemInviteCode: (code: string) => Promise; loginWithOAuth: (region: CloudRegion) => Promise; diff --git a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx b/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx index b3a9fd475..cacb0e52a 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx @@ -2,8 +2,8 @@ import { AnimatedEllipsis } from "@features/inbox/components/utils/AnimatedEllip import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; import { ArrowDownIcon } from "@phosphor-icons/react"; import { Box, Button, Flex, Text, Tooltip } from "@radix-ui/themes"; -import explorerHog from "@renderer/assets/images/explorer-hog.png"; -import graphsHog from "@renderer/assets/images/graphs-hog.png"; +import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; +import graphsHog from "@renderer/assets/images/hedgehogs/graphs-hog.png"; import mailHog from "@renderer/assets/images/mail-hog.png"; // ── Full-width empty states ───────────────────────────────────────────────── diff --git a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx b/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx index 394fbfdba..35ff4ffea 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx +++ b/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx @@ -1,28 +1,10 @@ import { - ArrowSquareOutIcon, - BrainIcon, BugIcon, - CircleNotchIcon, GithubLogoIcon, KanbanIcon, TicketIcon, - VideoIcon, } from "@phosphor-icons/react"; -import { - Badge, - Box, - Button, - Flex, - Link, - Spinner, - Switch, - Text, - Tooltip, -} from "@radix-ui/themes"; -import type { - Evaluation, - SignalSourceConfig, -} from "@renderer/api/posthogClient"; +import { Box, Button, Flex, Spinner, Switch, Text } from "@radix-ui/themes"; import { memo, useCallback } from "react"; export interface SignalSourceValues { @@ -122,151 +104,6 @@ const SignalSourceToggleCard = memo(function SignalSourceToggleCard({ ); }); -interface EvaluationRowProps { - evaluation: Evaluation; - onToggle: (id: string, enabled: boolean) => void; -} - -const EvaluationRow = memo(function EvaluationRow({ - evaluation, - onToggle, -}: EvaluationRowProps) { - const handleChange = useCallback( - (checked: boolean) => onToggle(evaluation.id, checked), - [onToggle, evaluation.id], - ); - - return ( - - - {evaluation.name} - - - - ); -}); - -interface EvaluationsSectionProps { - evaluations: Evaluation[]; - evaluationsUrl: string; - onToggleEvaluation: (id: string, enabled: boolean) => void; -} - -export const EvaluationsSection = memo(function EvaluationsSection({ - evaluations, - evaluationsUrl, - onToggleEvaluation, -}: EvaluationsSectionProps) { - return ( - - - - - - - - - - PostHog LLM Analytics - - - - Internal - - - - - Ongoing evaluation of how your AI features are performing based on - defined criteria - - - - - - {evaluations.length > 0 ? ( - - {evaluations.map((evaluation) => ( - - ))} - - ) : ( - - No evaluations configured yet. - - )} - - - Manage evaluations in PostHog Cloud - - - - - - ); -}); - -function SourceRunningIndicator({ - status, - message, -}: { - status: SignalSourceConfig["status"]; - message: string; -}) { - if (status !== "running") { - return null; - } - return ( - - - - {message} - - - ); -} - interface SignalSourceTogglesProps { value: SignalSourceValues; onToggle: (source: keyof SignalSourceValues, enabled: boolean) => void; @@ -277,11 +114,8 @@ interface SignalSourceTogglesProps { { requiresSetup: boolean; loading: boolean } > >; - sessionAnalysisStatus?: SignalSourceConfig["status"]; + sessionAnalysisStatus?: string | null; onSetup?: (source: keyof SignalSourceValues) => void; - evaluations?: Evaluation[]; - evaluationsUrl?: string; - onToggleEvaluation?: (id: string, enabled: boolean) => void; } export function SignalSourceToggles({ @@ -289,16 +123,8 @@ export function SignalSourceToggles({ onToggle, disabled, sourceStates, - sessionAnalysisStatus, onSetup, - evaluations, - evaluationsUrl, - onToggleEvaluation, }: SignalSourceTogglesProps) { - const toggleSessionReplay = useCallback( - (checked: boolean) => onToggle("session_replay", checked), - [onToggle], - ); const toggleErrorTracking = useCallback( (checked: boolean) => onToggle("error_tracking", checked), [onToggle], @@ -324,44 +150,11 @@ export function SignalSourceToggles({ } label="PostHog Error Tracking" - description="Surface new issues, reopenings, and volume spikes" + description="Surface new issues, reopenings and volume spikes" checked={value.error_tracking} onCheckedChange={toggleErrorTracking} disabled={disabled} /> - } - label="PostHog Session Replay" - labelSuffix={ - - Alpha - - } - description="Analyze session recordings and event data for UX issues" - checked={value.session_replay} - onCheckedChange={toggleSessionReplay} - disabled={disabled} - statusSection={ - value.session_replay ? ( - - ) : undefined - } - /> - {evaluations && evaluationsUrl && onToggleEvaluation && ( - - )} } label="GitHub Issues" diff --git a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx deleted file mode 100644 index d7ff782ac..000000000 --- a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { ArrowLeft, ArrowRight, Check } from "@phosphor-icons/react"; -import { Badge, Button, Flex, Text } from "@radix-ui/themes"; -import codeLogo from "@renderer/assets/images/code.svg"; -import { useEffect } from "react"; - -interface BillingStepProps { - onNext: () => void; - onBack: () => void; -} - -interface PlanFeature { - text: string; -} - -const FREE_FEATURES: PlanFeature[] = [ - { text: "Limited usage" }, - { text: "Local execution only" }, -]; - -const PRO_FEATURES: PlanFeature[] = [ - { text: "Unlimited usage*" }, - { text: "Local and cloud execution" }, -]; - -export function BillingStep({ onNext, onBack }: BillingStepProps) { - const selectedPlan = useOnboardingStore((state) => state.selectedPlan); - const selectPlan = useOnboardingStore((state) => state.selectPlan); - - useEffect(() => { - if (!selectedPlan) { - selectPlan("pro"); - } - }, [selectedPlan, selectPlan]); - - const handleContinue = () => { - onNext(); - }; - - return ( - - - PostHog - - - - - Choose your plan - - - {/* Free Plan */} - selectPlan("free")} - /> - - {/* Pro Plan */} - selectPlan("pro")} - recommended - /> - - - * Usage is limited to "human" level usage, this cannot be used as - your api key. If you hit this limit, please contact support. - - - - - - - - - - - ); -} - -interface PlanCardProps { - name: string; - price: string; - period: string; - features: PlanFeature[]; - isSelected: boolean; - onSelect: () => void; - recommended?: boolean; -} - -function PlanCard({ - name, - price, - period, - features, - isSelected, - onSelect, - recommended, -}: PlanCardProps) { - return ( - - - - - - {name} - - {recommended && ( - - Recommended - - )} - - - - {price} - - - {period} - - - - - - - - - {features.map((feature) => ( - - - - {feature.text} - - - ))} - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx b/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx new file mode 100644 index 000000000..1f720e98b --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/CliInstallStep.tsx @@ -0,0 +1,355 @@ +import { + ArrowLeft, + ArrowRight, + ArrowSquareOut, + ArrowsClockwise, + CheckCircle, + GitBranch, + GithubLogo, + Terminal, + Warning, +} from "@phosphor-icons/react"; +import { Box, Button, Code, Flex, Text } from "@radix-ui/themes"; +import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { EXTERNAL_LINKS } from "@utils/links"; +import { motion } from "framer-motion"; +import { useCallback, useState } from "react"; +import { OnboardingHogTip } from "./OnboardingHogTip"; +import { StepActions } from "./StepActions"; + +interface CliInstallStepProps { + onNext: () => void; + onBack: () => void; +} + +export function CliInstallStep({ onNext, onBack }: CliInstallStepProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [isCheckingGit, setIsCheckingGit] = useState(false); + const [isCheckingGh, setIsCheckingGh] = useState(false); + + const { data: gitStatus } = useQuery( + trpc.git.getGitStatus.queryOptions(undefined, { staleTime: 0 }), + ); + const { data: ghStatus } = useQuery( + trpc.git.getGhStatus.queryOptions(undefined, { staleTime: 0 }), + ); + + const gitInstalled = gitStatus?.installed ?? false; + const ghInstalled = ghStatus?.installed ?? false; + const ghAuthenticated = ghStatus?.authenticated ?? false; + const allReady = 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 + + + {gitInstalled && ( + + + + Installed + {gitStatus?.version + ? ` (${gitStatus.version})` + : ""} + + + )} + + {!gitInstalled && ( + + + Install with Homebrew or Xcode Command Line Tools: + + + + + + brew install git + + + + + + xcode-select --install + + + + + + + + + )} + + + + + {/* GitHub CLI box */} + + + + + + + + GitHub CLI + + + {ghInstalled && ghAuthenticated && ( + + + + {ghStatus?.username + ? `Logged in as ${ghStatus.username}` + : "Authenticated"} + + + )} + {ghInstalled && !ghAuthenticated && ( + + + + Not logged in + + + )} + + {!ghInstalled && ( + + + Install with Homebrew: + + + + + brew install gh + + + + + + + + )} + {ghInstalled && !ghAuthenticated && ( + + + Run this in your terminal to log in: + + + + + gh auth login + + + + + )} + + + + + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/ContextCollectionStep.tsx b/apps/code/src/renderer/features/onboarding/components/ContextCollectionStep.tsx new file mode 100644 index 000000000..e5aac07ac --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/ContextCollectionStep.tsx @@ -0,0 +1,221 @@ +import { ArrowLeft, ArrowRight } from "@phosphor-icons/react"; +import { Button, Flex, Text } from "@radix-ui/themes"; +import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; + +import { useContextCollection } from "../hooks/useContextCollection"; +import { SourceFeed } from "./context-collection/SourceFeed"; +import { SuggestedTasks } from "./context-collection/SuggestedTasks"; +import { StepActions } from "./StepActions"; + +interface ContextCollectionStepProps { + onNext: () => void; + onBack: () => void; +} + +export function ContextCollectionStep({ + onNext, + onBack, +}: ContextCollectionStepProps) { + const { sources, phase, isAllDone, totalItems } = useContextCollection(); + const [showTasks, setShowTasks] = useState(false); + + // Delay showing tasks briefly after scanning completes for a smooth transition + useEffect(() => { + if (!isAllDone) return; + const timeout = setTimeout(() => setShowTasks(true), 800); + return () => clearTimeout(timeout); + }, [isAllDone]); + + return ( + + + {/* Content area */} + + + + {!showTasks ? ( + + + {/* Title */} + + + + Building your context... + + + Scanning your data sources for insights and + priorities. + + + + + {/* Source feed */} + + + + + {/* Phase status + hedgehog */} + + + + + + + {phase} + {isAllDone && ( + + {totalItems.toLocaleString()} items across{" "} + {sources.length} sources + + )} + + + + + + + + ) : ( + + + {/* Title */} + + + Here's what we found + + + Based on {totalItems.toLocaleString()} items across your + data sources, we recommend starting with one of these: + + + + {/* Task cards */} + onNext()} /> + + + )} + + + + + {/* Footer buttons */} + + + + {showTasks && ( + + + + )} + + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/FeatureListItem.css b/apps/code/src/renderer/features/onboarding/components/FeatureListItem.css new file mode 100644 index 000000000..f5c1dfa03 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/FeatureListItem.css @@ -0,0 +1,22 @@ +.feature-list-item { + border-left: 2px solid var(--gray-4); + transition: + border-color 0.2s ease, + transform 0.2s ease; +} + +.feature-list-item:hover, +.feature-list-item--active { + border-left-color: var(--accent-9); + transform: translateX(6px); +} + +.feature-list-item .feature-list-item__description { + display: block; +} + +@media (max-height: 700px) { + .feature-list-item .feature-list-item__description { + display: none; + } +} diff --git a/apps/code/src/renderer/features/onboarding/components/FeatureListItem.tsx b/apps/code/src/renderer/features/onboarding/components/FeatureListItem.tsx index 6620fe177..6063cf266 100644 --- a/apps/code/src/renderer/features/onboarding/components/FeatureListItem.tsx +++ b/apps/code/src/renderer/features/onboarding/components/FeatureListItem.tsx @@ -1,56 +1,77 @@ import { Flex, Text } from "@radix-ui/themes"; +import { motion } from "framer-motion"; import type { ReactNode } from "react"; +import "./FeatureListItem.css"; interface FeatureListItemProps { icon: ReactNode; title: string; description: string; + active?: boolean; + index?: number; + onMouseEnter?: () => void; + onMouseLeave?: () => void; } export function FeatureListItem({ icon, title, description, + active = false, + index = 0, + onMouseEnter, + onMouseLeave, }: FeatureListItemProps) { return ( - - {icon} - - - - {title} - - - {description} - + {icon} + + + + {title} + + + {description} + + - + ); } diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index f5c659148..3403876bc 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -1,73 +1,88 @@ -import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; +import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowLeft, ArrowRight, ArrowSquareOut, + ArrowsClockwise, CheckCircle, + CircleNotch, + FolderOpen, + GearSix, GitBranch, } from "@phosphor-icons/react"; import { Box, Button, Flex, Skeleton, Text } from "@radix-ui/themes"; -import codeLogo from "@renderer/assets/images/code.svg"; +import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; import { trpcClient } from "@renderer/trpc/client"; import { useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { DetectedRepo } from "../hooks/useOnboardingFlow"; import { useProjectsWithIntegrations } from "../hooks/useProjectsWithIntegrations"; -import { ProjectSelect } from "./ProjectSelect"; +import { OnboardingHogTip } from "./OnboardingHogTip"; +import { StepActions } from "./StepActions"; const POLL_INTERVAL_MS = 3_000; -const POLL_TIMEOUT_MS = 300_000; // 5 minutes +const POLL_TIMEOUT_MS = 300_000; 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 cloudRegion = useAuthStateValue((state) => state.cloudRegion); const currentProjectId = useAuthStateValue((state) => state.projectId); - const client = useAuthenticatedClient(); - const selectProjectMutation = useSelectProjectMutation(); + const client = useOptionalAuthenticatedClient(); const queryClient = useQueryClient(); - const { projects, isLoading, isFetching } = useProjectsWithIntegrations(); + const { projects, isLoading } = useProjectsWithIntegrations(); const isConnecting = useOnboardingStore((state) => state.isConnectingGithub); const setConnectingGithub = useOnboardingStore( (state) => state.setConnectingGithub, ); - const manuallySelectedProjectId = useOnboardingStore( - (state) => state.selectedProjectId, - ); - const setSelectedProjectId = useOnboardingStore( - (state) => state.selectProjectId, - ); const pollTimerRef = useRef | null>(null); const pollTimeoutRef = useRef | null>(null); - // Determine which project to show: - // 1. If user manually selected one, use that - // 2. Current project from auth (matches user's active PostHog project) - // 3. Fall back to first available - 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], + () => projects.find((p) => p.id === currentProjectId), + [projects, currentProjectId], ); const hasGitIntegration = selectedProject?.hasGithubIntegration ?? false; + const { repositories, isLoadingRepos } = useRepositoryIntegration(); + const { githubIntegrations } = useIntegrationSelectors(); + const githubIntegration = githubIntegrations[0] ?? null; + + const repoSummary = useMemo(() => { + if (repositories.length === 0) return null; + const names = repositories.map((r) => r.split("/").pop() ?? r); + if (names.length <= 2) return names.join(" and "); + return `${names[0]}, ${names[1]} and ${names.length - 2} more`; + }, [repositories]); + + const repoMatchesGitHub = useMemo(() => { + if (!detectedRepo || repositories.length === 0) return false; + return repositories.some( + (r) => r.toLowerCase() === detectedRepo.fullName.toLowerCase(), + ); + }, [detectedRepo, repositories]); const stopPolling = useCallback(() => { if (pollTimerRef.current) { @@ -80,7 +95,6 @@ export function GitIntegrationStep({ } }, []); - // Stop polling when integration is detected useEffect(() => { if (hasGitIntegration && isConnecting) { stopPolling(); @@ -88,24 +102,21 @@ export function GitIntegrationStep({ } }, [hasGitIntegration, isConnecting, setConnectingGithub, stopPolling]); - // Cleanup on unmount useEffect(() => stopPolling, [stopPolling]); const handleConnectGitHub = async () => { - if (!cloudRegion || !selectedProjectId || !client) return; + if (!cloudRegion || !currentProjectId || !client) return; setConnectingGithub(true); try { await trpcClient.githubIntegration.startFlow.mutate({ region: cloudRegion, - projectId: selectedProjectId, + projectId: currentProjectId, }); - // Start polling for the new integration - pollTimerRef.current = setInterval(async () => { + pollTimerRef.current = setInterval(() => { queryClient.invalidateQueries({ queryKey: ["integrations"] }); }, POLL_INTERVAL_MS); - // Timeout after 5 minutes pollTimeoutRef.current = setTimeout(() => { stopPolling(); setConnectingGithub(false); @@ -115,358 +126,308 @@ export function GitIntegrationStep({ } }; - const handleRefresh = () => { - queryClient.invalidateQueries({ queryKey: ["integrations"] }); - }; - - const handleContinue = () => { - // Persist the selected project if it's different from current - if (selectedProjectId && selectedProjectId !== currentProjectId) { - selectProjectMutation.mutate(selectedProjectId); - } - onNext(); - }; - return ( - PostHog - - - - + {/* Header + content */} + + - Connect your Git repository - - - PostHog Code needs access to your GitHub repositories to enable - cloud runs and PR creation. - - - {selectedProject && ( - - - {selectedProject.organization.name} - - ({ - id: p.id, - name: p.name, - }))} - onProjectChange={setSelectedProjectId} - disabled={isLoading} - /> - - )} - + + Give your agents access to code + + - {/* Consistent status box - same height regardless of connection state */} - - - - {isLoading ? ( - - - - ) : hasGitIntegration ? ( - - - - ) : ( - - - - )} - - - - {isLoading ? ( - - - - - ) : hasGitIntegration ? ( - - + + + + + - GitHub connected - - - Your GitHub integration is active and ready to use. - - - ) : ( - + /> - No git integration found - - - Connect GitHub. + Choose your codebase - - )} - - - - {isLoading ? ( - - - - ) : !hasGitIntegration ? ( - - - - Opens GitHub to authorize the PostHog app + + + Select the local folder for your project so we can + analyze it. - - - ) : ( - - - - )} - - - - + + + + {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. + + + )} + + + + - - {!isLoading && ( + {/* GitHub integration */} - - - {hasGitIntegration ? ( - - ) : ( - - )} - + + + + + + Connect GitHub + + + {isLoading ? ( + + ) : hasGitIntegration ? ( + + + + Connected + + + ) : null} + + {hasGitIntegration ? ( + + + {isLoadingRepos + ? "Loading repositories..." + : repoSummary + ? `Access to ${repoSummary}` + : "No repositories found. Check your GitHub app settings."} + + + + + + + ) : !isLoading ? ( + + + Optional. Unlocks cloud agents and pull request + workflows. + + + + ) : null} + + - )} - + + + {/* Hog tip */} + + + + + + + ); diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 1d64fc693..3689d30f8 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -1,32 +1,70 @@ +import { DotPatternBackground } from "@components/DotPatternBackground"; import { DraggableTitleBar } from "@components/DraggableTitleBar"; -import { ZenHedgehog } from "@components/ZenHedgehog"; import { useLogoutMutation } from "@features/auth/hooks/authMutations"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { SignOut } from "@phosphor-icons/react"; +import { ArrowRight, Lifebuoy, SignOut } from "@phosphor-icons/react"; import { Button, Flex, Theme } from "@radix-ui/themes"; +import phWordmark from "@renderer/assets/images/wordmark.svg"; +import phWordmarkWhite from "@renderer/assets/images/wordmark-white.svg"; +import { trpcClient } from "@renderer/trpc/client"; +import { IS_DEV } from "@shared/constants/environment"; +import { useThemeStore } from "@stores/themeStore"; +import { EXTERNAL_LINKS } from "@utils/links"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; +import { useHotkeys } from "react-hotkeys-hook"; import { useOnboardingFlow } from "../hooks/useOnboardingFlow"; -import { BillingStep } from "./BillingStep"; +import { usePrefetchSignalData } from "../hooks/usePrefetchSignalData"; +import { CliInstallStep } from "./CliInstallStep"; import { GitIntegrationStep } from "./GitIntegrationStep"; -import { OrgBillingStep } from "./OrgBillingStep"; +import { ProjectSelectStep } from "./ProjectSelectStep"; import { SignalsStep } from "./SignalsStep"; import { StepIndicator } from "./StepIndicator"; -import { WelcomeStep } from "./WelcomeStep"; +import { WelcomeScreen } from "./WelcomeScreen"; + +const stepVariants = { + enter: (dir: number) => ({ opacity: 0, x: dir * 20 }), + center: { opacity: 1, x: 0 }, + exit: (dir: number) => ({ opacity: 0, x: dir * -20 }), +}; export function OnboardingFlow() { - const { currentStep, activeSteps, next, back } = useOnboardingFlow(); + const { + currentStep, + activeSteps, + direction, + next, + back, + selectedDirectory, + detectedRepo, + isDetectingRepo, + handleDirectoryChange, + } = useOnboardingFlow(); const completeOnboarding = useOnboardingStore( (state) => state.completeOnboarding, ); + const resetOnboarding = useOnboardingStore((state) => state.resetOnboarding); const logoutMutation = useLogoutMutation(); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const isDarkMode = useThemeStore((state) => state.isDarkMode); + usePrefetchSignalData(); + + useHotkeys("right", next, { enableOnFormTags: false }, [next]); + useHotkeys("left", back, { enableOnFormTags: false }, [back]); const handleComplete = () => { completeOnboarding(); }; return ( - + - - {/* Right panel — zen hedgehog */} - - - + {/* Content */} + PostHog - + {currentStep === "welcome" && ( - + )} - {currentStep === "billing" && ( + {currentStep === "project-select" && ( - + )} - {currentStep === "org-billing" && ( + {currentStep === "github" && ( - + )} - {currentStep === "git-integration" && ( + {currentStep === "install-cli" && ( - + )} {currentStep === "signals" && ( @@ -163,21 +215,45 @@ export function OnboardingFlow() { size="1" variant="ghost" color="gray" - onClick={() => logoutMutation.mutate()} - style={{ opacity: 0.5 }} - > - - Log out - - + + {isAuthenticated && ( + + )} + {IS_DEV && ( + + )} + diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingHogTip.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingHogTip.tsx new file mode 100644 index 000000000..155d52b40 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingHogTip.tsx @@ -0,0 +1,121 @@ +import { Flex, Text } from "@radix-ui/themes"; +import { motion, useAnimationControls } from "framer-motion"; +import { useCallback, useEffect, useRef } from "react"; + +interface OnboardingHogTipProps { + hogSrc: string; + message: string; + delay?: number; +} + +const talkingAnimation = { + rotate: [0, -3, 3, -2, 2, 0], + y: [0, -2, 0, -1, 0], + transition: { + duration: 0.4, + repeat: Infinity, + repeatDelay: 0.1, + }, +}; + +export function OnboardingHogTip({ + hogSrc, + message, + delay = 0.1, +}: OnboardingHogTipProps) { + const controls = useAnimationControls(); + + const isHovering = useRef(false); + + useEffect(() => { + const startDelay = (delay + 0.3) * 1000; + const startTimer = setTimeout(() => { + controls.start(talkingAnimation); + }, startDelay); + const stopTimer = setTimeout(() => { + if (!isHovering.current) { + controls.stop(); + controls.set({ rotate: 0, y: 0 }); + } + }, startDelay + 5000); + return () => { + clearTimeout(startTimer); + clearTimeout(stopTimer); + }; + }, [controls, delay]); + + const handleMouseEnter = useCallback(() => { + isHovering.current = true; + controls.start(talkingAnimation); + }, [controls]); + + const handleMouseLeave = useCallback(() => { + isHovering.current = false; + controls.stop(); + controls.set({ rotate: 0, y: 0 }); + }, [controls]); + + return ( + + + +
+ {/* Border tail */} +
+ {/* Fill tail */} +
+ + {message} + +
+ + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx deleted file mode 100644 index 7c600868a..000000000 --- a/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { authKeys, useCurrentUser } from "@features/auth/hooks/authQueries"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { useOrganizations } from "@hooks/useOrganizations"; -import { ArrowLeft, ArrowRight, CheckCircle } from "@phosphor-icons/react"; -import { - Badge, - Box, - Button, - Callout, - Flex, - Skeleton, - Text, -} from "@radix-ui/themes"; -import codeLogo from "@renderer/assets/images/code.svg"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { AnimatePresence, motion } from "framer-motion"; - -const log = logger.scope("org-billing-step"); - -interface OrgBillingStepProps { - onNext: () => void; - onBack: () => void; -} - -export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { - const selectedOrgId = useOnboardingStore((state) => state.selectedOrgId); - const selectOrg = useOnboardingStore((state) => state.selectOrg); - const client = useAuthenticatedClient(); - const { data: currentUser } = useCurrentUser({ client }); - const queryClient = useQueryClient(); - const switchOrganizationMutation = useMutation({ - mutationFn: async (orgId: string) => { - await client.switchOrganization(orgId); - await queryClient.invalidateQueries({ - queryKey: authKeys.currentUsers(), - }); - }, - onError: (err) => { - log.error("Failed to switch organization", err); - }, - }); - - const { orgsWithBilling, effectiveSelectedOrgId, isLoading, error } = - useOrganizations(); - - const currentUserOrgId = currentUser?.organization?.id; - - const handleContinue = async () => { - if (!effectiveSelectedOrgId) return; - - if (effectiveSelectedOrgId !== selectedOrgId) { - selectOrg(effectiveSelectedOrgId); - } - - if (client && effectiveSelectedOrgId !== currentUserOrgId) { - try { - await switchOrganizationMutation.mutateAsync(effectiveSelectedOrgId); - } catch {} - } - - onNext(); - }; - - const handleSelect = (orgId: string) => { - selectOrg(orgId); - }; - - return ( - - - - PostHog - - Choose your organization - - - Select which organization should be billed for your PostHog Code - usage. - - - - {error && ( - - - Failed to load organizations. Please try again later. - - - )} - - - - {isLoading ? ( - - - - - - - - - - - ) : ( - - - {orgsWithBilling.map((org) => ( - handleSelect(org.id)} - /> - ))} - - - )} - - - - - - - - - - ); -} - -interface OrgCardProps { - name: string; - hasActiveBilling: boolean; - isSelected: boolean; - onSelect: () => void; -} - -function OrgCard({ - name, - hasActiveBilling, - isSelected, - onSelect, -}: OrgCardProps) { - return ( - - - - {name} - - {hasActiveBilling && ( - - - Billing active - - )} - - - - {isSelected && ( - - )} - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelect.css b/apps/code/src/renderer/features/onboarding/components/ProjectSelect.css index 73bc50ed5..3a6910188 100644 --- a/apps/code/src/renderer/features/onboarding/components/ProjectSelect.css +++ b/apps/code/src/renderer/features/onboarding/components/ProjectSelect.css @@ -1,6 +1,5 @@ .project-select-popover [cmdk-root] { - width: 320px; - min-width: 320px; + width: 100%; background: var(--color-panel-solid); border-radius: var(--radius-3); border: 1px solid var(--gray-6); diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelect.tsx b/apps/code/src/renderer/features/onboarding/components/ProjectSelect.tsx index 82c4eb13a..26687d6f7 100644 --- a/apps/code/src/renderer/features/onboarding/components/ProjectSelect.tsx +++ b/apps/code/src/renderer/features/onboarding/components/ProjectSelect.tsx @@ -10,6 +10,7 @@ interface ProjectSelectProps { projects: Array<{ id: number; name: string }>; onProjectChange: (projectId: number) => void; disabled?: boolean; + size?: "1" | "2"; } export function ProjectSelect({ @@ -18,6 +19,7 @@ export function ProjectSelect({ projects, onProjectChange, disabled = false, + size = "2", }: ProjectSelectProps) { const [open, setOpen] = useState(false); const currentProject = projects.find((p) => p.id === projectId); @@ -28,14 +30,14 @@ export function ProjectSelect({ if (projects.length <= 1) { return ( - + {projectName} ); } return ( - + {projectName} {" · "} diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx new file mode 100644 index 000000000..511caa128 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx @@ -0,0 +1,448 @@ +import { OAuthControls } from "@features/auth/components/OAuthControls"; +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; +import { + authKeys, + useAuthStateValue, + useCurrentUser, +} from "@features/auth/hooks/authQueries"; +import { Command } from "@features/command/components/Command"; +import { useProjects } from "@features/projects/hooks/useProjects"; +import { + ArrowLeft, + ArrowRight, + CaretDown, + Check, + CheckCircle, +} from "@phosphor-icons/react"; +import { Box, Button, Flex, Popover, Spinner, Text } from "@radix-ui/themes"; +import happyHog from "@renderer/assets/images/hedgehogs/happy-hog.png"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { logger } from "@utils/logger"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useMemo, useState } from "react"; +import { OnboardingHogTip } from "./OnboardingHogTip"; +import { StepActions } from "./StepActions"; + +import "./ProjectSelect.css"; + +const log = logger.scope("project-select-step"); + +interface ProjectSelectStepProps { + onNext: () => void; + onBack: () => void; +} + +export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { + const isAuthenticated = + useAuthStateValue((state) => state.status) === "authenticated"; + const selectProjectMutation = useSelectProjectMutation(); + const currentProjectId = useAuthStateValue((state) => state.projectId); + const { projects, currentProject, currentUser, isLoading } = useProjects(); + const [projectOpen, setProjectOpen] = useState(false); + const [orgOpen, setOrgOpen] = useState(false); + const [isSwitchingOrg, setIsSwitchingOrg] = useState(false); + + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const { data: fullUser } = useCurrentUser({ client }); + + const organizations = useMemo(() => { + if (!fullUser?.organizations) return []; + return fullUser.organizations as Array<{ + id: string; + name: string; + slug: string; + }>; + }, [fullUser]); + + const currentOrg = fullUser?.organization as + | { id: string; name: string } + | undefined; + const hasMultipleOrgs = organizations.length > 1; + + const switchOrgMutation = useMutation({ + mutationFn: async (orgId: string) => { + if (!client) return; + await client.switchOrganization(orgId); + await queryClient.invalidateQueries({ + queryKey: authKeys.currentUsers(), + }); + }, + onMutate: () => { + setIsSwitchingOrg(true); + }, + onError: (err) => { + setIsSwitchingOrg(false); + log.error("Failed to switch organization", err); + }, + }); + + useEffect(() => { + if (isSwitchingOrg && !switchOrgMutation.isPending && !isLoading) { + setIsSwitchingOrg(false); + } + }, [isSwitchingOrg, switchOrgMutation.isPending, isLoading]); + + return ( + + + + + {/* Header + form */} + + {/* Section 1: Sign in */} + + + {isAuthenticated ? ( + + + + Pick your home base + + {!isLoading && !isSwitchingOrg && ( + + + + Signed in as {currentUser?.email} + + + )} + + + ) : ( + + + + Sign in to PostHog + + + + + + )} + + + + {/* Sections 2+3: Org & project selectors (authenticated only) */} + {isAuthenticated && (isLoading || isSwitchingOrg) && ( + + + + )} + + {isAuthenticated && !isSwitchingOrg && hasMultipleOrgs && ( + + + + Organization + + + + + + + + + + + + No organizations found. + + {[...organizations] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((org) => ( + { + if (org.id !== currentOrg?.id) { + switchOrgMutation.mutate(org.id); + } + setOrgOpen(false); + }} + > + + + {org.name} + + {org.id === currentOrg?.id && ( + + )} + + + ))} + + + + + + + )} + + {/* Section 3: Project selector (only when authenticated, not switching, and loaded) */} + {isAuthenticated && !isSwitchingOrg && !isLoading && ( + + + + Project + + + + + + + + + + No projects found. + {[...projects] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((project) => ( + { + selectProjectMutation.mutate(project.id); + setProjectOpen(false); + }} + > + + + {project.name} + + {project.id === currentProjectId && ( + + )} + + + ))} + + + + + + + )} + + + {/* Hog tip */} + {isAuthenticated && !isLoading && !isSwitchingOrg && ( + + )} + + + + + + {isAuthenticated && !isLoading && ( + + )} + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx b/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx index 561ab992b..52ea46f68 100644 --- a/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx @@ -1,12 +1,13 @@ import { DataSourceSetup } from "@features/inbox/components/DataSourceSetup"; import { SignalSourceToggles } from "@features/inbox/components/SignalSourceToggles"; import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; -import { useMeQuery } from "@hooks/useMeQuery"; import { ArrowLeft, ArrowRight } from "@phosphor-icons/react"; import { Button, Flex, Text } from "@radix-ui/themes"; -import codeLogo from "@renderer/assets/images/code.svg"; +import detectiveHog from "@renderer/assets/images/hedgehogs/detective-hog.png"; import { useQueryClient } from "@tanstack/react-query"; import { motion } from "framer-motion"; +import { OnboardingHogTip } from "./OnboardingHogTip"; +import { StepActions } from "./StepActions"; interface SignalsStepProps { onNext: () => void; @@ -24,15 +25,9 @@ export function SignalsStep({ onNext, onBack }: SignalsStepProps) { handleSetup, handleSetupComplete, handleSetupCancel, - evaluations, - evaluationsUrl, - handleToggleEvaluation, } = useSignalSourceManager(); - const { data: me } = useMeQuery(); - const isStaff = me?.is_staff ?? false; const anyEnabled = - displayValues.session_replay || displayValues.error_tracking || displayValues.github || displayValues.linear || @@ -51,120 +46,126 @@ export function SignalsStep({ onNext, onBack }: SignalsStepProps) { - PostHog - - - - + {/* Header + content */} + + - Enable Inbox - - - Inbox automatically analyzes your product data and prioritizes - actionable tasks. Choose which sources to enable for this - project. - + + + Teach your agents what matters + + + Signals watch your product data around the clock and surface + the highest-impact work straight to your Inbox. + + + + + + {setupSource ? ( + void handleSetupComplete()} + onCancel={handleSetupCancel} + /> + ) : ( + + void handleToggle(source, enabled) + } + disabled={isLoading} + sourceStates={sourceStates} + onSetup={handleSetup} + /> + )} + - {setupSource ? ( - void handleSetupComplete()} - onCancel={handleSetupCancel} - /> - ) : ( - - void handleToggle(source, enabled) - } - disabled={isLoading} - sourceStates={sourceStates} - onSetup={handleSetup} - evaluations={isStaff ? evaluations : undefined} - evaluationsUrl={isStaff ? evaluationsUrl : undefined} - onToggleEvaluation={ - isStaff - ? (id, enabled) => void handleToggleEvaluation(id, enabled) - : undefined - } - /> - )} + {/* Hog tip */} + + - + + {anyEnabled ? ( + - {anyEnabled ? ( - - ) : ( - - )} - - - + Continue + + + ) : ( + + )} + ); diff --git a/apps/code/src/renderer/features/onboarding/components/StepActions.tsx b/apps/code/src/renderer/features/onboarding/components/StepActions.tsx new file mode 100644 index 000000000..24a0f033e --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/StepActions.tsx @@ -0,0 +1,23 @@ +import { Flex } from "@radix-ui/themes"; +import { motion } from "framer-motion"; +import type { ReactNode } from "react"; + +interface StepActionsProps { + children: ReactNode; + delay?: number; +} + +export function StepActions({ children, delay = 0.15 }: StepActionsProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/StepIndicator.tsx b/apps/code/src/renderer/features/onboarding/components/StepIndicator.tsx index 17d1d45ac..7cf01b8db 100644 --- a/apps/code/src/renderer/features/onboarding/components/StepIndicator.tsx +++ b/apps/code/src/renderer/features/onboarding/components/StepIndicator.tsx @@ -10,11 +10,12 @@ export function StepIndicator({ currentStep, activeSteps, }: StepIndicatorProps) { - const currentIndex = activeSteps.indexOf(currentStep); + const displaySteps = activeSteps; + const currentIndex = displaySteps.indexOf(currentStep); return ( - {activeSteps.map((step, index) => ( + {displaySteps.map((step, index) => (
, + title: "Your signals inbox", + description: + "Automatically surfaces the highest-impact work from your product data so you always know what to do next.", + }, + { + icon: , + title: "Product data as context", + description: + "Your agents have context from your analytics, session replays and feature flags built in.", + }, + { + icon: , + title: "Any model, any harness", + description: + "Bring your own agent framework or use our built-in harnesses. Swap models without changing your workflow.", + }, + { + icon: , + title: "Ship work, not messages", + description: + "Run tasks in parallel across local and cloud environments. Work gets done whether you're watching or not.", + }, + { + icon: , + title: "Review and ship with confidence", + description: + "Inline diffs, AI-assisted code review and automated pull request creation in one flow.", + }, +]; + +interface WelcomeScreenProps { + onNext: () => void; +} + +const CYCLE_INTERVAL_MS = 2500; +const CYCLE_START_DELAY_MS = FEATURES.length * 100 + 400; + +export function WelcomeScreen({ onNext }: WelcomeScreenProps) { + const [activeIndex, setActiveIndex] = useState(-1); + const timerRef = useRef>(null); + + const startCycling = useCallback(() => { + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setActiveIndex((prev) => (prev + 1) % FEATURES.length); + }, CYCLE_INTERVAL_MS); + }, []); + + useEffect(() => { + const timeout = setTimeout(() => { + setActiveIndex(0); + startCycling(); + }, CYCLE_START_DELAY_MS); + + return () => { + clearTimeout(timeout); + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [startCycling]); + + const handleMouseEnter = (index: number) => { + setActiveIndex(index); + if (timerRef.current) clearInterval(timerRef.current); + }; + + const handleMouseLeave = () => { + startCycling(); + }; + + return ( + + + + + + + Welcome to PostHog Code + + + Your product workbench. + + + + + {FEATURES.map((feature, index) => ( + handleMouseEnter(index)} + onMouseLeave={handleMouseLeave} + /> + ))} + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/WelcomeStep.tsx b/apps/code/src/renderer/features/onboarding/components/WelcomeStep.tsx deleted file mode 100644 index b9b0c1d7e..000000000 --- a/apps/code/src/renderer/features/onboarding/components/WelcomeStep.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - ArrowRight, - Cloud, - CodeBlock, - GitPullRequest, - Robot, - Stack, -} from "@phosphor-icons/react"; -import { Button, Flex } from "@radix-ui/themes"; -import codeLogo from "@renderer/assets/images/code.svg"; -import { FeatureListItem } from "./FeatureListItem"; - -interface WelcomeStepProps { - onNext: () => void; -} - -const FEATURES = [ - { - icon: , - title: "Use any agent or harness", - description: - "Bring your own agent framework or use our built-in harnesses to get started fast.", - }, - { - icon: , - title: "Run your agent anywhere", - description: - "Work locally, in a worktree, or spin up cloud environments on demand.", - }, - { - icon: , - title: "Review your code", - description: - "Inline diffs, focused reviews, and AI-assisted code understanding.", - }, - { - icon: , - title: "Create pull requests", - description: - "Go from task to PR with automated branch management and descriptions.", - }, - { - icon: , - title: "Run many agents at once", - description: - "Parallelise work across multiple agents tackling different tasks simultaneously.", - }, -]; - -export function WelcomeStep({ onNext }: WelcomeStepProps) { - return ( - - - - PostHog Code - - - - - {FEATURES.map((feature) => ( - - ))} - - - - - - - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/components/context-collection/ParticleBackground.tsx b/apps/code/src/renderer/features/onboarding/components/context-collection/ParticleBackground.tsx new file mode 100644 index 000000000..7c9e68b4d --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/context-collection/ParticleBackground.tsx @@ -0,0 +1,77 @@ +import { + type ISourceOptions, + MoveDirection, + OutMode, +} from "@tsparticles/engine"; +import Particles, { initParticlesEngine } from "@tsparticles/react"; +import { loadSlim } from "@tsparticles/slim"; +import { useEffect, useMemo, useState } from "react"; + +const particleOptions: ISourceOptions = { + fullScreen: false, + fpsLimit: 60, + particles: { + number: { + value: 60, + density: { + enable: true, + }, + }, + color: { + value: "#8a8a8a", + }, + links: { + enable: true, + distance: 150, + color: "#999999", + opacity: 0.25, + width: 1, + }, + move: { + enable: true, + speed: 0.8, + direction: MoveDirection.none, + outModes: { + default: OutMode.bounce, + }, + }, + size: { + value: { min: 1.5, max: 3.5 }, + }, + opacity: { + value: 0.35, + }, + }, + interactivity: { + events: { + onHover: { enable: false }, + onClick: { enable: false }, + }, + }, + detectRetina: true, +}; + +export function ParticleBackground() { + const [ready, setReady] = useState(false); + + useEffect(() => { + initParticlesEngine(async (engine) => { + await loadSlim(engine); + }).then(() => setReady(true)); + }, []); + + const options = useMemo(() => particleOptions, []); + + if (!ready) return null; + + return ( + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/context-collection/SourceFeed.tsx b/apps/code/src/renderer/features/onboarding/components/context-collection/SourceFeed.tsx new file mode 100644 index 000000000..eb702a8e9 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/context-collection/SourceFeed.tsx @@ -0,0 +1,18 @@ +import { Flex } from "@radix-ui/themes"; +import type { SourceState } from "../../hooks/useContextCollection"; + +import { SourceSlot } from "./SourceSlot"; + +interface SourceFeedProps { + sources: SourceState[]; +} + +export function SourceFeed({ sources }: SourceFeedProps) { + return ( + + {sources.map((source) => ( + + ))} + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/context-collection/SourceSlot.tsx b/apps/code/src/renderer/features/onboarding/components/context-collection/SourceSlot.tsx new file mode 100644 index 000000000..839a1756d --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/context-collection/SourceSlot.tsx @@ -0,0 +1,149 @@ +import { CheckCircle, CircleNotch } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { AnimatePresence, motion } from "framer-motion"; +import type { SourceState } from "../../hooks/useContextCollection"; + +interface SourceSlotProps { + source: SourceState; +} + +export function SourceSlot({ source }: SourceSlotProps) { + const { config, status, currentItem, currentCount } = source; + const Icon = config.icon; + const isScanning = status === "scanning"; + const isDone = status === "done"; + const isWaiting = status === "waiting"; + + return ( + + {/* Icon + label (fixed left side) */} + + + {isDone ? ( + + + + ) : ( + + )} + + + {config.label} + + + + {/* Right side: ephemeral item or summary — fixed layout with absolute positioning */} +
+ + {isScanning && currentItem && ( + + + + + + {currentItem} + + + )} + + {isDone && ( + + + {currentCount.toLocaleString()} items + + + )} + +
+
+ ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx b/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx new file mode 100644 index 000000000..c82c3ae7f --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/components/context-collection/SuggestedTasks.tsx @@ -0,0 +1,156 @@ +import type { Icon } from "@phosphor-icons/react"; +import { + ArrowRight, + Bug, + TestTube, + Warning, + Wrench, +} from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { motion } from "framer-motion"; + +interface TaskCard { + id: string; + title: string; + description: string; + context: string; + icon: Icon; + color: string; +} + +const MOCK_TASKS: TaskCard[] = [ + { + id: "fix-checkout-typeerror", + title: "Fix TypeError in checkout flow", + description: + "Cannot read property 'id' of undefined in checkout.ts:142. Spiked 3x this week, failing 12% of purchase sessions.", + context: "Based on 83 error events and 342 session recordings", + icon: Bug, + color: "red", + }, + { + id: "add-error-handling-payments", + title: "Add error handling to payments API", + description: + "The /api/payments/create endpoint has no try-catch around the Stripe call. 23 unhandled rejections logged this week.", + context: "Based on 23 unhandled errors in PostHog", + icon: Warning, + color: "orange", + }, + { + id: "write-tests-auth-migration", + title: "Write tests for auth migration", + description: + "PR #156 migrates session auth to OAuth2 but has 0% test coverage. 4 critical paths are untested.", + context: "Based on GitHub PR #156 and 0 test files", + icon: TestTube, + color: "violet", + }, + { + id: "fix-n-plus-one-dashboard", + title: "Fix N+1 query in dashboard endpoint", + description: + "GET /api/dashboards loads each widget individually. P95 latency is 4.2s — could be under 200ms with a single query.", + context: "Based on 1,891 slow requests in session data", + icon: Wrench, + color: "green", + }, +]; + +interface SuggestedTasksProps { + onSelectTask: (taskId: string) => void; +} + +export function SuggestedTasks({ onSelectTask }: SuggestedTasksProps) { + return ( + + {MOCK_TASKS.map((task, index) => { + const TaskIcon = task.icon; + return ( + onSelectTask(task.id)} + type="button" + style={{ + display: "flex", + alignItems: "flex-start", + gap: 14, + padding: "16px 18px", + backgroundColor: "var(--color-panel-solid)", + border: "1px solid var(--gray-a3)", + borderRadius: 12, + boxShadow: + "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)", + cursor: "pointer", + textAlign: "left", + width: "100%", + transition: "border-color 0.15s ease, box-shadow 0.15s ease", + }} + whileHover={{ + borderColor: `var(--${task.color}-6)`, + boxShadow: + "0 2px 8px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04)", + }} + > + + + + + + + {task.title} + + + + + {task.description} + + + {task.context} + + + + ); + })} + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useContextCollection.ts b/apps/code/src/renderer/features/onboarding/hooks/useContextCollection.ts new file mode 100644 index 000000000..b2d2abd67 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/hooks/useContextCollection.ts @@ -0,0 +1,278 @@ +import type { Icon } from "@phosphor-icons/react"; +import { + Bug, + ChartLine, + GithubLogo, + GitPullRequest, + Kanban, + Monitor, + Ticket, +} from "@phosphor-icons/react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface SourceConfig { + id: string; + label: string; + icon: Icon; + color: string; + mockItems: string[]; + targetCount: number; + startDelay: number; + scanDuration: number; +} + +export interface SourceState { + config: SourceConfig; + status: "waiting" | "scanning" | "done"; + currentItem: string | null; + currentCount: number; +} + +const SOURCES: SourceConfig[] = [ + { + id: "ph-events", + label: "PostHog Events", + icon: ChartLine, + color: "orange", + targetCount: 1247, + startDelay: 300, + scanDuration: 6000, + mockItems: [ + "$pageview on /pricing", + "$autocapture button click", + "user_signed_up", + "$pageview on /docs", + "feature_flag_called", + "insight_viewed", + "$pageleave on /settings", + "dashboard_loaded", + "invite_sent", + "export_started", + ], + }, + { + id: "ph-errors", + label: "PostHog Errors", + icon: Bug, + color: "red", + targetCount: 83, + startDelay: 800, + scanDuration: 4500, + mockItems: [ + "TypeError: Cannot read 'id' of undefined", + "ChunkLoadError in checkout.js", + "NetworkError: Failed to fetch", + "RangeError: Maximum call stack exceeded", + "SyntaxError: Unexpected token", + "ReferenceError: session is not defined", + ], + }, + { + id: "ph-sessions", + label: "Session Recordings", + icon: Monitor, + color: "amber", + targetCount: 342, + startDelay: 1200, + scanDuration: 5000, + mockItems: [ + "Session 3m 42s — /checkout flow", + "Session 1m 15s — /onboarding", + "Session 8m 03s — /dashboard", + "Session 0m 45s — /pricing → bounce", + "Session 5m 21s — /settings → /billing", + ], + }, + { + id: "gh-issues", + label: "GitHub Issues", + icon: GithubLogo, + color: "gray", + targetCount: 156, + startDelay: 2000, + scanDuration: 5000, + mockItems: [ + "#423: Fix checkout flow regression", + "#891: Add dark mode support", + "#1024: Improve onboarding UX", + "#756: API rate limiting not working", + "#1102: Mobile layout broken on iPad", + "#943: Add webhook retry logic", + ], + }, + { + id: "gh-prs", + label: "GitHub PRs", + icon: GitPullRequest, + color: "gray", + targetCount: 89, + startDelay: 2500, + scanDuration: 4000, + mockItems: [ + "PR #156: Migrate auth to OAuth2", + "PR #203: Update billing page", + "PR #187: Fix N+1 query in dashboard", + "PR #211: Add feature flag for new UI", + "PR #198: Refactor event pipeline", + ], + }, + { + id: "linear", + label: "Linear Issues", + icon: Kanban, + color: "violet", + targetCount: 64, + startDelay: 3000, + scanDuration: 3500, + mockItems: [ + "ENG-342: Implement SSO", + "ENG-401: Migrate to new API", + "ENG-389: Fix flaky test suite", + "ENG-415: Add audit logging", + "ENG-378: Optimize query performance", + ], + }, + { + id: "zendesk", + label: "Zendesk Tickets", + icon: Ticket, + color: "green", + targetCount: 31, + startDelay: 3500, + scanDuration: 3000, + mockItems: [ + "Ticket: Can't export CSV from dashboard", + "Ticket: SSO login failing intermittently", + "Ticket: Feature request — dark mode", + "Ticket: Billing page shows wrong plan", + "Ticket: API docs outdated for v2", + ], + }, +]; + +const PHASES: { time: number; text: string }[] = [ + { time: 0, text: "Connecting to your data..." }, + { time: 1500, text: "Scanning PostHog events..." }, + { time: 3000, text: "Reading GitHub issues..." }, + { time: 5500, text: "Analyzing patterns..." }, + { time: 7000, text: "Finding priorities..." }, +]; + +const TICK_MS = 100; +const ITEM_CYCLE_MS = 1500; + +export function useContextCollection() { + const [sources, setSources] = useState(() => + SOURCES.map((config) => ({ + config, + status: "waiting" as const, + currentItem: null, + currentCount: 0, + })), + ); + const [phase, setPhase] = useState("Connecting to your data..."); + const [isAllDone, setIsAllDone] = useState(false); + + const elapsedRef = useRef(0); + const itemCycleRef = useRef>(new Map()); + const itemIndexRef = useRef>(new Map()); + + const tick = useCallback(() => { + elapsedRef.current += TICK_MS; + const elapsed = elapsedRef.current; + + // Update phase text + for (const p of PHASES) { + if (elapsed >= p.time) { + setPhase(p.text); + } + } + + setSources((prev) => { + let changed = false; + const next = prev.map((source) => { + const { config } = source; + const sourceElapsed = elapsed - config.startDelay; + + // Not started yet + if (sourceElapsed < 0) return source; + + // Just started scanning + if (source.status === "waiting") { + changed = true; + itemIndexRef.current.set(config.id, 0); + itemCycleRef.current.set(config.id, 0); + return { + ...source, + status: "scanning" as const, + currentItem: config.mockItems[0], + }; + } + + // Done scanning + if ( + source.status === "scanning" && + sourceElapsed >= config.scanDuration + ) { + changed = true; + return { + ...source, + status: "done" as const, + currentItem: null, + currentCount: config.targetCount, + }; + } + + // Cycling items during scan + if (source.status === "scanning") { + const lastCycle = itemCycleRef.current.get(config.id) ?? 0; + if (elapsed - lastCycle >= ITEM_CYCLE_MS) { + changed = true; + itemCycleRef.current.set(config.id, elapsed); + const idx = + ((itemIndexRef.current.get(config.id) ?? 0) + 1) % + config.mockItems.length; + itemIndexRef.current.set(config.id, idx); + + // Ease-out count increment + const progress = sourceElapsed / config.scanDuration; + const easedProgress = 1 - (1 - progress) ** 2; + const newCount = Math.min( + Math.round(easedProgress * config.targetCount), + config.targetCount, + ); + + return { + ...source, + currentItem: config.mockItems[idx], + currentCount: newCount, + }; + } + } + + return source; + }); + + if (!changed) return prev; + return next; + }); + }, []); + + useEffect(() => { + const interval = setInterval(tick, TICK_MS); + return () => clearInterval(interval); + }, [tick]); + + // Check if all done + useEffect(() => { + const allDone = sources.every((s) => s.status === "done"); + if (allDone && !isAllDone) { + setIsAllDone(true); + setPhase("Ready!"); + } + }, [sources, isAllDone]); + + const totalItems = sources.reduce((sum, s) => sum + s.currentCount, 0); + + return { sources, phase, isAllDone, totalItems }; +} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts index e411d5283..d305d2251 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts @@ -1,24 +1,53 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { useEffect, useMemo } from "react"; +import { trpcClient } from "@renderer/trpc/client"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ONBOARDING_STEPS, type OnboardingStep } from "../types"; +export interface DetectedRepo { + organization: string; + repository: string; + fullName: string; + remote?: string; + branch?: string; +} + export function useOnboardingFlow() { const currentStep = useOnboardingStore((state) => state.currentStep); const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); - const billingEnabled = useFeatureFlag("twig-billing", false); + const directionRef = useRef<1 | -1>(1); + + const [selectedDirectory, setSelectedDirectory] = useState(""); + const [detectedRepo, setDetectedRepo] = useState(null); + const [isDetectingRepo, setIsDetectingRepo] = useState(false); - // Show billing onboarding steps only when billing is enabled - const activeSteps = useMemo(() => { - if (billingEnabled) { - return ONBOARDING_STEPS; + const handleDirectoryChange = useCallback(async (path: string) => { + setSelectedDirectory(path); + setDetectedRepo(null); + if (!path) return; + + setIsDetectingRepo(true); + try { + const result = await trpcClient.git.detectRepo.query({ + directoryPath: path, + }); + if (result) { + setDetectedRepo({ + organization: result.organization, + repository: result.repository, + fullName: `${result.organization}/${result.repository}`, + remote: result.remote ?? undefined, + branch: result.branch ?? undefined, + }); + } + } catch { + // Not a git repo or no remote + } finally { + setIsDetectingRepo(false); } - return ONBOARDING_STEPS.filter( - (step) => step !== "billing" && step !== "org-billing", - ); - }, [billingEnabled]); + }, []); + + const activeSteps = ONBOARDING_STEPS; - // Reset to first step if current step is no longer in active steps useEffect(() => { if (!activeSteps.includes(currentStep)) { setCurrentStep(activeSteps[0]); @@ -31,17 +60,21 @@ export function useOnboardingFlow() { const next = () => { if (!isLastStep) { + directionRef.current = 1; setCurrentStep(activeSteps[currentIndex + 1]); } }; const back = () => { if (!isFirstStep) { + directionRef.current = -1; setCurrentStep(activeSteps[currentIndex - 1]); } }; const goTo = (step: OnboardingStep) => { + const targetIndex = activeSteps.indexOf(step); + directionRef.current = targetIndex >= currentIndex ? 1 : -1; setCurrentStep(step); }; @@ -52,8 +85,13 @@ export function useOnboardingFlow() { activeSteps, isFirstStep, isLastStep, + direction: directionRef.current, next, back, goTo, + selectedDirectory, + detectedRepo, + isDetectingRepo, + handleDirectoryChange, }; } diff --git a/apps/code/src/renderer/features/onboarding/hooks/usePrefetchSignalData.ts b/apps/code/src/renderer/features/onboarding/hooks/usePrefetchSignalData.ts new file mode 100644 index 000000000..9363f9a65 --- /dev/null +++ b/apps/code/src/renderer/features/onboarding/hooks/usePrefetchSignalData.ts @@ -0,0 +1,66 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useProjects } from "@features/projects/hooks/useProjects"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; + +/** + * Prefetches onboarding step data so GitHub and Signals steps load instantly. + * Call this early in the onboarding flow (e.g. in OnboardingFlow component). + */ +export function usePrefetchSignalData(): void { + const client = useOptionalAuthenticatedClient(); + const projectId = useAuthStateValue((state) => state.projectId); + const { projects } = useProjects(); + const queryClient = useQueryClient(); + + // Prefetch per-project integrations (used by GitIntegrationStep) + useEffect(() => { + if (!client || projects.length === 0) return; + + for (const project of projects) { + queryClient.prefetchQuery({ + queryKey: ["integrations", project.id], + queryFn: () => client.getIntegrationsForProject(project.id), + staleTime: 60_000, + }); + } + }, [client, projects, queryClient]); + + // Prefetch signals data and repo list + useEffect(() => { + if (!client || !projectId) return; + + queryClient.prefetchQuery({ + queryKey: ["signals", "source-configs", projectId], + queryFn: () => client.listSignalSourceConfigs(projectId), + staleTime: 30_000, + }); + + queryClient.prefetchQuery({ + queryKey: ["external-data-sources", projectId], + queryFn: () => client.listExternalDataSources(projectId), + staleTime: 60_000, + }); + + // Prefetch integrations list, then prefetch GitHub repos if integration exists + queryClient.prefetchQuery({ + queryKey: ["integrations", "list"], + queryFn: async () => { + const integrations = await client.getIntegrations(); + const ghIntegration = ( + integrations as { id: number; kind: string }[] + ).find((i) => i.kind === "github"); + if (ghIntegration) { + queryClient.prefetchQuery({ + queryKey: ["integrations", "repositories", ghIntegration.id], + queryFn: () => client.getGithubRepositories(ghIntegration.id), + staleTime: 60_000, + }); + } + return integrations; + }, + staleTime: 60_000, + }); + }, [client, projectId, queryClient]); +} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts b/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts index e441f606a..c6da7b49e 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts @@ -1,4 +1,4 @@ -import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { AUTH_SCOPED_QUERY_META } from "@features/auth/hooks/authQueries"; import type { Integration } from "@features/integrations/stores/integrationStore"; import { useProjects } from "@features/projects/hooks/useProjects"; @@ -15,7 +15,7 @@ export interface ProjectWithIntegrations { export function useProjectsWithIntegrations() { const { projects, isLoading: projectsLoading } = useProjects(); - const client = useAuthenticatedClient(); + const client = useOptionalAuthenticatedClient(); // Fetch integrations for each project in parallel const integrationQueries = useQueries({ diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index 4d165dbd6..64c69ba3e 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -5,6 +5,7 @@ import type { OnboardingStep } from "../types"; interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; + hasCompletedSetup: boolean; isConnectingGithub: boolean; selectedPlan: "free" | "pro" | null; selectedOrgId: string | null; @@ -14,6 +15,7 @@ interface OnboardingStoreState { interface OnboardingStoreActions { setCurrentStep: (step: OnboardingStep) => void; completeOnboarding: () => void; + completeSetup: () => void; resetOnboarding: () => void; resetSelections: () => void; setConnectingGithub: (isConnecting: boolean) => void; @@ -27,6 +29,7 @@ type OnboardingStore = OnboardingStoreState & OnboardingStoreActions; const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, + hasCompletedSetup: false, isConnectingGithub: false, selectedPlan: null, selectedOrgId: null, @@ -40,6 +43,7 @@ export const useOnboardingStore = create()( setCurrentStep: (step) => set({ currentStep: step }), completeOnboarding: () => set({ hasCompletedOnboarding: true }), + completeSetup: () => set({ hasCompletedSetup: true }), resetOnboarding: () => set({ ...initialState }), resetSelections: () => set({ @@ -59,6 +63,7 @@ export const useOnboardingStore = create()( partialize: (state) => ({ currentStep: state.currentStep, hasCompletedOnboarding: state.hasCompletedOnboarding, + hasCompletedSetup: state.hasCompletedSetup, selectedPlan: state.selectedPlan, selectedOrgId: state.selectedOrgId, selectedProjectId: state.selectedProjectId, diff --git a/apps/code/src/renderer/features/onboarding/types.ts b/apps/code/src/renderer/features/onboarding/types.ts index 9b702fb63..c812a1512 100644 --- a/apps/code/src/renderer/features/onboarding/types.ts +++ b/apps/code/src/renderer/features/onboarding/types.ts @@ -1,14 +1,14 @@ export type OnboardingStep = | "welcome" - | "billing" - | "org-billing" - | "git-integration" + | "project-select" + | "github" + | "install-cli" | "signals"; export const ONBOARDING_STEPS: OnboardingStep[] = [ "welcome", - "billing", - "org-billing", - "git-integration", + "project-select", + "github", + "install-cli", "signals", ]; diff --git a/apps/code/src/renderer/features/projects/hooks/useProjects.tsx b/apps/code/src/renderer/features/projects/hooks/useProjects.tsx index e7406bcc0..d9bf2bd1b 100644 --- a/apps/code/src/renderer/features/projects/hooks/useProjects.tsx +++ b/apps/code/src/renderer/features/projects/hooks/useProjects.tsx @@ -84,18 +84,34 @@ export function useProjects() { const currentProject = projects.find((p) => p.id === currentProjectId); const groupedProjects = groupProjectsByOrg(projects); + const userTeamId = + currentUser?.team && typeof currentUser.team === "object" + ? (currentUser.team as { id: number }).id + : null; + useEffect(() => { if (projects.length > 0 && !currentProject) { - log.info("Auto-selecting first available project", { - projectId: projects[0].id, + const preferredProject = + (userTeamId && projects.find((p) => p.id === userTeamId)) || + projects[0]; + log.info("Auto-selecting project", { + projectId: preferredProject.id, + source: + preferredProject.id === userTeamId ? "user-team" : "first-available", reason: currentProjectId == null ? "no project selected" : "current project not found in list", }); - selectProjectMutation.mutate(projects[0].id); + selectProjectMutation.mutate(preferredProject.id); } - }, [currentProject, currentProjectId, projects, selectProjectMutation]); + }, [ + currentProject, + currentProjectId, + projects, + selectProjectMutation, + userTeamId, + ]); return { projects, diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx index 53fe39ce9..d9eb28a00 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx @@ -1,7 +1,6 @@ import { DataSourceSetup } from "@features/inbox/components/DataSourceSetup"; import { SignalSourceToggles } from "@features/inbox/components/SignalSourceToggles"; import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; -import { useMeQuery } from "@hooks/useMeQuery"; import { Flex, Text } from "@radix-ui/themes"; export function SignalSourcesSettings() { @@ -15,12 +14,7 @@ export function SignalSourcesSettings() { handleSetup, handleSetupComplete, handleSetupCancel, - evaluations, - evaluationsUrl, - handleToggleEvaluation, } = useSignalSourceManager(); - const { data: me } = useMeQuery(); - const isStaff = me?.is_staff ?? false; if (isLoading) { return ( @@ -50,13 +44,6 @@ export function SignalSourcesSettings() { sourceStates={sourceStates} sessionAnalysisStatus={sessionAnalysisStatus} onSetup={handleSetup} - evaluations={isStaff ? evaluations : undefined} - evaluationsUrl={isStaff ? evaluationsUrl : undefined} - onToggleEvaluation={ - isStaff - ? (id, enabled) => void handleToggleEvaluation(id, enabled) - : undefined - } /> )} diff --git a/apps/code/src/renderer/features/setup/components/SetupView.tsx b/apps/code/src/renderer/features/setup/components/SetupView.tsx new file mode 100644 index 000000000..05517a11f --- /dev/null +++ b/apps/code/src/renderer/features/setup/components/SetupView.tsx @@ -0,0 +1,199 @@ +import { DotPatternBackground } from "@components/DotPatternBackground"; +import { SourceFeed } from "@features/onboarding/components/context-collection/SourceFeed"; +import { SuggestedTasks } from "@features/onboarding/components/context-collection/SuggestedTasks"; +import { useContextCollection } from "@features/onboarding/hooks/useContextCollection"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { Rocket } from "@phosphor-icons/react"; +import { Box, Button, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; +import { useNavigationStore } from "@stores/navigationStore"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; + +export function SetupView() { + const { sources, phase, isAllDone, totalItems } = useContextCollection(); + const [showTasks, setShowTasks] = useState(false); + const completeSetup = useOnboardingStore((state) => state.completeSetup); + const navigateToTaskInput = useNavigationStore( + (state) => state.navigateToTaskInput, + ); + + useSetHeaderContent( + + + + Finish setup + + , + ); + + useEffect(() => { + if (!isAllDone) return; + const timeout = setTimeout(() => setShowTasks(true), 800); + return () => clearTimeout(timeout); + }, [isAllDone]); + + const handleSelectTask = () => { + completeSetup(); + navigateToTaskInput(); + }; + + const handleSkip = () => { + completeSetup(); + navigateToTaskInput(); + }; + + return ( + + + + + + {!showTasks ? ( + + + + + + Building your context... + + + Scanning your data sources for insights and priorities. + + + + + + + + + + + + + + + {phase} + {isAllDone && ( + + {totalItems.toLocaleString()} items across{" "} + {sources.length} sources + + )} + + + + + + + + ) : ( + + + + + Here's what we found + + + Based on {totalItems.toLocaleString()} items across your + data sources, we recommend starting with one of these: + + + + + + + + + + + )} + + + + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index ad1542982..e4fae2329 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -21,6 +21,7 @@ import { import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; import { trpcClient } from "@renderer/trpc/client"; import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { EXTERNAL_LINKS } from "@utils/links"; import { isMac } from "@utils/platform"; import { useState } from "react"; import "./ProjectSwitcher.css"; @@ -95,7 +96,7 @@ export function ProjectSwitcher() { const handleDiscord = async () => { await trpcClient.os.openExternal.mutate({ - url: "https://discord.gg/c3qYyJXSWp", + url: EXTERNAL_LINKS.discord, }); setPopoverOpen(false); }; @@ -184,18 +185,14 @@ export function ProjectSwitcher() { - handleOpenExternal("https://posthog.com/code") - } + onClick={() => handleOpenExternal(EXTERNAL_LINKS.website)} > PostHog Code Website - handleOpenExternal("https://posthog.com/privacy") - } + onClick={() => handleOpenExternal(EXTERNAL_LINKS.privacy)} > Privacy Policy diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index f56771b46..96de34748 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -5,6 +5,7 @@ import { INBOX_PIPELINE_STATUS_FILTER, INBOX_REFETCH_INTERVAL_MS, } from "@features/inbox/utils/inboxConstants"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { getSessionService } from "@features/sessions/service/service"; import { archiveTaskImperative, @@ -27,6 +28,7 @@ import { useSidebarData } from "../hooks/useSidebarData"; import { useTaskViewed } from "../hooks/useTaskViewed"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; +import { SetupItem } from "./items/SetupItem"; import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; import { TaskListView } from "./TaskListView"; @@ -39,6 +41,7 @@ function SidebarMenuComponent() { navigateToInbox, navigateToCommandCenter, navigateToSkills, + navigateToSetup, } = useNavigationStore(); const { data: allTasks = [] } = useTasks(); @@ -51,6 +54,10 @@ function SidebarMenuComponent() { const { archiveTask } = useArchiveTask(); const { togglePin } = usePinnedTasks(); + const hasCompletedSetup = useOnboardingStore( + (state) => state.hasCompletedSetup, + ); + const sidebarData = useSidebarData({ activeView: view, }); @@ -117,6 +124,10 @@ function SidebarMenuComponent() { navigateToSkills(); }; + const handleSetupClick = () => { + navigateToSetup(); + }; + const handleTaskClick = (taskId: string) => { const task = taskMap.get(taskId); if (task) { @@ -261,6 +272,15 @@ function SidebarMenuComponent() { /> + {!hasCompletedSetup && ( + + + + )} + - ) : ( - - ) - } + icon={} isExpanded={isExpanded} onToggle={() => toggleSection(group.id)} addSpacingBefore={false} diff --git a/apps/code/src/renderer/features/sidebar/components/items/SetupItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/SetupItem.tsx new file mode 100644 index 000000000..55146fece --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/items/SetupItem.tsx @@ -0,0 +1,45 @@ +import { Rocket } from "@phosphor-icons/react"; + +interface SetupItemProps { + isActive: boolean; + onClick: () => void; +} + +export function SetupItem({ isActive, onClick }: SetupItemProps) { + return ( + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index cf8d21cab..44b8e6b8d 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -43,6 +43,7 @@ export interface SidebarData { isInboxActive: boolean; isCommandCenterActive: boolean; isSkillsActive: boolean; + isSetupActive: boolean; isLoading: boolean; activeTaskId: string | null; pinnedTasks: TaskData[]; @@ -61,7 +62,8 @@ interface ViewState { | "inbox" | "archived" | "command-center" - | "skills"; + | "skills" + | "setup"; data?: Task; } @@ -171,6 +173,7 @@ export function useSidebarData({ const isInboxActive = activeView.type === "inbox"; const isCommandCenterActive = activeView.type === "command-center"; const isSkillsActive = activeView.type === "skills"; + const isSetupActive = activeView.type === "setup"; const activeTaskId = activeView.type === "task-detail" && activeView.data @@ -280,6 +283,7 @@ export function useSidebarData({ isInboxActive, isCommandCenterActive, isSkillsActive, + isSetupActive, isLoading, activeTaskId, pinnedTasks, diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 2f84abfb4..6b0604cf6 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -1,3 +1,4 @@ +import { DotPatternBackground } from "@components/DotPatternBackground"; import { EnvironmentSelector } from "@features/environments/components/EnvironmentSelector"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; @@ -37,8 +38,6 @@ import { useTaskCreation } from "../hooks/useTaskCreation"; import { TaskInputEditor } from "./TaskInputEditor"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; -const DOT_FILL = "var(--gray-6)"; - interface TaskInputProps { sessionId?: string; onTaskCreated?: (task: import("@shared/types").Task) => void; @@ -379,37 +378,7 @@ export function TaskInput({ height="100%" style={{ position: "relative" }} > - + ({ @@ -57,7 +57,7 @@ function useAllGithubRepositories(githubIntegrations: Integration[]) { for (const result of results) { if (result.isPending) pending = true; if (!result.data) continue; - for (const repo of result.data.repos) { + for (const repo of result.data.repos ?? []) { if (!(repo in map)) { map[repo] = result.data.integrationId; } diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 336c77a2b..e8c103dd7 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -19,7 +19,8 @@ type ViewType = | "inbox" | "archived" | "command-center" - | "skills"; + | "skills" + | "setup"; interface ViewState { type: ViewType; @@ -39,6 +40,7 @@ interface NavigationStore { navigateToArchived: () => void; navigateToCommandCenter: () => void; navigateToSkills: () => void; + navigateToSetup: () => void; goBack: () => void; goForward: () => void; canGoBack: () => boolean; @@ -69,6 +71,9 @@ const isSameView = (view1: ViewState, view2: ViewState): boolean => { if (view1.type === "skills" && view2.type === "skills") { return true; } + if (view1.type === "setup" && view2.type === "setup") { + return true; + } return false; }; @@ -183,6 +188,10 @@ export const useNavigationStore = create()( navigate({ type: "skills" }); }, + navigateToSetup: () => { + navigate({ type: "setup" }); + }, + goBack: () => { const { history, historyIndex } = get(); if (historyIndex > 0) { diff --git a/apps/code/src/renderer/utils/links.ts b/apps/code/src/renderer/utils/links.ts new file mode 100644 index 000000000..5d2bc306d --- /dev/null +++ b/apps/code/src/renderer/utils/links.ts @@ -0,0 +1,7 @@ +export const EXTERNAL_LINKS = { + discord: "https://discord.gg/posthog", + ghInstall: "https://cli.github.com/", + gitInstall: "https://git-scm.com/install/mac", + privacy: "https://posthog.com/privacy", + website: "https://posthog.com/code", +} as const; diff --git a/apps/code/tests/e2e/tests/smoke.spec.ts b/apps/code/tests/e2e/tests/smoke.spec.ts index 393e9309f..b442cbe65 100644 --- a/apps/code/tests/e2e/tests/smoke.spec.ts +++ b/apps/code/tests/e2e/tests/smoke.spec.ts @@ -20,6 +20,12 @@ test.describe("Smoke Tests", () => { .waitFor({ state: "hidden", timeout: 30000 }) .catch(() => {}); + const hasOnboarding = await window + .locator("text=Welcome to PostHog Code") + .first() + .isVisible() + .catch(() => false); + const hasAuthScreen = await window .locator("text=Sign in") .first() @@ -38,7 +44,8 @@ test.describe("Smoke Tests", () => { .isVisible() .catch(() => false); - const isValidBootState = hasAuthScreen || hasMainLayout || hasSettings; + const isValidBootState = + hasOnboarding || hasAuthScreen || hasMainLayout || hasSettings; expect(isValidBootState).toBe(true); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2364f028..f6eccf416 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,15 @@ importers: '@trpc/tanstack-react-query': specifier: ^11.12.0 version: 11.12.0(@tanstack/react-query@5.90.20(react@19.1.0))(@trpc/client@11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.12.0(typescript@5.9.3))(react@19.1.0)(typescript@5.9.3) + '@tsparticles/engine': + specifier: ^3.9.1 + version: 3.9.1 + '@tsparticles/react': + specifier: ^3.0.0 + version: 3.0.0(@tsparticles/engine@3.9.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tsparticles/slim': + specifier: ^3.9.1 + version: 3.9.1 '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -5097,6 +5106,121 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tsparticles/basic@3.9.1': + resolution: {integrity: sha512-ijr2dHMx0IQHqhKW3qA8tfwrR2XYbbWYdaJMQuBo2CkwBVIhZ76U+H20Y492j/NXpd1FUnt2aC0l4CEVGVGdeQ==} + + '@tsparticles/engine@3.9.1': + resolution: {integrity: sha512-DpdgAhWMZ3Eh2gyxik8FXS6BKZ8vyea+Eu5BC4epsahqTGY9V3JGGJcXC6lRJx6cPMAx1A0FaQAojPF3v6rkmQ==} + + '@tsparticles/interaction-external-attract@3.9.1': + resolution: {integrity: sha512-5AJGmhzM9o4AVFV24WH5vSqMBzOXEOzIdGLIr+QJf4fRh9ZK62snsusv/ozKgs2KteRYQx+L7c5V3TqcDy2upg==} + + '@tsparticles/interaction-external-bounce@3.9.1': + resolution: {integrity: sha512-bv05+h70UIHOTWeTsTI1AeAmX6R3s8nnY74Ea6p6AbQjERzPYIa0XY19nq/hA7+Nrg+EissP5zgoYYeSphr85A==} + + '@tsparticles/interaction-external-bubble@3.9.1': + resolution: {integrity: sha512-tbd8ox/1GPl+zr+KyHQVV1bW88GE7OM6i4zql801YIlCDrl9wgTDdDFGIy9X7/cwTvTrCePhrfvdkUamXIribQ==} + + '@tsparticles/interaction-external-connect@3.9.1': + resolution: {integrity: sha512-sq8YfUNsIORjXHzzW7/AJQtfi/qDqLnYG2qOSE1WOsog39MD30RzmiOloejOkfNeUdcGUcfsDgpUuL3UhzFUOA==} + + '@tsparticles/interaction-external-grab@3.9.1': + resolution: {integrity: sha512-QwXza+sMMWDaMiFxd8y2tJwUK6c+nNw554+/9+tEZeTTk2fCbB0IJ7p/TH6ZGWDL0vo2muK54Njv2fEey191ow==} + + '@tsparticles/interaction-external-pause@3.9.1': + resolution: {integrity: sha512-Gzv4/FeNir0U/tVM9zQCqV1k+IAgaFjDU3T30M1AeAsNGh/rCITV2wnT7TOGFkbcla27m4Yxa+Fuab8+8pzm+g==} + + '@tsparticles/interaction-external-push@3.9.1': + resolution: {integrity: sha512-GvnWF9Qy4YkZdx+WJL2iy9IcgLvzOIu3K7aLYJFsQPaxT8d9TF8WlpoMlWKnJID6H5q4JqQuMRKRyWH8aAKyQw==} + + '@tsparticles/interaction-external-remove@3.9.1': + resolution: {integrity: sha512-yPThm4UDWejDOWW5Qc8KnnS2EfSo5VFcJUQDWc1+Wcj17xe7vdSoiwwOORM0PmNBzdDpSKQrte/gUnoqaUMwOA==} + + '@tsparticles/interaction-external-repulse@3.9.1': + resolution: {integrity: sha512-/LBppXkrMdvLHlEKWC7IykFhzrz+9nebT2fwSSFXK4plEBxDlIwnkDxd3FbVOAbnBvx4+L8+fbrEx+RvC8diAw==} + + '@tsparticles/interaction-external-slow@3.9.1': + resolution: {integrity: sha512-1ZYIR/udBwA9MdSCfgADsbDXKSFS0FMWuPWz7bm79g3sUxcYkihn+/hDhc6GXvNNR46V1ocJjrj0u6pAynS1KQ==} + + '@tsparticles/interaction-particles-attract@3.9.1': + resolution: {integrity: sha512-CYYYowJuGwRLUixQcSU/48PTKM8fCUYThe0hXwQ+yRMLAn053VHzL7NNZzKqEIeEyt5oJoy9KcvubjKWbzMBLQ==} + + '@tsparticles/interaction-particles-collisions@3.9.1': + resolution: {integrity: sha512-ggGyjW/3v1yxvYW1IF1EMT15M6w31y5zfNNUPkqd/IXRNPYvm0Z0ayhp+FKmz70M5p0UxxPIQHTvAv9Jqnuj8w==} + + '@tsparticles/interaction-particles-links@3.9.1': + resolution: {integrity: sha512-MsLbMjy1vY5M5/hu/oa5OSRZAUz49H3+9EBMTIOThiX+a+vpl3sxc9AqNd9gMsPbM4WJlub8T6VBZdyvzez1Vg==} + + '@tsparticles/move-base@3.9.1': + resolution: {integrity: sha512-X4huBS27d8srpxwOxliWPUt+NtCwY+8q/cx1DvQxyqmTA8VFCGpcHNwtqiN+9JicgzOvSuaORVqUgwlsc7h4pQ==} + + '@tsparticles/move-parallax@3.9.1': + resolution: {integrity: sha512-whlOR0bVeyh6J/hvxf/QM3DqvNnITMiAQ0kro6saqSDItAVqg4pYxBfEsSOKq7EhjxNvfhhqR+pFMhp06zoCVA==} + + '@tsparticles/plugin-easing-quad@3.9.1': + resolution: {integrity: sha512-C2UJOca5MTDXKUTBXj30Kiqr5UyID+xrY/LxicVWWZPczQW2bBxbIbfq9ULvzGDwBTxE2rdvIB8YFKmDYO45qw==} + + '@tsparticles/plugin-hex-color@3.9.1': + resolution: {integrity: sha512-vZgZ12AjUicJvk7AX4K2eAmKEQX/D1VEjEPFhyjbgI7A65eX72M465vVKIgNA6QArLZ1DLs7Z787LOE6GOBWsg==} + + '@tsparticles/plugin-hsl-color@3.9.1': + resolution: {integrity: sha512-jJd1iGgRwX6eeNjc1zUXiJivaqC5UE+SC2A3/NtHwwoQrkfxGWmRHOsVyLnOBRcCPgBp/FpdDe6DIDjCMO715w==} + + '@tsparticles/plugin-rgb-color@3.9.1': + resolution: {integrity: sha512-SBxk7f1KBfXeTnnklbE2Hx4jBgh6I6HOtxb+Os1gTp0oaghZOkWcCD2dP4QbUu7fVNCMOcApPoMNC8RTFcy9wQ==} + + '@tsparticles/react@3.0.0': + resolution: {integrity: sha512-hjGEtTT1cwv6BcjL+GcVgH++KYs52bIuQGW3PWv7z3tMa8g0bd6RI/vWSLj7p//NZ3uTjEIeilYIUPBh7Jfq/Q==} + peerDependencies: + '@tsparticles/engine': ^3.0.2 + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@tsparticles/shape-circle@3.9.1': + resolution: {integrity: sha512-DqZFLjbuhVn99WJ+A9ajz9YON72RtCcvubzq6qfjFmtwAK7frvQeb6iDTp6Ze9FUipluxVZWVRG4vWTxi2B+/g==} + + '@tsparticles/shape-emoji@3.9.1': + resolution: {integrity: sha512-ifvY63usuT+hipgVHb8gelBHSeF6ryPnMxAAEC1RGHhhXfpSRWMtE6ybr+pSsYU52M3G9+TF84v91pSwNrb9ZQ==} + + '@tsparticles/shape-image@3.9.1': + resolution: {integrity: sha512-fCA5eme8VF3oX8yNVUA0l2SLDKuiZObkijb0z3Ky0qj1HUEVlAuEMhhNDNB9E2iELTrWEix9z7BFMePp2CC7AA==} + + '@tsparticles/shape-line@3.9.1': + resolution: {integrity: sha512-wT8NSp0N9HURyV05f371cHKcNTNqr0/cwUu6WhBzbshkYGy1KZUP9CpRIh5FCrBpTev34mEQfOXDycgfG0KiLQ==} + + '@tsparticles/shape-polygon@3.9.1': + resolution: {integrity: sha512-dA77PgZdoLwxnliH6XQM/zF0r4jhT01pw5y7XTeTqws++hg4rTLV9255k6R6eUqKq0FPSW1/WBsBIl7q/MmrqQ==} + + '@tsparticles/shape-square@3.9.1': + resolution: {integrity: sha512-DKGkDnRyZrAm7T2ipqNezJahSWs6xd9O5LQLe5vjrYm1qGwrFxJiQaAdlb00UNrexz1/SA7bEoIg4XKaFa7qhQ==} + + '@tsparticles/shape-star@3.9.1': + resolution: {integrity: sha512-kdMJpi8cdeb6vGrZVSxTG0JIjCwIenggqk0EYeKAwtOGZFBgL7eHhF2F6uu1oq8cJAbXPujEoabnLsz6mW8XaA==} + + '@tsparticles/slim@3.9.1': + resolution: {integrity: sha512-CL5cDmADU7sDjRli0So+hY61VMbdroqbArmR9Av+c1Fisa5ytr6QD7Jv62iwU2S6rvgicEe9OyRmSy5GIefwZw==} + + '@tsparticles/updater-color@3.9.1': + resolution: {integrity: sha512-XGWdscrgEMA8L5E7exsE0f8/2zHKIqnTrZymcyuFBw2DCB6BIV+5z6qaNStpxrhq3DbIxxhqqcybqeOo7+Alpg==} + + '@tsparticles/updater-life@3.9.1': + resolution: {integrity: sha512-Oi8aF2RIwMMsjssUkCB6t3PRpENHjdZf6cX92WNfAuqXtQphr3OMAkYFJFWkvyPFK22AVy3p/cFt6KE5zXxwAA==} + + '@tsparticles/updater-opacity@3.9.1': + resolution: {integrity: sha512-w778LQuRZJ+IoWzeRdrGykPYSSaTeWfBvLZ2XwYEkh/Ss961InOxZKIpcS6i5Kp/Zfw0fS1ZAuqeHwuj///Osw==} + + '@tsparticles/updater-out-modes@3.9.1': + resolution: {integrity: sha512-cKQEkAwbru+hhKF+GTsfbOvuBbx2DSB25CxOdhtW2wRvDBoCnngNdLw91rs+0Cex4tgEeibkebrIKFDDE6kELg==} + + '@tsparticles/updater-rotate@3.9.1': + resolution: {integrity: sha512-9BfKaGfp28JN82MF2qs6Ae/lJr9EColMfMTHqSKljblwbpVDHte4umuwKl3VjbRt87WD9MGtla66NTUYl+WxuQ==} + + '@tsparticles/updater-size@3.9.1': + resolution: {integrity: sha512-3NSVs0O2ApNKZXfd+y/zNhTXSFeG1Pw4peI8e6z/q5+XLbmue9oiEwoPy/tQLaark3oNj3JU7Q903ZijPyXSzw==} + + '@tsparticles/updater-stroke-color@3.9.1': + resolution: {integrity: sha512-3x14+C2is9pZYTg9T2TiA/aM1YMq4wLdYaZDcHm3qO30DZu5oeQq0rm/6w+QOGKYY1Z3Htg9rlSUZkhTHn7eDA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -16715,6 +16839,188 @@ snapshots: minimatch: 10.1.2 path-browserify: 1.0.1 + '@tsparticles/basic@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + '@tsparticles/move-base': 3.9.1 + '@tsparticles/plugin-hex-color': 3.9.1 + '@tsparticles/plugin-hsl-color': 3.9.1 + '@tsparticles/plugin-rgb-color': 3.9.1 + '@tsparticles/shape-circle': 3.9.1 + '@tsparticles/updater-color': 3.9.1 + '@tsparticles/updater-opacity': 3.9.1 + '@tsparticles/updater-out-modes': 3.9.1 + '@tsparticles/updater-size': 3.9.1 + + '@tsparticles/engine@3.9.1': {} + + '@tsparticles/interaction-external-attract@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-bounce@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-bubble@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-connect@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-grab@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-pause@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-push@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-remove@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-repulse@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-slow@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-particles-attract@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-particles-collisions@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-particles-links@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/move-base@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/move-parallax@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/plugin-easing-quad@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/plugin-hex-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/plugin-hsl-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/plugin-rgb-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/react@3.0.0(@tsparticles/engine@3.9.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tsparticles/engine': 3.9.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tsparticles/shape-circle@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-emoji@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-image@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-line@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-polygon@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-square@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-star@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/slim@3.9.1': + dependencies: + '@tsparticles/basic': 3.9.1 + '@tsparticles/engine': 3.9.1 + '@tsparticles/interaction-external-attract': 3.9.1 + '@tsparticles/interaction-external-bounce': 3.9.1 + '@tsparticles/interaction-external-bubble': 3.9.1 + '@tsparticles/interaction-external-connect': 3.9.1 + '@tsparticles/interaction-external-grab': 3.9.1 + '@tsparticles/interaction-external-pause': 3.9.1 + '@tsparticles/interaction-external-push': 3.9.1 + '@tsparticles/interaction-external-remove': 3.9.1 + '@tsparticles/interaction-external-repulse': 3.9.1 + '@tsparticles/interaction-external-slow': 3.9.1 + '@tsparticles/interaction-particles-attract': 3.9.1 + '@tsparticles/interaction-particles-collisions': 3.9.1 + '@tsparticles/interaction-particles-links': 3.9.1 + '@tsparticles/move-parallax': 3.9.1 + '@tsparticles/plugin-easing-quad': 3.9.1 + '@tsparticles/shape-emoji': 3.9.1 + '@tsparticles/shape-image': 3.9.1 + '@tsparticles/shape-line': 3.9.1 + '@tsparticles/shape-polygon': 3.9.1 + '@tsparticles/shape-square': 3.9.1 + '@tsparticles/shape-star': 3.9.1 + '@tsparticles/updater-life': 3.9.1 + '@tsparticles/updater-rotate': 3.9.1 + '@tsparticles/updater-stroke-color': 3.9.1 + + '@tsparticles/updater-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-life@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-opacity@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-out-modes@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-rotate@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-size@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-stroke-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1