Files
multica/packages/views/onboarding/steps/step-runtime-connect.tsx
Jiayuan Zhang e8746c5030 fix(onboarding): eliminate Desktop runtime-step false-negative flash + parallelize daemon version detection (MUL-5119) (#5756)
* perf(daemon): parallelize runtime version detection during registration (MUL-5119)

Registration probed each agent CLI's `--version` serially, so total latency
was the sum of every probe. On an onboarding host with several coding tools
installed that stacked into many seconds before runtimes registered — long
enough that the desktop runtime step timed out into its empty 'no runtime
found' state while the daemon was still working.

Fan the probes out with a bounded errgroup so total latency tracks the slowest
single probe instead of their sum. Each probe still self-heals a vanished
pinned path and re-detects the live version (no cross-registration caching, so
an in-place upgrade is still reported correctly); failures are logged and
skipped as before. Results are sorted by provider for a deterministic payload.

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

* fix(onboarding): stop the runtime step flashing 'no runtime found' while the daemon probes (MUL-5119)

The runtime step flipped from scanning to the empty 'no runtime found' state on
a fixed 5s wall-clock, so a machine that does have coding tools installed saw a
false-negative flash whenever registration outlasted the timeout (cold start,
slow/wedged CLI, many CLIs).

Gate the empty flip on a desktop-only `runtimesPending` signal derived from the
local daemon's live status (booting, or running with agent CLIs detected on the
host): while pending, keep the scanning skeleton past the soft timeout. An
absolute hard-timeout ceiling still guarantees a fallback so a wedged probe
can't pin the step on the skeleton forever. Web omits the signal and keeps the
plain wall-clock timeout.

Also drop the two dead/duplicated affordances on the step: the permanently
disabled 'Start exploring' button now renders only in the found phase, and the
empty state's duplicate footer 'Skip for now' is removed in favour of its own
Skip card.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:29:47 +08:00

671 lines
22 KiB
TypeScript

"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,
runtimesPending,
}: {
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>;
/** Desktop-only signal: the local daemon is still booting or is known to
* have agent CLIs on this host that haven't finished registering yet.
* While true, the step keeps showing the scanning skeleton past the normal
* timeout instead of flashing the "no runtime found" empty state — that
* empty state is a false negative when the daemon is mid-probe (MUL-5119).
* Web omits it and keeps the plain wall-clock timeout. */
runtimesPending?: boolean;
}) {
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}
runtimesPending={runtimesPending}
/>
);
}
// ============================================================
// Fancy desktop view
// ============================================================
type Phase = "scanning" | "found" | "empty";
/** Idle ms before an empty list flips from "scanning" to "empty" — unless the
* platform reports runtimes are still pending (see `runtimesPending`). */
const EMPTY_TIMEOUT_MS = 5000;
/** Absolute ceiling: even while the platform still reports runtimes pending,
* fall back to the empty exits after this so a wedged version probe can never
* hang the step on the scanning skeleton forever. */
const EMPTY_HARD_TIMEOUT_MS = 20000;
function FancyView({
wsId,
runtimes,
selected,
selectedId,
setSelectedId,
onNext,
onBack,
onRefresh,
runtimesPending,
}: {
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>;
runtimesPending?: boolean;
}) {
const { t } = useT("onboarding");
const qc = useQueryClient();
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
// Decide when an empty runtime list stops being "still scanning" and becomes
// the genuine "no runtime" exits. Two timers run while the list is empty:
//
// - soft (EMPTY_TIMEOUT_MS): the normal budget. Once it fires we flip to
// empty UNLESS `runtimesPending` says the platform (desktop daemon) is
// still booting or mid-probe — registration on a host with several CLIs
// can outlast the soft budget, and flashing "no runtime found" while the
// daemon is still working is a false negative (MUL-5119).
// - hard (EMPTY_HARD_TIMEOUT_MS): an absolute ceiling so a wedged probe
// that never resolves `runtimesPending` back to false can't pin the step
// on the scanning skeleton forever.
//
// `scanEpoch` resets both timers 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 [softTimedOut, setSoftTimedOut] = useState(false);
const [hardTimedOut, setHardTimedOut] = useState(false);
useEffect(() => {
if (runtimes.length > 0) return;
setSoftTimedOut(false);
setHardTimedOut(false);
const soft = window.setTimeout(() => setSoftTimedOut(true), EMPTY_TIMEOUT_MS);
const hard = window.setTimeout(
() => setHardTimedOut(true),
EMPTY_HARD_TIMEOUT_MS,
);
return () => {
window.clearTimeout(soft);
window.clearTimeout(hard);
};
}, [runtimes.length, scanEpoch]);
const phase: Phase =
runtimes.length > 0
? "found"
: hardTimedOut || (softTimedOut && runtimesPending !== true)
? "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={handleSkip}
onRefresh={handleRefresh}
refreshing={refreshing}
/>
)}
{/* Footer action bar. The controls are phase-scoped so no dead or
duplicated affordance ever shows:
- Skip: shown while scanning / found. The empty phase owns its
own prominent Skip card, so the footer Skip is dropped there
to avoid two "Skip for now" buttons on one screen.
- Start exploring: only actionable once a runtime is picked, so
it renders only in the found phase instead of sitting
permanently disabled through scanning / empty. */}
<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>
{phase !== "empty" && (
<div className="flex items-center gap-2">
<Button
size="lg"
variant="secondary"
disabled={submitting}
onClick={handleSkip}
>
{t(($) => $.step_runtime.skip)}
</Button>
{phase === "found" && (
<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>
);
}