Files
multica/packages/views/onboarding/steps/step-runtime-connect.tsx
Jiayuan Zhang 6f21cb8f3e [codex] Simplify onboarding runtime bootstrap (#2836)
* feat(onboarding): simplify runtime bootstrap

* fix(onboarding): close private-helper reuse hole and guide-issue nav race

- server: when bootstrap looks for an existing Multica Helper, require
  Visibility="workspace" so a private helper owned by another member
  can't be auto-assigned to the onboarding issue (and trigger a task as
  that private agent), which would have bypassed canAccessPrivateAgent.
- web onboarding page: refreshMe() inside bootstrap flips hasOnboarded
  before onComplete fires, letting the guard's router.replace overtake
  onComplete's router.push to the new guide issue. Mark the page as
  "completing" right before navigating so the guard stays silent during
  the in-flight transition.

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

* fix(runtimes): escape daemon command literals to satisfy i18next/no-literal-string

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <lambda@multica.ai>
2026-05-19 09:52:35 +02:00

669 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 { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Loader2, RefreshCw } from "lucide-react";
import { captureEvent, setPersonProperties } from "@multica/core/analytics";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { runtimeKeys } from "@multica/core/runtimes/queries";
import type { AgentRuntime } from "@multica/core/types";
import { DragStrip } from "@multica/views/platform";
import { StepHeader } from "../components/step-header";
import { RuntimeAsidePanel } from "../components/runtime-aside-panel";
import { useRuntimePicker } from "../components/use-runtime-picker";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { useT } from "../../i18n";
/**
* Step 3 (desktop) — connect a runtime.
*
* Owns the full window: DragStrip + 3-region app shell (header /
* scrolling middle / sticky footer) on the left, permanent
* educational aside on the right. Built to mirror Step 1
* questionnaire's shell so the onboarding flow reads as one
* continuous surface.
*
* Data layer (`useRuntimePicker`): TanStack Query polls every 2s
* while empty; `daemon:register` WS event invalidates instantly;
* default selection prefers online, falls back to first.
*
* Web routes to `StepPlatformFork` instead — it owns its own
* runtime picker embedded under the CLI expand.
*/
export function StepRuntimeConnect({
wsId,
onNext,
onBack,
onRefresh,
}: {
wsId: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Platform-level rescan hook. Desktop wires this to restart the
* bundled daemon so a freshly-installed CLI shows up — otherwise the
* daemon's PATH probe runs once at boot and never re-probes. */
onRefresh?: () => void | Promise<void>;
}) {
const { runtimes, selected, selectedId, setSelectedId } =
useRuntimePicker(wsId);
return (
<FancyView
wsId={wsId}
runtimes={runtimes}
selected={selected}
selectedId={selectedId}
setSelectedId={setSelectedId}
onNext={onNext}
onBack={onBack}
onRefresh={onRefresh}
/>
);
}
// ============================================================
// Fancy desktop view
// ============================================================
type Phase = "scanning" | "found" | "empty";
/** Input ms before an empty list flips from "scanning" to "empty". */
const EMPTY_TIMEOUT_MS = 5000;
function FancyView({
wsId,
runtimes,
selected,
selectedId,
setSelectedId,
onNext,
onBack,
onRefresh,
}: {
wsId: string;
runtimes: AgentRuntime[];
selected: AgentRuntime | null;
selectedId: string | null;
setSelectedId: (id: string) => void;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
onRefresh?: () => void | Promise<void>;
}) {
const { t } = useT("onboarding");
const qc = useQueryClient();
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
// Flip to "empty" only after we've waited long enough for the daemon
// to report. The 5s budget covers the bundled daemon's typical 13s
// boot; anything past that is a genuine "no runtime" situation and we
// switch from scanning skeletons to the skip / refresh exits.
// `scanEpoch` resets the timer when the user hits Refresh, so a
// freshly-installed CLI gets another scanning window before falling
// back to the empty state.
const [scanEpoch, setScanEpoch] = useState(0);
const [hasTimedOut, setHasTimedOut] = useState(false);
useEffect(() => {
if (runtimes.length > 0) return;
setHasTimedOut(false);
const id = window.setTimeout(() => setHasTimedOut(true), EMPTY_TIMEOUT_MS);
return () => window.clearTimeout(id);
}, [runtimes.length, scanEpoch]);
const phase: Phase =
runtimes.length > 0 ? "found" : hasTimedOut ? "empty" : "scanning";
const onlineCount = runtimes.filter((r) => r.status === "online").length;
// One-shot analytics event when the scan window resolves. Answers the
// question "did the user actually have any AI CLI installed on this
// machine when they hit Step 3" — currently unanswerable from the
// existing funnel because a zero-CLI daemon fails to register at all,
// so `runtime_registered` is silent on that cohort. Emitting from here
// (rather than the daemon) keeps the signal in sync with what the UI
// actually showed the user: "scanning → found" vs "scanning → empty"
// after the 5s grace period.
const detectStartRef = useRef<number | null>(null);
if (detectStartRef.current === null) {
detectStartRef.current =
typeof performance !== "undefined" ? performance.now() : Date.now();
}
const detectedEmittedRef = useRef(false);
useEffect(() => {
if (detectedEmittedRef.current) return;
if (phase === "scanning") return;
detectedEmittedRef.current = true;
const providers = Array.from(
new Set(runtimes.map((r) => r.provider).filter(Boolean)),
).sort();
const now =
typeof performance !== "undefined" ? performance.now() : Date.now();
const detectMs = Math.round(now - (detectStartRef.current ?? now));
captureEvent("onboarding_runtime_detected", {
source: "onboarding",
surface: "step3_desktop",
workspace_id: wsId,
outcome: phase,
runtime_count: runtimes.length,
online_count: onlineCount,
providers,
has_claude: providers.includes("claude"),
has_codex: providers.includes("codex"),
has_cursor: providers.includes("cursor"),
detect_ms: detectMs,
});
setPersonProperties({
has_any_cli: runtimes.length > 0,
detected_cli_count: runtimes.length,
});
}, [phase, runtimes, onlineCount]);
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) });
detectedEmittedRef.current = false;
detectStartRef.current =
typeof performance !== "undefined" ? performance.now() : Date.now();
setScanEpoch((n) => n + 1);
} finally {
setRefreshing(false);
}
}, [onRefresh, qc, wsId, refreshing]);
// Skip is always available — regardless of phase. Hitting Skip routes
// through the runtime-less branch, which creates one focused self-serve
// onboarding issue instead of seeding the old starter project.
const handleSkip = async () => {
if (submitting) return;
setSubmitting(true);
try {
await onNext(null);
} finally {
setSubmitting(false);
}
};
// Continue only makes sense when a runtime is selected. Otherwise
// there's nothing to pass to Step 4.
const canContinue = phase === "found" && selected !== null;
const handleContinue = async () => {
if (!canContinue || submitting) return;
setSubmitting(true);
try {
await onNext(selected);
} finally {
setSubmitting(false);
}
};
const footerHint =
phase === "found" && selected
? t(($) => $.step_runtime.hint_selected, { name: selected.name })
: phase === "found"
? t(($) => $.step_runtime.hint_pick)
: phase === "scanning"
? t(($) => $.step_runtime.hint_waiting)
: t(($) => $.step_runtime.hint_skip_or_refresh);
return (
<div className="animate-onboarding-enter grid h-full min-h-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_480px]">
{/* Left — DragStrip + 3-region app shell */}
<div className="flex min-h-0 flex-col">
<DragStrip />
{/* Header — Back + horizontal step indicator */}
<header className="flex shrink-0 items-center gap-4 bg-background px-6 py-3 sm:px-10 md:px-14 lg:px-16">
{onBack ? (
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t(($) => $.common.back)}
</button>
) : (
<span aria-hidden className="w-0" />
)}
<div className="flex-1">
<StepHeader currentStep="runtime" />
</div>
</header>
{/* Scrollable middle — content changes by phase but always wraps
at max-w-[620px] so the 2-column runtime grid has room to
breathe without stretching into readability territory.
Skip + Continue sit inline directly below the phase view
(not in a sticky bottom footer) so the action bar stays
close to the form content and the page doesn't leave a
large dead zone when the runtime list is short. */}
<main
ref={mainRef}
style={fadeStyle}
className="min-h-0 flex-1 overflow-y-auto"
>
{/* key=phase forces a remount on phase transition so the
`animate-onboarding-enter` animation replays — otherwise CSS
only runs on initial mount and scanning→found would be a
hard cut. */}
<div
key={phase}
className="animate-onboarding-enter mx-auto w-full max-w-[620px] px-6 py-10 sm:px-10 md:px-14 lg:px-0 lg:py-14"
>
{phase === "scanning" && <ScanningView />}
{phase === "found" && (
<FoundView
runtimes={runtimes}
selectedId={selectedId}
onSelect={setSelectedId}
onlineCount={onlineCount}
onRefresh={handleRefresh}
refreshing={refreshing}
/>
)}
{phase === "empty" && (
<EmptyView
onSkip={() => onNext(null)}
onRefresh={handleRefresh}
refreshing={refreshing}
/>
)}
<div className="mt-8 flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<span
aria-live="polite"
className="mr-auto text-xs text-muted-foreground"
>
{footerHint}
</span>
<div className="flex items-center gap-2">
<Button
size="lg"
variant="secondary"
disabled={submitting}
onClick={handleSkip}
>
{t(($) => $.step_runtime.skip)}
</Button>
<Button
size="lg"
disabled={!canContinue || submitting}
onClick={handleContinue}
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t(($) => $.common.continue)}
<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>
);
}