Files
multica/packages/views/onboarding/steps/step-runtime-connect.tsx
Bohan Jiang 3cb5dc3ad6 chore(analytics): retire redundant PostHog tracking (MUL-4127) (#4996)
* chore(analytics): retire redundant PostHog tracking (MUL-4127)

PostHog had become a chaotic, largely-unused second copy of data we already
query from the DB and Grafana. Remove the redundant instrumentation.

Server: every product event (signup, workspace_created, issue_created,
issue_executed, chat_message_sent, team_invite_*, onboarding_*, agent_created,
cloud_waitlist_joined, feedback_submitted, contact_sales_submitted,
squad_created, autopilot_created) is now in metricsOnlyEvents, so
metrics.RecordEvent still increments the Prometheus/Grafana counter but no longer
ships to PostHog. DB rows remain the source of truth. Runtime/autopilot/
agent_task lifecycle were already Prometheus-only.

Frontend: delete the PostHog-only funnel instrumentation — $pageview (+ web and
desktop trackers), download_intent_expressed/page_viewed/initiated, the
onboarding_started mirror, onboarding_runtime_path_selected/detected,
feedback_opened, and source_backfill_*. The source-backfill modal itself stays
(it PATCHes the questionnaire to the DB).

Kept on PostHog (frontend only): $exception autocapture and the
client_crash / client_unresponsive stability telemetry (no DB equivalent), plus
$identify/$set. captureSignupSource (attribution cookie) stays — it still feeds
the signup_source Prometheus label.

Verified: pnpm typecheck, pnpm lint (0 errors), vitest (core/views/web/desktop),
go test ./internal/analytics/... ./internal/metrics/...

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

* docs(analytics): fix stale PostHog references after MUL-4127 (review follow-up)

Addresses review of #4996 — three spots still described server events as active
PostHog signals after they became metrics-only:

- docs/analytics.md: issue_executed is no longer a PostHog success signal; it is
  Prometheus-only (multica_issue_executed_total) + issue.first_executed_at, in
  both the event contract and the Reconciliation section.
- docs/analytics.md: the signup $set_once person properties (email, signup_source)
  are no longer emitted — signup is Prometheus-only; only the bucketed
  signup_source survives as the multica_signup_total label.
- server/internal/metrics/business_events.go: RecordEvent doc comment no longer
  claims it ships product events to PostHog / "PostHog is reserved for
  user/product-behaviour events" — every server event is now metrics-only.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 01:43:15 +08:00

619 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 { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { runtimeKeys } from "@multica/core/runtimes/queries";
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 { 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,
onRefresh,
}: {
wsId: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Platform-level rescan hook. Desktop wires this to restart the
* bundled daemon so a freshly-installed CLI shows up — otherwise the
* daemon's PATH probe runs once at boot and never re-probes. */
onRefresh?: () => void | Promise<void>;
}) {
const { runtimes, selected, selectedId, setSelectedId } =
useRuntimePicker(wsId);
return (
<FancyView
wsId={wsId}
runtimes={runtimes}
selected={selected}
selectedId={selectedId}
setSelectedId={setSelectedId}
onNext={onNext}
onBack={onBack}
onRefresh={onRefresh}
/>
);
}
// ============================================================
// 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,
onRefresh,
}: {
wsId: string;
runtimes: AgentRuntime[];
selected: AgentRuntime | null;
selectedId: string | null;
setSelectedId: (id: string) => void;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
onRefresh?: () => void | Promise<void>;
}) {
const { t } = useT("onboarding");
const qc = useQueryClient();
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 / refresh exits.
// `scanEpoch` resets the timer when the user hits Refresh, so a
// freshly-installed CLI gets another scanning window before falling
// back to the empty state.
const [scanEpoch, setScanEpoch] = useState(0);
const [hasTimedOut, setHasTimedOut] = useState(false);
useEffect(() => {
if (runtimes.length > 0) return;
setHasTimedOut(false);
const id = window.setTimeout(() => setHasTimedOut(true), EMPTY_TIMEOUT_MS);
return () => window.clearTimeout(id);
}, [runtimes.length, scanEpoch]);
const phase: Phase =
runtimes.length > 0 ? "found" : hasTimedOut ? "empty" : "scanning";
const onlineCount = runtimes.filter((r) => r.status === "online").length;
const [submitting, setSubmitting] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// Refresh triggers a re-scan: restart the daemon (if the platform
// wired `onRefresh`) so its PATH probe runs again, invalidate the
// runtime query, and reset the empty-state timeout so the user sees
// the scanning skeleton instead of the empty exits while the daemon
// boots back up.
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
try {
if (onRefresh) await onRefresh();
await qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
setScanEpoch((n) => n + 1);
} finally {
setRefreshing(false);
}
}, [onRefresh, qc, wsId, refreshing]);
// Skip is always available — regardless of phase. Hitting Skip routes
// through the runtime-less branch, which creates one focused self-serve
// onboarding issue instead of seeding the old starter 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)
: t(($) => $.step_runtime.hint_skip_or_refresh);
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.
Skip + Continue sit inline directly below the phase view
(not in a sticky bottom footer) so the action bar stays
close to the form content and the page doesn't leave a
large dead zone when the runtime list is short. */}
<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}
onRefresh={handleRefresh}
refreshing={refreshing}
/>
)}
{phase === "empty" && (
<EmptyView
onSkip={() => onNext(null)}
onRefresh={handleRefresh}
refreshing={refreshing}
/>
)}
<div className="mt-8 flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<span
aria-live="polite"
className="mr-auto text-xs text-muted-foreground"
>
{footerHint}
</span>
<div className="flex items-center gap-2">
<Button
size="lg"
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(($) => $.step_runtime.start_exploring)}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</main>
</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,
onRefresh,
refreshing,
}: {
runtimes: AgentRuntime[];
selectedId: string | null;
onSelect: (id: string) => void;
onlineCount: number;
onRefresh: () => void;
refreshing: boolean;
}) {
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>
<RefreshButton
onClick={onRefresh}
refreshing={refreshing}
className="ml-auto"
/>
</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({
onSkip,
onRefresh,
refreshing,
}: {
onSkip: () => void;
onRefresh: () => void;
refreshing: boolean;
}) {
const { t } = useT("onboarding");
return (
<div>
<div className="flex items-start justify-between gap-4">
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
{t(($) => $.step_runtime.empty_headline)}
</h1>
<RefreshButton
onClick={onRefresh}
refreshing={refreshing}
className="mt-2 shrink-0"
/>
</div>
<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}
/>
<ComingSoonCard
title={t(($) => $.step_runtime.empty_waitlist_title)}
subtitle={t(($) => $.step_runtime.empty_waitlist_subtitle)}
badgeLabel={t(($) => $.step_runtime.empty_waitlist_action)}
/>
</div>
</div>
);
}
/**
* Static, non-interactive variant of EmptyCard used for the cloud-computer
* row. The card is dimmed and the pill is rendered as a badge so the user
* understands the option exists but isn't actionable yet. Mirrors the
* "Coming soon" treatment on the web platform fork.
*/
function ComingSoonCard({
title,
subtitle,
badgeLabel,
}: {
title: string;
subtitle: string;
badgeLabel: string;
}) {
return (
<div
aria-disabled
className="flex items-center justify-between gap-4 rounded-lg border border-dashed bg-muted/20 px-5 py-4 opacity-70"
>
<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 rounded-full border bg-background px-3 py-1.5 text-[12px] font-medium uppercase tracking-wide text-muted-foreground"
>
{badgeLabel}
</span>
</div>
);
}
function RefreshButton({
onClick,
refreshing,
className,
}: {
onClick: () => void;
refreshing: boolean;
className?: string;
}) {
const { t } = useT("onboarding");
return (
<Button
type="button"
variant="ghost"
size="sm"
disabled={refreshing}
onClick={onClick}
className={className}
>
<RefreshCw
className={cn("h-3.5 w-3.5", refreshing && "animate-spin")}
aria-hidden
/>
{refreshing
? t(($) => $.step_runtime.refreshing)
: t(($) => $.step_runtime.refresh)}
</Button>
);
}
/**
* 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>
);
}