mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 14:37:44 +02:00
* 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>
617 lines
22 KiB
TypeScript
617 lines
22 KiB
TypeScript
"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' work. Pick
|
||
how you'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'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 & 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, 5–30s) →
|
||
// ~2s daemon boot → immediate WS register. So under 15s means
|
||
// "still normal", 15–45s means "probably stuck on browser auth",
|
||
// 45–90s 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 10–30 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'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't live yet. Leave your email and
|
||
we'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>
|
||
);
|
||
}
|