singularity-forge/web/components/sf/onboarding-gate.tsx
ace-pm 172753c3b2 refactor(forge): complete gsd → forge rebrand across native, logging, and build system
- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep
- Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts
- Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests)
- Rename log prefix "gsd-db:" → "forge-db:" in template literals
- Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds
- Update CI workflow artifact names (build-native.yml)
- Verify only packages/native/* touched (no upstream pi-* packages renamed)

Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is
sf-run-specific; all gsd-prefixed logging and crate names must align with new identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:11:45 +02:00

303 lines
12 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import Image from "next/image"
import {
type WorkspaceOnboardingProviderState,
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/sf-workspace-store"
import { useDevOverrides } from "@/lib/dev-overrides"
import { useUserMode, type UserMode } from "@/lib/use-user-mode"
import { navigateToGSDView } from "@/lib/workflow-action-execution"
import { cn } from "@/lib/utils"
import { StepWelcome } from "./onboarding/step-welcome"
import { StepMode } from "./onboarding/step-mode"
import { StepProvider } from "./onboarding/step-provider"
import { StepAuthenticate } from "./onboarding/step-authenticate"
import { StepDevRoot } from "./onboarding/step-dev-root"
import { StepOptional } from "./onboarding/step-optional"
import { StepRemote } from "./onboarding/step-remote"
import { StepReady } from "./onboarding/step-ready"
import { StepProject } from "./onboarding/step-project"
// ─── Constants ──────────────────────────────────────────────────────
const WIZARD_STEPS = [
{ id: "welcome", label: "Welcome" },
{ id: "mode", label: "Mode" },
{ id: "provider", label: "Provider" },
{ id: "authenticate", label: "Auth" },
{ id: "devRoot", label: "Root" },
{ id: "optional", label: "Extras" },
{ id: "remote", label: "Remote" },
{ id: "ready", label: "Ready" },
{ id: "project", label: "Project" },
] as const
const TOTAL_STEPS = WIZARD_STEPS.length
const EMPTY_PROVIDERS: WorkspaceOnboardingProviderState[] = []
// ─── Helpers ────────────────────────────────────────────────────────
function chooseDefaultProvider(providers: WorkspaceOnboardingProviderState[]): string | null {
const unresolvedRecommended = providers.find((p) => !p.configured && p.recommended)
if (unresolvedRecommended) return unresolvedRecommended.id
const unresolved = providers.find((p) => !p.configured)
if (unresolved) return unresolved.id
return providers[0]?.id ?? null
}
// Slide animation
const slideVariants = {
enter: (dir: number) => ({ x: dir > 0 ? 50 : -50, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir < 0 ? 50 : -50, opacity: 0 }),
}
// ─── Step indicator (centered row of dots with labels) ──────────────
function StepIndicator({ current, total }: { current: number; total: number }) {
return (
<div className="flex items-center gap-1">
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className={cn(
"rounded-full transition-all duration-300",
i === current
? "h-1.5 w-5 bg-foreground"
: i < current
? "h-1.5 w-1.5 bg-foreground/40"
: "h-1.5 w-1.5 bg-foreground/10",
)}
/>
))}
</div>
)
}
// ─── Main Component ─────────────────────────────────────────────────
export function OnboardingGate() {
const workspace = useGSDWorkspaceState()
const {
refreshOnboarding,
saveApiKey,
startProviderFlow,
submitProviderFlowInput,
cancelProviderFlow,
refreshBoot,
} = useGSDWorkspaceActions()
const devOverrides = useDevOverrides()
const onboarding = workspace.boot?.onboarding
const forceVisible = devOverrides.isActive("forceOnboarding")
const isBusy = workspace.onboardingRequestState !== "idle"
// ─── Wizard state ───
const [stepIndex, setStepIndex] = useState(0)
const [direction, setDirection] = useState(0)
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
const [dismissedAfterSuccess, setDismissedAfterSuccess] = useState(false)
const [userMode, setUserMode] = useUserMode()
const [selectedMode, setSelectedMode] = useState<UserMode | null>(userMode)
const providers = onboarding?.required.providers ?? EMPTY_PROVIDERS
const effectiveSelectedProviderId = useMemo(() => {
if (onboarding?.activeFlow?.providerId) return onboarding.activeFlow.providerId
if (selectedProviderId && providers.some((p) => p.id === selectedProviderId)) return selectedProviderId
return chooseDefaultProvider(providers)
}, [onboarding?.activeFlow?.providerId, providers, selectedProviderId])
const shouldHideAfterSuccess = dismissedAfterSuccess && !onboarding?.locked && !isBusy
// Track whether auth was locked when the user arrived at step 3.
// Auto-advance only fires when auth transitions from locked → unlocked
// while the user is on the auth step — not when navigating back or
// when the provider was already configured.
const [authWasLockedOnArrival, setAuthWasLockedOnArrival] = useState(false)
const goTo = useCallback(
(target: number) => {
// When arriving at auth step, snapshot the locked state
if (target === 3 && onboarding?.locked) {
setAuthWasLockedOnArrival(true)
} else if (target === 3 && !onboarding?.locked) {
// Already unlocked — don't set the flag (prevents auto-advance)
setAuthWasLockedOnArrival(false)
}
setDirection(target > stepIndex ? 1 : -1)
setStepIndex(target)
},
[stepIndex, onboarding?.locked],
)
// Auto-advance past auth only when it just succeeded during this visit
useEffect(() => {
if (!onboarding) return
if (stepIndex !== 3) return
if (!authWasLockedOnArrival) return
const isUnlocked = !onboarding.locked
const bridgeDone = onboarding.bridgeAuthRefresh.phase === "succeeded" || onboarding.bridgeAuthRefresh.phase === "idle"
if (!isUnlocked || !bridgeDone) return
const t = window.setTimeout(() => goTo(4), 0)
return () => window.clearTimeout(t)
}, [onboarding, goTo, stepIndex, authWasLockedOnArrival])
const selectedProvider = useMemo(() => {
return providers.find((p) => p.id === effectiveSelectedProviderId) ?? null
}, [effectiveSelectedProviderId, providers])
// ─── Gate check ───
if (!onboarding) return null
const onboardingSettled =
!onboarding.locked ||
(onboarding.lastValidation?.status === "succeeded" &&
(onboarding.bridgeAuthRefresh.phase === "succeeded" || onboarding.bridgeAuthRefresh.phase === "idle"))
if (!forceVisible && (onboardingSettled || shouldHideAfterSuccess)) return null
const stepLabel = WIZARD_STEPS[stepIndex]?.label ?? ""
return (
<div className="pointer-events-auto absolute inset-0 z-30 flex flex-col bg-background" data-testid="onboarding-gate">
{/* Header */}
<header className="relative z-10 flex h-12 shrink-0 items-center justify-between px-5 md:px-8">
{/* Left — logo */}
<div className="flex w-24 items-center gap-2">
<Image src="/logo-white.svg" alt="GSD" width={57} height={16} className="hidden h-4 w-auto dark:block" />
<Image src="/logo-black.svg" alt="GSD" width={57} height={16} className="h-4 w-auto dark:hidden" />
</div>
{/* Center — step indicator */}
<div className="absolute inset-x-0 flex justify-center pointer-events-none">
<div className="pointer-events-auto">
<StepIndicator current={stepIndex} total={TOTAL_STEPS} />
</div>
</div>
{/* Right — step label */}
<div className="flex w-24 justify-end">
<span className="text-xs text-muted-foreground">{stepLabel}</span>
</div>
</header>
{/* Thin progress — hidden when not needed */}
{/* Content — full remaining height, scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col justify-center px-5 py-10 md:px-8 md:py-16">
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={stepIndex}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 400, damping: 35, opacity: { duration: 0.15 } }}
>
{stepIndex === 0 && <StepWelcome onNext={() => goTo(1)} />}
{stepIndex === 1 && (
<StepMode
selected={selectedMode}
onSelect={(mode) => { setSelectedMode(mode); setUserMode(mode) }}
onNext={() => goTo(2)}
onBack={() => goTo(0)}
/>
)}
{stepIndex === 2 && (
<StepProvider
providers={onboarding.required.providers}
selectedId={effectiveSelectedProviderId}
onSelect={(id) => {
setSelectedProviderId(id)
goTo(3)
}}
onNext={() => goTo(4)}
onBack={() => goTo(1)}
/>
)}
{stepIndex === 3 && selectedProvider && (
<StepAuthenticate
provider={selectedProvider}
activeFlow={onboarding.activeFlow}
lastValidation={onboarding.lastValidation}
requestState={workspace.onboardingRequestState}
requestProviderId={workspace.onboardingRequestProviderId}
onSaveApiKey={async (pid, key) => {
const next = await saveApiKey(pid, key)
const settled = Boolean(
next && !next.locked &&
(next.bridgeAuthRefresh.phase === "succeeded" || next.bridgeAuthRefresh.phase === "idle"),
)
if (settled) { setDismissedAfterSuccess(true); void refreshBoot() }
return next
}}
onStartFlow={(pid) => void startProviderFlow(pid)}
onSubmitFlowInput={(fid, input) => void submitProviderFlowInput(fid, input)}
onCancelFlow={(fid) => void cancelProviderFlow(fid)}
onBack={() => goTo(2)}
onNext={() => goTo(2)}
bridgeRefreshPhase={onboarding.bridgeAuthRefresh.phase}
bridgeRefreshError={onboarding.bridgeAuthRefresh.error}
/>
)}
{stepIndex === 4 && <StepDevRoot onBack={() => goTo(2)} onNext={() => goTo(5)} />}
{stepIndex === 5 && (
<StepOptional
sections={onboarding.optional.sections}
onBack={() => goTo(4)}
onNext={() => goTo(6)}
/>
)}
{stepIndex === 6 && (
<StepRemote
onBack={() => goTo(5)}
onNext={() => goTo(7)}
/>
)}
{stepIndex === 7 && (
<StepReady
providerLabel={
onboarding.lastValidation?.providerId
? onboarding.required.providers.find((p) => p.id === onboarding.lastValidation?.providerId)?.label ?? "Provider"
: "Provider"
}
onFinish={() => goTo(8)}
/>
)}
{stepIndex === 8 && (
<StepProject
onBack={() => goTo(7)}
onBeforeSwitch={() => {
// Disarm the gate BEFORE switchProject triggers a store remount
if (devOverrides.isActive("forceOnboarding")) {
devOverrides.toggle("forceOnboarding")
}
setDismissedAfterSuccess(true)
}}
onFinish={() => {
const mode = selectedMode ?? userMode
navigateToGSDView("dashboard")
void refreshBoot()
}}
/>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
)
}