Files
multica/packages/views/onboarding/steps/step-platform-fork.tsx
Naiyuan Qing 5335edd50d feat(web): /download page + desktop promotion across landing, login, onboarding (#1500)
* docs(download): add redesign plan and copy positioning source of truth

Captures motivation (Desktop is Multica's native form; CLI is a
distinct scenario for servers/remote boxes, not a Desktop fallback),
four-step execution plan, and every touchpoint's current-vs-new
copy in EN + ZH. Subsequent UI steps read strings from the
positioning doc instead of inventing them inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): /download page with OS auto-detection

New landing-group route that serves as the single canonical download
destination. Auto-detects OS + arch via navigator.userAgentData
(Chromium) with UA-string fallback, then surfaces the matching
Desktop installer as the primary CTA. All platforms stay visible
below, plus a CLI section (positioned for servers / remote boxes /
headless setups, not as a lightweight Desktop) and a Cloud waitlist.

Version + asset URLs come from api.github.com/repos/.../releases/latest
with Vercel ISR (revalidate=300) so every release automatically
propagates — no manual redeploy. Optional GITHUB_TOKEN env var lifts
the 60/hr unauthenticated rate limit for local dev. Failure
degrades cleanly to "Version unavailable" + a link to GitHub
releases.

Also points landing hero + footer Download links at /download
(previously pointed at the GitHub releases page directly), and
re-exports CloudWaitlistExpand from @multica/views/onboarding so
the new Cloud section can reuse the existing form.

Intel Mac has no binary today (electron-builder targets mac arm64
only); the page is honest about it and routes Intel users to CLI.

i18n copy sourced verbatim from docs/download-positioning.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): rewrite Step 3 fork + web Welcome Desktop CTA

Welcome screen now self-segments: on web (runtimeInstructions
present), the primary CTA is "Download Desktop" with a benefit-led
subtitle ("Desktop bundles the runtime — nothing to install.
Continue on web to connect your own CLI.") that lets developers
with their own CLI recognize their path while guiding everyone
else toward the desktop app. Desktop branch drops the "3 minutes"
estimate in favor of the aha promise. Download button is a real
<a href> link so middle-click / copy-link / screen readers all
behave correctly.

Step 3 fork drops the stale isMac gate — Windows / Linux binaries
now ship, the macOS-only muted card was a lie. The single Desktop
card now routes to /download (not GitHub releases directly) so
users land on the auto-detect page. CLI card is reframed around
its real scenario (servers, remote dev boxes, headless) rather
than posing as a lightweight Desktop, and the CLI dialog's stall
tier redirects users to Desktop instead of Cloud waitlist when
the daemon never registers — Desktop is the genuine retreat.

cli-install-instructions gets a one-liner acknowledging the CLI's
server use case, mirroring the card copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web,auth): desktop promotion on login + solid landing hero download

LoginPage accepts a new `extra?: ReactNode` slot rendered below the
Google button. The web shell injects a hardcoded-EN "Prefer the
desktop app? Download →" nudge there — catching users at their
lowest-investment moment, before they've typed an email. Desktop's
login wrapper omits the slot (a download prompt inside the app
would be absurd), so only the web surface renders it.

Copy is English-only for now because the /login route sits outside
the landing group's LocaleProvider. Lifting locale detection into
the root layout would force every page dynamic and kill the Router
Cache — a trade-off not worth two strings. The `auth.login.extra*`
i18n keys added during Step 2 are removed for the same reason:
they're dead code without a LocaleProvider wrapping login.

Landing hero "Download Desktop" upgrades from ghost to solid and
swaps its handwritten monitor SVG for lucide-react's Download
icon. Both hero CTAs are now solid-weighted — the icon + distinct
label differentiates them. href already points to /download from
the Step 2 landing nav pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web/download): anchor dark LandingHeader with relative wrapper

LandingHeader's dark variant uses `absolute top-0 inset-x-0`, which
only reads correctly when wrapped by a positioned ancestor — see
multica-landing.tsx:14 for the canonical pattern. Without the
wrapper the header escaped to the initial containing block and
appeared fixed as users scrolled the page.

