Files
multica/packages/views/onboarding/steps/step-runtime-connect.tsx
Multica Eve ce00e05169 Add canonical PostHog core metrics events (#2302)
* Add canonical PostHog core metrics events

Co-authored-by: multica-agent <github@multica.ai>

* Address analytics review feedback

Co-authored-by: multica-agent <github@multica.ai>

* Tighten analytics review follow-ups

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 13:12:00 +08:00

605 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
import { captureEvent, setPersonProperties } from "@multica/core/analytics";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { cn } from "@multica/ui/lib/utils";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import type { AgentRuntime } from "@multica/core/types";
import { DragStrip } from "@multica/views/platform";
import { StepHeader } from "../components/step-header";
import { RuntimeAsidePanel } from "../components/runtime-aside-panel";
import { useRuntimePicker } from "../components/use-runtime-picker";
import { CloudWaitlistExpand } from "../components/cloud-waitlist-expand";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { useT } from "../../i18n";
/**
* Step 3 (desktop) — connect a runtime.
*
* Owns the full window: DragStrip + 3-region app shell (header /
* scrolling middle / sticky footer) on the left, permanent
* educational aside on the right. Built to mirror Step 1
* questionnaire's shell so the onboarding flow reads as one
* continuous surface.
*
* Data layer (`useRuntimePicker`): TanStack Query polls every 2s
* while empty; `daemon:register` WS event invalidates instantly;
* default selection prefers online, falls back to first.
*
* Web routes to `StepPlatformFork` instead — it owns its own
* runtime picker embedded under the CLI expand.
*/
export function StepRuntimeConnect({
wsId,
onNext,
onBack,
onWaitlistSubmitted,
}: {
wsId: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Parent-level latch used to label the onboarding completion path
* as `cloud_waitlist` when the user ends up skipping this step
* after submitting the waitlist form. */
onWaitlistSubmitted?: () => void;
}) {
const { runtimes, selected, selectedId, setSelectedId } =
useRuntimePicker(wsId);
return (
<FancyView
wsId={wsId}
runtimes={runtimes}
selected={selected}
selectedId={selectedId}
setSelectedId={setSelectedId}
onNext={onNext}
onBack={onBack}
onWaitlistSubmitted={onWaitlistSubmitted}
/>
);
}
// ============================================================
// Fancy desktop view
// ============================================================
type Phase = "scanning" | "found" | "empty";
/** Input ms before an empty list flips from "scanning" to "empty". */
const EMPTY_TIMEOUT_MS = 5000;
function FancyView({
wsId,
runtimes,
selected,
selectedId,
setSelectedId,
onNext,
onBack,
onWaitlistSubmitted,
}: {
wsId: string;
runtimes: AgentRuntime[];
selected: AgentRuntime | null;
selectedId: string | null;
setSelectedId: (id: string) => void;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
onWaitlistSubmitted?: () => void;
}) {
const { t } = useT("onboarding");
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
// Flip to "empty" only after we've waited long enough for the daemon
// to report. The 5s budget covers the bundled daemon's typical 13s
// boot; anything past that is a genuine "no runtime" situation and we
// switch from scanning skeletons to the skip / cloud-waitlist exits.
const [hasTimedOut, setHasTimedOut] = useState(false);
useEffect(() => {
if (runtimes.length > 0) return;
const t = window.setTimeout(() => setHasTimedOut(true), EMPTY_TIMEOUT_MS);
return () => window.clearTimeout(t);
}, [runtimes.length]);
const phase: Phase =
runtimes.length > 0 ? "found" : hasTimedOut ? "empty" : "scanning";
const onlineCount = runtimes.filter((r) => r.status === "online").length;
// One-shot analytics event when the scan window resolves. Answers the
// question "did the user actually have any AI CLI installed on this
// machine when they hit Step 3" — currently unanswerable from the
// existing funnel because a zero-CLI daemon fails to register at all,
// so `runtime_registered` is silent on that cohort. Emitting from here
// (rather than the daemon) keeps the signal in sync with what the UI
// actually showed the user: "scanning → found" vs "scanning → empty"
// after the 5s grace period.
const detectStartRef = useRef<number | null>(null);
if (detectStartRef.current === null) {
detectStartRef.current =
typeof performance !== "undefined" ? performance.now() : Date.now();
}
const detectedEmittedRef = useRef(false);
useEffect(() => {
if (detectedEmittedRef.current) return;
if (phase === "scanning") return;
detectedEmittedRef.current = true;
const providers = Array.from(
new Set(runtimes.map((r) => r.provider).filter(Boolean)),
).sort();
const now =
typeof performance !== "undefined" ? performance.now() : Date.now();
const detectMs = Math.round(now - (detectStartRef.current ?? now));
captureEvent("onboarding_runtime_detected", {
source: "onboarding",
surface: "step3_desktop",
workspace_id: wsId,
outcome: phase,
runtime_count: runtimes.length,
online_count: onlineCount,
providers,
has_claude: providers.includes("claude"),
has_codex: providers.includes("codex"),
has_cursor: providers.includes("cursor"),
detect_ms: detectMs,
});
setPersonProperties({
has_any_cli: runtimes.length > 0,
detected_cli_count: runtimes.length,
});
}, [phase, runtimes, onlineCount]);
const [submitting, setSubmitting] = useState(false);
// Cloud waitlist submission state lives here (rather than in EmptyView)
// so it survives phase flips — e.g. a runtime coming online after the
// user has already submitted the waitlist form.
const [waitlistSubmitted, setWaitlistSubmitted] = useState(false);
// Skip is always available — regardless of phase. Hitting Skip routes
// the flow through the self-serve branch (agent=null), which still
// completes onboarding and seeds a Getting Started project.
const handleSkip = async () => {
if (submitting) return;
setSubmitting(true);
try {
await onNext(null);
} finally {
setSubmitting(false);
}
};
// Continue only makes sense when a runtime is selected. Otherwise
// there's nothing to pass to Step 4.
const canContinue = phase === "found" && selected !== null;
const handleContinue = async () => {
if (!canContinue || submitting) return;
setSubmitting(true);
try {
await onNext(selected);
} finally {
setSubmitting(false);
}
};
const footerHint =
phase === "found" && selected
? t(($) => $.step_runtime.hint_selected, { name: selected.name })
: phase === "found"
? t(($) => $.step_runtime.hint_pick)
: phase === "scanning"
? t(($) => $.step_runtime.hint_waiting)
: waitlistSubmitted
? t(($) => $.step_runtime.hint_waitlist_done)
: t(($) => $.step_runtime.hint_skip_or_waitlist);
return (
<div className="animate-onboarding-enter grid h-full min-h-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_480px]">
{/* Left — DragStrip + 3-region app shell */}
<div className="flex min-h-0 flex-col">
<DragStrip />
{/* Header — Back + horizontal step indicator */}
<header className="flex shrink-0 items-center gap-4 bg-background px-6 py-3 sm:px-10 md:px-14 lg:px-16">
{onBack ? (
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t(($) => $.common.back)}
</button>
) : (
<span aria-hidden className="w-0" />
)}
<div className="flex-1">
<StepHeader currentStep="runtime" />
</div>
</header>
{/* Scrollable middle — content changes by phase but always wraps
at max-w-[620px] so the 2-column runtime grid has room to
breathe without stretching into readability territory. */}
<main
ref={mainRef}
style={fadeStyle}
className="min-h-0 flex-1 overflow-y-auto"
>
{/* key=phase forces a remount on phase transition so the
`animate-onboarding-enter` animation replays — otherwise CSS
only runs on initial mount and scanning→found would be a
hard cut. */}
<div
key={phase}
className="animate-onboarding-enter mx-auto w-full max-w-[620px] px-6 py-10 sm:px-10 md:px-14 lg:px-0 lg:py-14"
>
{phase === "scanning" && <ScanningView />}
{phase === "found" && (
<FoundView
runtimes={runtimes}
selectedId={selectedId}
onSelect={setSelectedId}
onlineCount={onlineCount}
/>
)}
{phase === "empty" && (
<EmptyView
waitlistSubmitted={waitlistSubmitted}
onWaitlistSubmitted={() => {
setWaitlistSubmitted(true);
onWaitlistSubmitted?.();
}}
onSkip={() => onNext(null)}
/>
)}
</div>
</main>
{/* Sticky footer — Skip (always) on the left, hint + Continue
(gated on runtime selection) on the right. Skip is the
self-serve exit: onNext(null) → bootstrap runs the no-agent
branch, onboarding still completes. */}
<footer className="flex shrink-0 items-center justify-end gap-4 bg-background px-6 py-4 sm:px-10 md:px-14 lg:px-16">
<span
aria-live="polite"
className="mr-auto text-xs text-muted-foreground"
>
{footerHint}
</span>
<Button
variant="secondary"
disabled={submitting}
onClick={handleSkip}
>
{t(($) => $.step_runtime.skip)}
</Button>
<Button
size="lg"
disabled={!canContinue || submitting}
onClick={handleContinue}
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t(($) => $.common.continue)}
<ArrowRight className="h-4 w-4" />
</Button>
</footer>
</div>
{/* Right — always-visible educational aside. "You picked" subsection
only appears when there's a selection; the other two stay constant. */}
<aside className="hidden min-h-0 border-l bg-muted/40 lg:flex lg:flex-col">
<DragStrip />
<div className="min-h-0 flex-1 overflow-y-auto px-12 py-12">
<RuntimeAsidePanel />
</div>
</aside>
</div>
);
}
// ------------------------------------------------------------
// Phase views (inline — all three share the same 620px column)
// ------------------------------------------------------------
function ScanningView() {
const { t } = useT("onboarding");
return (
<div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
{t(($) => $.step_runtime.scanning_headline)}
</h1>
<p className="mt-4 max-w-[560px] text-[15.5px] leading-[1.55] text-muted-foreground">
{t(($) => $.step_runtime.scanning_lede_prefix)}
<span className="font-medium text-foreground">{"Claude Code"}</span>
{", "}
<span className="font-medium text-foreground">{"Codex"}</span>
{", "}
<span className="font-medium text-foreground">{"Cursor"}</span>
{t(($) => $.step_runtime.scanning_lede_suffix)}
</p>
<div className="mt-10 grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<SkeletonRuntimeCard />
<SkeletonRuntimeCard />
</div>
</div>
);
}
function FoundView({
runtimes,
selectedId,
onSelect,
onlineCount,
}: {
runtimes: AgentRuntime[];
selectedId: string | null;
onSelect: (id: string) => void;
onlineCount: number;
}) {
const { t } = useT("onboarding");
const total = runtimes.length;
const statusLabel =
onlineCount === total
? t(($) => $.step_runtime.status_all_online)
: onlineCount === 0
? t(($) => $.step_runtime.status_none_online)
: t(($) => $.step_runtime.status_n_online, { count: onlineCount });
const statusTone =
onlineCount === 0 ? "text-muted-foreground" : "text-success";
return (
<div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
{t(($) => $.step_runtime.found_headline)}
</h1>
<p className="mt-4 max-w-[560px] text-[15.5px] leading-[1.55] text-muted-foreground">
{t(($) => $.step_runtime.found_lede)}
</p>
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg bg-muted/60 px-4 py-2.5 text-xs">
<span className="font-semibold text-foreground">
{t(($) => $.step_runtime.runtime_count, { count: total })}
</span>
<span className="text-muted-foreground">·</span>
<span className={cn("flex items-center gap-1", statusTone)}>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
onlineCount === 0 ? "bg-muted-foreground/40" : "bg-success",
)}
aria-hidden
/>
{statusLabel}
</span>
</div>
<div className="mt-6 grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{runtimes.map((rt) => (
<RuntimeCard
key={rt.id}
runtime={rt}
selected={rt.id === selectedId}
onSelect={() => onSelect(rt.id)}
/>
))}
</div>
</div>
);
}
function EmptyView({
waitlistSubmitted,
onWaitlistSubmitted,
onSkip,
}: {
waitlistSubmitted: boolean;
onWaitlistSubmitted: () => void;
onSkip: () => void;
}) {
const { t } = useT("onboarding");
const [waitlistOpen, setWaitlistOpen] = useState(false);
return (
<div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
{t(($) => $.step_runtime.empty_headline)}
</h1>
<p className="mt-4 max-w-[560px] text-[15.5px] leading-[1.55] text-muted-foreground">
{t(($) => $.step_runtime.empty_lede_prefix)}
<span className="font-medium text-foreground">{"Claude Code"}</span>
{", "}
<span className="font-medium text-foreground">{"Codex"}</span>
{", "}
<span className="font-medium text-foreground">{"Cursor"}</span>
{t(($) => $.step_runtime.empty_lede_suffix)}
</p>
<div className="mt-10 flex flex-col gap-3.5">
<EmptyCard
title={t(($) => $.step_runtime.empty_skip_title)}
subtitle={t(($) => $.step_runtime.empty_skip_subtitle)}
actionLabel={t(($) => $.step_runtime.empty_skip_action)}
onAction={onSkip}
/>
<EmptyCard
title={t(($) => $.step_runtime.empty_waitlist_title)}
subtitle={t(($) => $.step_runtime.empty_waitlist_subtitle)}
actionLabel={
waitlistSubmitted
? t(($) => $.step_runtime.empty_waitlist_done)
: t(($) => $.step_runtime.empty_waitlist_action)
}
onAction={() => setWaitlistOpen(true)}
/>
</div>
<Dialog
open={waitlistOpen}
onOpenChange={(o) => (o ? null : setWaitlistOpen(false))}
>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{t(($) => $.step_runtime.dialog_title)}</DialogTitle>
<DialogDescription>
{t(($) => $.step_runtime.dialog_description)}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
<CloudWaitlistExpand
submitted={waitlistSubmitted}
onSubmitted={onWaitlistSubmitted}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setWaitlistOpen(false)}>
{waitlistSubmitted
? t(($) => $.step_runtime.dialog_close)
: t(($) => $.step_runtime.dialog_cancel)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
/**
* Card with a prominent right-side button. Mirrors the ForkAlt pattern
* from the web fork step — whole card is clickable, but the pill is
* the visual affordance that signals "this is a button".
*/
function EmptyCard({
title,
subtitle,
actionLabel,
onAction,
}: {
title: string;
subtitle: string;
actionLabel: string;
onAction: () => void;
}) {
return (
<button
type="button"
onClick={onAction}
className="group flex items-center justify-between gap-4 rounded-lg border bg-card px-5 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-muted/30"
>
<div className="min-w-0">
<div className="text-[14.5px] font-medium text-foreground">{title}</div>
<p className="mt-1 text-[12.5px] leading-[1.55] text-muted-foreground">
{subtitle}
</p>
</div>
<span
aria-hidden
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border bg-background px-4 py-2 text-[13px] font-medium text-foreground transition-colors group-hover:border-foreground group-hover:bg-foreground group-hover:text-background"
>
{actionLabel}
<ArrowRight className="h-3.5 w-3.5" />
</span>
</button>
);
}
// ------------------------------------------------------------
// Card components
// ------------------------------------------------------------
function RuntimeCard({
runtime,
selected,
onSelect,
}: {
runtime: AgentRuntime;
selected: boolean;
onSelect: () => void;
}) {
const { t } = useT("onboarding");
const online = runtime.status === "online";
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
selected
? "border-foreground shadow-[inset_0_0_0_1px_var(--color-foreground)]"
: "hover:border-foreground/20",
)}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent/30">
<ProviderLogo provider={runtime.provider} className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{runtime.name}
</div>
<div className="mt-0.5 flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
online ? "bg-success" : "bg-muted-foreground/40",
)}
aria-hidden
/>
{online ? t(($) => $.step_runtime.online_label) : t(($) => $.step_runtime.offline_label)}
</div>
</div>
<RadioMark selected={selected} />
</button>
);
}
function SkeletonRuntimeCard() {
return (
<div
aria-hidden
className="flex animate-pulse items-center gap-3 rounded-lg border bg-card p-4"
>
<div className="h-7 w-7 shrink-0 rounded-md bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-3 w-28 rounded bg-muted" />
<div className="h-2.5 w-16 rounded bg-muted/70" />
</div>
<div className="h-4 w-4 shrink-0 rounded-full border-[1.5px] border-muted" />
</div>
);
}
function RadioMark({ selected }: { selected: boolean }) {
return (
<span
aria-hidden
className={cn(
"relative inline-block h-4 w-4 shrink-0 rounded-full border-[1.5px] transition-colors",
selected ? "border-foreground" : "border-border",
)}
>
{selected && (
<span className="absolute inset-[3px] rounded-full bg-foreground" />
)}
</span>
);
}