Files
multica/packages/views/onboarding/steps/step-platform-fork.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

511 lines
18 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, type ReactNode } from "react";
import { ArrowLeft, ArrowRight, Download } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";
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 { CompactRuntimeRow } from "../components/compact-runtime-row";
import { useRuntimePicker } from "../components/use-runtime-picker";
import { useT } from "../../i18n";
/**
* Step 3 on **web**. The user is in a browser and hasn't downloaded
* the desktop app yet, so we can't scan their machine for runtimes.
* This screen is a fan-out: three clearly clickable cards, each with
* an explicit right-side button that says what clicking does:
*
* 1. **Download desktop** — primary card, black bg, "Download" pill.
* Opens the installer in a new tab; the user finishes onboarding
* inside the desktop app.
* 2. **Install the CLI** — alt card, "Show steps" pill → opens a
* dialog containing the real install instructions + live runtime
* probe. When a runtime appears and the user selects it, the
* dialog's "Connect & continue" button fires `onNext(runtime)`
* and advances the flow.
* 3. **Cloud computer** — alt card, "Coming soon" badge. Not yet
* available; rendered as a static, non-actionable preview.
*
* Footer is simplified — no Continue button, since the CLI dialog
* owns that advancement itself. Only Skip remains.
*/
type DialogState = "cli" | null;
// Single canonical download destination — the /download page owns
// OS + arch detection, the All-Platforms matrix, release-note links,
// and the CLI / Cloud alternates. Kept in sync with landing-hero.tsx
// and landing footer nav, both of which target the same path.
const DOWNLOAD_PAGE_URL = "/download";
export function StepPlatformFork({
wsId,
onNext,
onBack,
cliInstructions,
}: {
wsId: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Platform-specific CLI install card, rendered inside the CLI dialog. */
cliInstructions?: ReactNode;
}) {
const { t } = useT("onboarding");
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
const [dialog, setDialog] = useState<DialogState>(null);
const [downloaded, setDownloaded] = useState(false);
const picker = useRuntimePicker(wsId);
const pickDesktop = () => {
window.open(DOWNLOAD_PAGE_URL, "_blank", "noopener,noreferrer");
setDownloaded(true);
};
const handleOpenCli = () => {
setDialog("cli");
};
const handleCliConnect = () => {
if (!picker.selected) return;
setDialog(null);
onNext(picker.selected);
};
const footerHint = (() => {
if (downloaded) {
return t(($) => $.step_platform.hint_downloaded);
}
return t(($) => $.step_platform.hint_default);
})();
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 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>
<main
ref={mainRef}
style={fadeStyle}
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-[620px] px-6 py-10 sm:px-10 md:px-14 lg:px-0 lg:py-14">
<div className="mb-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.step_platform.eyebrow)}
</div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
{t(($) => $.step_platform.headline)}
</h1>
<p className="mt-4 max-w-[560px] text-[15.5px] leading-[1.55] text-muted-foreground">
{t(($) => $.step_platform.lede)}
</p>
<div className="mt-10 flex max-w-[560px] flex-col gap-3.5">
<ForkPrimary onClick={pickDesktop} downloaded={downloaded} />
<ForkAlt
title={t(($) => $.step_platform.cli_title)}
subtitle={t(($) => $.step_platform.cli_subtitle)}
actionLabel={t(($) => $.step_platform.cli_action)}
onAction={handleOpenCli}
/>
<ForkAlt
title={t(($) => $.step_platform.cloud_title)}
subtitle={t(($) => $.step_platform.cloud_subtitle)}
actionLabel={t(($) => $.step_platform.cloud_action)}
disabled
/>
</div>
{/* Inline action bar — hint on the left, Skip on the right.
Advancement for the CLI path is owned by the CLI
dialog's own "Connect & continue" button; Skip creates
the single self-serve onboarding issue. */}
<div className="mt-8 flex max-w-[560px] flex-wrap items-center justify-between gap-x-4 gap-y-2">
<span
aria-live="polite"
className="text-xs text-muted-foreground"
>
{footerHint}
</span>
<Button variant="secondary" onClick={() => onNext(null)}>
{t(($) => $.step_runtime.skip)}
</Button>
</div>
</div>
</main>
</div>
{/* Right — always-visible aside */}
<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>
<CliInstallDialog
open={dialog === "cli"}
onClose={() => setDialog(null)}
onConnect={handleCliConnect}
runtimes={picker.runtimes}
selectedId={picker.selectedId}
onSelect={picker.setSelectedId}
hasRuntimes={picker.hasRuntimes}
canConnect={picker.selected !== null}
selectedName={picker.selected?.name ?? null}
cliInstructions={cliInstructions}
/>
</div>
);
}
// ------------------------------------------------------------
// Fork cards
// ------------------------------------------------------------
function ForkPrimary({
onClick,
downloaded,
}: {
onClick: () => void;
downloaded: boolean;
}) {
const { t } = useT("onboarding");
return (
<button
type="button"
onClick={onClick}
className={cn(
"group flex items-center justify-between gap-4 rounded-xl bg-foreground px-6 py-5 text-left text-background transition-transform",
"hover:-translate-y-0.5",
)}
>
<div className="min-w-0">
<div className="flex items-center gap-2 text-[17px] font-medium tracking-tight">
<Download className="h-4 w-4" aria-hidden />
{downloaded
? t(($) => $.step_platform.download_title_after)
: t(($) => $.step_platform.download_title)}
</div>
<div className="mt-1 text-[13px] text-background/60">
{downloaded
? t(($) => $.step_platform.download_subtitle_after)
: t(($) => $.step_platform.download_subtitle)}
</div>
</div>
<span
aria-hidden
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-background/10 px-4 py-2 text-[13px] font-medium transition-colors group-hover:bg-background/20"
>
{t(($) => $.step_platform.download_button)}
<ArrowRight className="h-3.5 w-3.5" />
</span>
</button>
);
}
/**
* Alt card with a right-side action. When `disabled`, the action
* renders as a static badge (used for "Coming soon" paths that aren't
* yet wired up); otherwise it's an outline button that fires
* `onAction` and typically opens a dialog.
*/
function ForkAlt({
title,
subtitle,
actionLabel,
onAction,
disabled = false,
}: {
title: string;
subtitle: ReactNode;
actionLabel: ReactNode;
onAction?: () => void;
disabled?: boolean;
}) {
return (
<div
className={cn(
"flex items-center justify-between gap-4 rounded-lg border bg-card px-5 py-4",
disabled && "opacity-70",
)}
>
<div className="min-w-0">
<div className="text-[14.5px] font-medium text-foreground">{title}</div>
<div className="mt-1 text-[12.5px] leading-[1.5] text-muted-foreground">
{subtitle}
</div>
</div>
{disabled ? (
<span className="shrink-0 rounded-full border bg-muted px-3 py-1 text-[12px] font-medium text-muted-foreground">
{actionLabel}
</span>
) : (
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={onAction}
>
{actionLabel}
</Button>
)}
</div>
);
}
// ------------------------------------------------------------
// CLI install dialog
// ------------------------------------------------------------
/**
* Modal dialog for the CLI install path. Contains the real install
* instructions card (via the `cliInstructions` slot) plus the live
* runtime probe. Owns its own "Connect & continue" advancement — when
* a runtime has registered and the user picks it, clicking that button
* closes the dialog and fires the parent's `onConnect`.
*/
function CliInstallDialog({
open,
onClose,
onConnect,
runtimes,
selectedId,
onSelect,
hasRuntimes,
canConnect,
selectedName,
cliInstructions,
}: {
open: boolean;
onClose: () => void;
onConnect: () => void;
runtimes: AgentRuntime[];
selectedId: string | null;
onSelect: (id: string) => void;
hasRuntimes: boolean;
canConnect: boolean;
selectedName: string | null;
cliInstructions?: ReactNode;
}) {
const { t } = useT("onboarding");
return (
<Dialog open={open} onOpenChange={(o) => (o ? null : onClose())}>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{t(($) => $.step_platform.cli_dialog_title)}</DialogTitle>
<DialogDescription>
{t(($) => $.step_platform.cli_dialog_description)}
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pt-2">
{cliInstructions}
{hasRuntimes ? (
<>
<div className="flex items-center gap-2 pt-1 text-sm">
<div className="h-2 w-2 rounded-full bg-success" />
<span className="font-medium">
{t(($) => $.step_platform.runtimes_connected, { count: runtimes.length })}
</span>
</div>
{/* Cap the runtime list at ~4 rows visible, scroll the rest.
Keeps the commands above always reachable even when
a user has many machines registered. */}
<div className="flex max-h-[240px] flex-col gap-2 overflow-y-auto">
{runtimes.map((rt) => (
<CompactRuntimeRow
key={rt.id}
runtime={rt}
selected={rt.id === selectedId}
onSelect={() => onSelect(rt.id)}
/>
))}
</div>
</>
) : (
<CliWaitingStatus dialogOpen={open} />
)}
</div>
<DialogFooter className="flex items-center justify-between gap-3 sm:justify-between">
{/* Hint is only useful AFTER a runtime has registered — "pick
one" / "selected X". While still waiting, the body's
CliWaitingStatus already conveys the live-listening state,
so an additional "Waiting..." footer line is duplication. */}
<span className="text-xs text-muted-foreground">
{hasRuntimes
? canConnect && selectedName
? t(($) => $.step_runtime.hint_selected, { name: selectedName })
: t(($) => $.step_platform.cli_dialog_pick_hint)
: null}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>
{t(($) => $.common.cancel)}
</Button>
<Button disabled={!canConnect} onClick={onConnect}>
{t(($) => $.step_runtime.start_exploring)}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Format a seconds count as `m:ss` (e.g. 75 → "1:15"). Inline helper —
* no existing utility matches this format (agent-live-card's
* formatElapsed uses "1m 15s" style, not suitable for a ticking clock).
*/
function formatElapsed(seconds: number) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
/**
* Waiting state for the CLI dialog — shown until the first daemon
* registers. We can't actually observe the install / login / daemon-
* start phases from the frontend (they happen in the user's terminal
* and browser), so the best we can do is:
*
* 1. Confirm "we're listening" — a pulsing green dot + m:ss timer
* signals an active WS subscription (useRuntimePicker is already
* subscribed to `daemon:register`). This is what tells the user
* "the system isn't frozen, it's waiting for your daemon".
* 2. Progressively reveal troubleshooting hints as elapsed time
* crosses thresholds — so a user who stalls mid-setup gets
* useful guidance without being dogpiled at t=0.
* 3. At the 90s+ "stalled" tier, point the user at alternate paths
* (Skip / Cloud waitlist) — parallels desktop's EmptyView, which
* already exposes the same two exits when no runtime registers.
*
* Elapsed-time counter only ticks while the dialog is open so reopen
* after closing resets the staging.
*/
function CliWaitingStatus({ dialogOpen }: { dialogOpen: boolean }) {
const { t } = useT("onboarding");
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!dialogOpen) {
setElapsed(0);
return;
}
const id = window.setInterval(() => {
setElapsed((e) => e + 1);
}, 1000);
return () => window.clearInterval(id);
}, [dialogOpen]);
// Stage thresholds are rough — `multica setup` typical flow is
// ~1s save config → browser-tab auth (user-driven, 530s) →
// ~2s daemon boot → immediate WS register. So under 15s means
// "still normal", 1545s means "probably stuck on browser auth",
// 4590s means "probably an error in the terminal", 90s+ means
// "nothing's coming through, suggest alt paths" (the stalled tier
// parallels desktop StepRuntimeConnect's EmptyView — by that point
// it's worth pointing the user at Skip or Cloud waitlist).
const stage: "normal" | "midway" | "slow" | "stalled" =
elapsed < 15
? "normal"
: elapsed < 45
? "midway"
: elapsed < 90
? "slow"
: "stalled";
return (
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2 text-sm">
{/* Pulsing green dot signals active WS subscription — the
useRuntimePicker hook is already subscribed to `daemon:register`,
this is the visual confirmation that "we're listening". */}
<span
aria-hidden
className="inline-block size-2 shrink-0 rounded-full bg-success animate-pulse"
/>
<span className="font-medium text-foreground">
{t(($) => $.step_platform.live_listening)}
</span>
<span className="ml-auto font-mono text-xs tabular-nums text-muted-foreground">
{formatElapsed(elapsed)}
</span>
</div>
<p
aria-live="polite"
className="text-[12.5px] leading-[1.55] text-muted-foreground"
>
{stage === "normal" && (
<>
{t(($) => $.step_platform.stage_normal_prefix)}
<span className="font-mono">{"multica setup"}</span>
{t(($) => $.step_platform.stage_normal_suffix)}
</>
)}
{stage === "midway" && (
<>
{t(($) => $.step_platform.stage_midway_prefix)}
<span className="font-mono">{"multica setup"}</span>
{t(($) => $.step_platform.stage_midway_suffix)}
</>
)}
{stage === "slow" && (
<>
{t(($) => $.step_platform.stage_slow_prefix)}
<span className="font-mono">{"multica setup"}</span>
{t(($) => $.step_platform.stage_slow_suffix)}
</>
)}
{stage === "stalled" && (
<>
{t(($) => $.step_platform.stage_stalled_prefix)}
<span className="font-medium text-foreground">{t(($) => $.step_platform.stage_stalled_term)}</span>
{t(($) => $.step_platform.stage_stalled_suffix)}
</>
)}
</p>
</div>
);
}