Also drops the <main> element around the body sections for
consistency with the rest of the landing group (neither
multica-landing nor about-page-client wraps in <main>).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(landing/hero): keep Download Desktop as ghost to preserve CTA hierarchy

Upgrading to solid alongside the existing "Start free trial" CTA
killed the primary / secondary distinction — both buttons were
white on dark, competing for attention. Revert to ghost so the
conversion CTA (trial) stays the visual primary. The lucide
Download icon swap stays (cleaner than the handwritten monitor
SVG).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(onboarding): update platform-fork assertions for /download route

The Desktop card in Step 3 now opens the new /download page instead
of GitHub releases, and the post-click feedback text changed to
match ("Continuing on the download page…" in place of "Downloading
Multica…"). Update the expectations and drop the isMac navigator
stub that was only needed when the component had a macOS-only
primary branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Merge origin/main into NevilleQingNY/download-redesign

Main added onboarding funnel analytics (#1489) that captures
`is_mac` as a dimension for each Step 3 path selection. This
branch had removed the `isMac` state because the UI no longer
branches on it (Windows / Linux desktop builds ship now). Git
auto-merged the two diffs into a file that referenced a deleted
variable.

Reintroduce `isMac` as a lazy client-only computation scoped to
analytics capture only — the UI stays platform-agnostic. Handlers
fire client-side so SSR safety isn't needed; a plain const reads
navigator on first render.

typecheck passes across all 6 packages; all 166 views tests
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analytics): instrument download funnel across 5 surfaces + /download

Closes the gap left by PR #1489: onboarding analytics captured Step
3 path selection but missed the four surfaces that advertise the
desktop app earlier in the funnel (landing hero, landing footer,
login, Welcome), and the /download page itself had zero coverage —
so we could see the last-mile path but not the top-of-funnel entry
nor the page-to-installer conversion.

Three new events, wired via `@multica/core/analytics`:

1. `download_intent_expressed` fires on any CTA pointing at
   /download. `source` splits the five surfaces cleanly; every
   authenticated emission also writes `platform_preference=desktop`
   on the person (same convention Step 3 already uses).

2. `download_page_viewed` fires once per /download mount after OS
   detect resolves. Carries `detected_os`, `detected_arch`,
   `detect_confident` (Chromium userAgentData vs UA fallback), and
   `version_available` so the Safari-on-Mac arm64-default cohort
   and GitHub-rate-limited degraded sessions are each isolable.
   Also $set_once's `first_detected_os/arch` on the person so every
   downstream event gains a platform dimension without re-emitting.

3. `download_initiated` fires on every installer click — Hero's
   primary CTA and each All Platforms matrix row. `primary_cta`
   splits hero-recommended from manual picks; `matched_detect`
   quantifies detect accuracy from the single event (no cross-join
   to download_page_viewed needed).

Augments the existing `onboarding_runtime_path_selected` with a
`source: "step3"` property — literal today, reserved for future
surfaces reusing the same event name. `is_mac` kept for
backward-compat with PR #1489's dashboards; the new events use
`detected_os` + `detected_arch` instead.

New `setPersonPropertiesOnce` wire helper in
`packages/core/analytics/download.ts` for `$set_once` — mirrors
the backend's `Event.SetOnce` semantics.

docs/analytics.md update lands in the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(analytics): document download_intent_expressed / page_viewed / initiated

Adds the three new download-funnel events to the frontend-only
section. Also notes the semantic shift on
onboarding_runtime_path_selected: its `path: "download_desktop"`
now signals Step 3 path choice, not actual download start —
download_intent_expressed is the new canonical "user expressed
intent to download desktop" signal across surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:25:01 +08:00

617 lines
22 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 {
captureDownloadIntent,
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 { 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 { CloudWaitlistExpand } from "../components/cloud-waitlist-expand";
/**
* 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 waitlist** — alt card, "Join waitlist" pill → opens a
* dialog with an email + reason form. Submitting is pure interest
* capture; the dialog doesn't advance the flow. The user then
* closes the dialog and can hit Skip in the footer.
*
* Footer is simplified — no Continue button, since the CLI dialog
* owns that advancement itself. Only Skip remains.
*/
type DialogState = "cli" | "cloud" | 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,
onWaitlistSubmitted,
}: {
wsId: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Platform-specific CLI install card, rendered inside the CLI dialog. */
cliInstructions?: ReactNode;
/** Parent-level latch used to label the onboarding completion path
* as `cloud_waitlist` when the user ends up skipping Step 3 after
* submitting the waitlist form. */
onWaitlistSubmitted?: () => void;
}) {
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
const [dialog, setDialog] = useState<DialogState>(null);
const [downloaded, setDownloaded] = useState(false);
const [waitlistSubmitted, setWaitlistSubmitted] = useState(false);
// Platform signal retained purely for PostHog dimensions — the UI
// no longer branches on it (Windows / Linux desktop installers now
// ship, so all three platforms get the same card). Computed
// lazily; SSR-safe because handlers only run client-side.
const isMac =
typeof navigator !== "undefined" &&
(/Mac|iPhone|iPad|iPod/i.test(navigator.platform || "") ||
/Mac OS X/i.test(navigator.userAgent || ""));
const picker = useRuntimePicker(wsId);
const pickDesktop = () => {
window.open(DOWNLOAD_PAGE_URL, "_blank", "noopener,noreferrer");
setDownloaded(true);
// Step-3-scoped path selection event (kept for existing funnels);
// `source: "step3"` future-proofs if the event is reused from
// another surface later.
captureEvent("onboarding_runtime_path_selected", {
path: "download_desktop",
source: "step3",
is_mac: isMac,
});
// Cross-surface Desktop intent event — also fires from landing
// hero / footer / login / Welcome. Enables the top-of-funnel
// split without retrofitting `onboarding_runtime_path_selected`
// to non-onboarding contexts.
captureDownloadIntent("step3");
};
const handleOpenCli = () => {
setDialog("cli");
captureEvent("onboarding_runtime_path_selected", {
path: "cli",
source: "step3",
is_mac: isMac,
});
setPersonProperties({ platform_preference: "web" });
};
const handleOpenCloud = () => {
setDialog("cloud");
captureEvent("onboarding_runtime_path_selected", {
path: "cloud_waitlist",
source: "step3",
is_mac: isMac,
});
};
const handleCliConnect = () => {
if (!picker.selected) return;
setDialog(null);
onNext(picker.selected);
};
const footerHint = (() => {
if (waitlistSubmitted) {
return "You're on the waitlist — pick Skip to keep exploring.";
}
if (downloaded) {
return "Finish setup on the download page, then come back to this tab.";
}
return "Pick a path above — or skip and configure a runtime later.";
})();
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" />
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">
Step 3 · Runtime
</div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
Connect a runtime.
</h1>
<p className="mt-4 max-w-[560px] text-[15.5px] leading-[1.55] text-muted-foreground">
A runtime is what actually runs your agents&apos; work. Pick
how you&apos;d like to set one up.
</p>
<div className="mt-10 flex max-w-[560px] flex-col gap-3.5">
<ForkPrimary onClick={pickDesktop} downloaded={downloaded} />
<ForkAlt
title="Install the CLI"
subtitle="For servers, remote dev boxes, and headless setups. Terminal required."
actionLabel="Show steps"
onAction={handleOpenCli}
/>
<ForkAlt
title="Cloud runtime"
subtitle="We host the runtime. Not live yet — join the waitlist."
actionLabel={
waitlistSubmitted ? "On the list" : "Join waitlist"
}
onAction={handleOpenCloud}
/>
</div>
</div>
</main>
{/* Footer — 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 is the self-serve exit. */}
<footer className="flex shrink-0 items-center justify-between gap-4 bg-background px-6 py-4 sm:px-10 md:px-14 lg:px-16">
<span
aria-live="polite"
className="text-xs text-muted-foreground"
>
{footerHint}
</span>
<Button variant="secondary" onClick={() => onNext(null)}>
Skip for now
</Button>
</footer>
</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}
/>
<CloudWaitlistDialog
open={dialog === "cloud"}
onClose={() => setDialog(null)}
submitted={waitlistSubmitted}
onSubmitted={() => {
setWaitlistSubmitted(true);
onWaitlistSubmitted?.();
}}
/>
</div>
);
}
// ------------------------------------------------------------
// Fork cards
// ------------------------------------------------------------
function ForkPrimary({
onClick,
downloaded,
}: {
onClick: () => void;
downloaded: boolean;
}) {
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 ? "Continuing on the download page…" : "Download the desktop app"}
</div>
<div className="mt-1 text-[13px] text-background/60">
{downloaded
? "Opened in a new tab. Pick your installer there, then finish setup on desktop."
: "Bundled daemon, zero setup. Pick your platform on the next page."}
</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"
>
Download
<ArrowRight className="h-3.5 w-3.5" />
</span>
</button>
);
}
/**
* Alt card with an explicit right-side action pill. The whole card is
* clickable (so you can hit the title/subtitle too), but the pill is the
* visual anchor — it's what tells the user "this card is a button".
* Pressing it opens a dialog that owns the real content + action.
*/
function ForkAlt({
title,
subtitle,
actionLabel,
onAction,
}: {
title: string;
subtitle: ReactNode;
actionLabel: ReactNode;
onAction: () => void;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border bg-card px-5 py-4">
<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>
<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;
}) {
return (
<Dialog open={open} onOpenChange={(o) => (o ? null : onClose())}>
{/* max-h + flex column so an unbounded runtime list (N machines)
triggers internal scrolling instead of pushing the footer's
Connect button below the viewport. */}
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Install the CLI</DialogTitle>
<DialogDescription>
Same daemon as Desktop, installed via terminal. Use it when
Desktop doesn&apos;t fit servers, remote dev boxes, or
headless setups.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pt-2">
{cliInstructions}
{/* Live probe. Shows a staged waiting message with elapsed-
time fallbacks while no runtime is detected; flips to
a success list once the daemon registers via WS. */}
{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">
{runtimes.length} runtime{runtimes.length > 1 ? "s" : ""}{" "}
connected
</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
? `Selected: ${selectedName}`
: "Pick a runtime above."
: null}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button disabled={!canConnect} onClick={onConnect}>
Connect &amp; continue
<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 [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">
Live · Listening for your daemon
</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" && (
<>
Run the command above. As soon as{" "}
<span className="font-mono">multica setup</span> finishes
browser sign-in and the daemon starts, your runtime will
appear here automatically (usually 1030 seconds).
</>
)}
{stage === "midway" && (
<>
Still listening. Make sure you finished the browser tab that{" "}
<span className="font-mono">multica setup</span> opened it
needs you to approve the sign-in before the daemon can start.
</>
)}
{stage === "slow" && (
<>
Taking longer than usual. Check the terminal where you ran{" "}
<span className="font-mono">multica setup</span> for errors.
</>
)}
{stage === "stalled" && (
<>
Nothing coming through yet. If you&apos;re not comfortable
with the terminal,{" "}
<span className="font-medium text-foreground">Desktop</span>{" "}
is the smoother path it bundles the daemon. Close this
dialog and pick Desktop, or hit Skip to continue.
</>
)}
</p>
</div>
);
}
// ------------------------------------------------------------
// Cloud waitlist dialog
// ------------------------------------------------------------
/**
* Modal dialog for the cloud waitlist path. Wraps the shared
* `CloudWaitlistExpand` form. Submitting it records interest — the
* dialog does NOT advance the onboarding flow. After submit, the user
* closes the dialog and can hit Skip in the footer.
*/
function CloudWaitlistDialog({
open,
onClose,
submitted,
onSubmitted,
}: {
open: boolean;
onClose: () => void;
submitted: boolean;
onSubmitted: () => void;
}) {
return (
<Dialog open={open} onOpenChange={(o) => (o ? null : onClose())}>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Join the cloud runtime waitlist</DialogTitle>
<DialogDescription>
Cloud runtimes aren&apos;t live yet. Leave your email and
we&apos;ll email you when they are.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
<CloudWaitlistExpand
submitted={submitted}
onSubmitted={onSubmitted}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{submitted ? "Close" : "Cancel"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}