fix(onboarding): refresh button, "agent runtime" wording, coming-soon card

Three fixes on the desktop Step 3 empty state per review:

1. Empty headline + hints now say "agent runtime", matching the
   picker-context terminology established earlier in this PR.
2. Add a Refresh button (header pill in Found, inline with the
   headline in Empty). Desktop wires it to restart the bundled
   daemon so a freshly-installed Claude/Codex/Cursor CLI is picked
   up — the daemon's PATH probe runs once at boot, so without a
   restart the install would only take effect on next launch.
3. "Use a cloud computer" loses the waitlist dialog and renders as
   a disabled "Coming soon" badge, aligning with the web fork.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Jiayuan Zhang
2026-05-19 01:41:40 +08:00
parent eeee3b4bcf
commit ab99b045d5
7 changed files with 187 additions and 124 deletions

View File

@@ -74,6 +74,13 @@ function WindowOverlayInner() {
push(paths.root());
}
}}
// Restart the bundled daemon when the user hits Refresh on
// Step 3. The daemon's PATH probe runs once at boot, so a
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
// up until the daemon is bounced.
onRuntimeRefresh={async () => {
await window.daemonAPI?.restart?.();
}}
/>
)}
</div>

View File

@@ -39,7 +39,8 @@
"common": {
"back": "Back",
"continue": "Continue",
"skip": "Skip"
"skip": "Skip",
"cancel": "Cancel"
},
"questions": {
"eyebrow_about_you": "About you",
@@ -265,26 +266,22 @@
"status_n_online": "{{count}} online",
"online_label": "online",
"offline_label": "offline",
"empty_headline": "No AI coding tools found on this computer yet.",
"empty_lede_prefix": "Multica works through AI coding tools like ",
"empty_lede_suffix": ", and others — none are installed on this computer. Install one and come back, or pick a path below.",
"empty_headline": "No agent runtime found on this computer yet.",
"empty_lede_prefix": "An agent runtime is an AI coding tool we connect to — like ",
"empty_lede_suffix": ". Install one and refresh, or pick a path below.",
"empty_skip_title": "Skip for now",
"empty_skip_subtitle": "Enter your workspace in read-only mode. Your agent can't run tasks until an agent runtime is connected on this computer — but you can still browse, plan, and invite teammates.",
"empty_skip_action": "Skip",
"empty_waitlist_title": "Use a cloud computer",
"empty_waitlist_subtitle": "We'll run a computer for you in the cloud — no local install, no setup. Not live yet; click to leave your email and get notified.",
"empty_waitlist_action": "Join waitlist",
"empty_waitlist_done": "On the waitlist",
"dialog_title": "Join the cloud-computer waitlist",
"dialog_description": "Cloud computers aren't live yet. Leave your email and we'll email you when they are.",
"dialog_close": "Close",
"dialog_cancel": "Cancel",
"empty_waitlist_subtitle": "We'll run a computer for you in the cloud. Not live yet.",
"empty_waitlist_action": "Coming soon",
"skip": "Skip for now",
"refresh": "Refresh",
"refreshing": "Refreshing…",
"hint_selected": "Selected: {{name}}",
"hint_pick": "Pick an agent runtime above to continue.",
"hint_waiting": "Waiting for the first result…",
"hint_waitlist_done": "You're on the waitlist — skip to keep exploring.",
"hint_skip_or_waitlist": "Skip to enter your workspace, or join the cloud waitlist above."
"hint_skip_or_refresh": "Refresh after installing one, or skip to enter your workspace."
},
"step_platform": {
"eyebrow": "Connect a computer",

View File

@@ -39,7 +39,8 @@
"common": {
"back": "返回",
"continue": "继续",
"skip": "跳过"
"skip": "跳过",
"cancel": "取消"
},
"questions": {
"eyebrow_about_you": "关于你",
@@ -264,26 +265,22 @@
"status_n_online": "{{count}} 个在线",
"online_label": "在线",
"offline_label": "离线",
"empty_headline": "这台电脑上还没装支持的 AI 编程工具。",
"empty_lede_prefix": "Multica 借助本地的 AI 编程工具运行,比如 ",
"empty_lede_suffix": " 等 —— 这台电脑上一个都没装。装一个再回来,或在下面挑一条路。",
"empty_headline": "这台电脑上还没有可用的 agent 运行时。",
"empty_lede_prefix": "agent 运行时是我们接管的 AI 编程工具,比如 ",
"empty_lede_suffix": "。装好其中之一后点刷新,或在下面挑一条路。",
"empty_skip_title": "暂时跳过",
"empty_skip_subtitle": "进入工作区(只读模式)。在这台电脑上连接 agent 运行时之前,智能体无法执行 task —— 但你仍可以浏览、规划、邀请同事。",
"empty_skip_action": "跳过",
"empty_waitlist_title": "改用云电脑",
"empty_waitlist_subtitle": "我们会在云上为你运行一台电脑 —— 无需本地安装,无需配置。尚未上线,点击留下邮箱。",
"empty_waitlist_action": "加入候补",
"empty_waitlist_done": "已在候补",
"dialog_title": "加入云电脑候补名单",
"dialog_description": "云电脑尚未上线。留下邮箱,上线时通过邮件通知你。",
"dialog_close": "关闭",
"dialog_cancel": "取消",
"empty_waitlist_subtitle": "我们会在云上为你运行一台电脑。尚未上线。",
"empty_waitlist_action": "敬请期待",
"skip": "暂时跳过",
"refresh": "刷新",
"refreshing": "正在刷新…",
"hint_selected": "已选择:{{name}}",
"hint_pick": "在上方选一个 agent 运行时继续。",
"hint_waiting": "等待第一个结果...",
"hint_waitlist_done": "已在候补名单 —— 跳过即可继续探索。",
"hint_skip_or_waitlist": "跳过进入工作区,或在上方加入云候补。"
"hint_skip_or_refresh": "装好后点刷新,或跳过进入工作区。"
},
"step_platform": {
"eyebrow": "连接一台电脑",

View File

@@ -71,9 +71,15 @@ function mergeQuestionnaire(
export function OnboardingFlow({
onComplete,
runtimeInstructions,
onRuntimeRefresh,
}: {
onComplete: (workspace?: Workspace) => void;
runtimeInstructions?: React.ReactNode;
/** Desktop wires this to restart the bundled daemon so a freshly
* installed agent CLI gets picked up on the runtime step. Web omits
* it — its CLI install flow already runs on the user's machine and
* the embedded picker reacts to daemon:register events. */
onRuntimeRefresh?: () => void | Promise<void>;
}) {
const { t } = useT("onboarding");
const user = useAuthStore((s) => s.user);
@@ -94,11 +100,6 @@ export function OnboardingFlow({
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [runtime, setRuntime] = useState<AgentRuntime | null>(null);
const [, setAgent] = useState<Agent | null>(null);
// Sticky flag: Step 3's cloud-waitlist dialog only lives inside
// StepPlatformFork's local state, so the completion path for
// `runtime=null && waitlist submitted` would be invisible here without
// a shell-level record. One way latch; never cleared once set.
const [waitlistSubmitted, setWaitlistSubmitted] = useState(false);
// Fetched at Step 0 + Step 2. Step 2 uses it to detect a pre-existing
// workspace from an earlier abandoned onboarding (so StepWorkspace shows
@@ -293,7 +294,7 @@ export function OnboardingFlow({
wsId={workspace.id}
onNext={handleRuntimeNext}
onBack={() => handleBack("runtime")}
onWaitlistSubmitted={() => setWaitlistSubmitted(true)}
onRefresh={onRuntimeRefresh}
/>
);
}
@@ -325,17 +326,12 @@ export function OnboardingFlow({
);
}
// Derive the completion-path label for Step 5 here — runtime +
// waitlist state both live in this shell, StepFirstIssue doesn't
// have the visibility to compute it itself.
// runtime set → "full"
// no runtime + waitlist → "cloud_waitlist"
// no runtime, no waitlist → "runtime_skipped"
// Derive the completion-path label for Step 5. The cloud-waitlist
// exit was removed from Step 3 (replaced with a "Coming soon" badge)
// so this is now a binary: runtime → "full", no runtime → "runtime_skipped".
const completionPath: OnboardingCompletionPath = runtime
? "full"
: waitlistSubmitted
? "cloud_waitlist"
: "runtime_skipped";
: "runtime_skipped";
return (
<div className="animate-onboarding-enter flex min-h-full flex-col">

View File

@@ -415,7 +415,7 @@ function CliInstallDialog({
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>
{t(($) => $.step_runtime.dialog_cancel)}
{t(($) => $.common.cancel)}
</Button>
<Button disabled={!canConnect} onClick={onConnect}>
{t(($) => $.step_platform.cli_dialog_connect)}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AgentRuntime } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
@@ -64,10 +65,15 @@ function setPicker(patch: Partial<typeof mocks.pickerState> = {}) {
function renderStep() {
const onNext = vi.fn();
const onBack = vi.fn();
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<StepRuntimeConnect wsId="ws_test" onNext={onNext} onBack={onBack} />
</I18nProvider>,
<QueryClientProvider client={qc}>
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<StepRuntimeConnect wsId="ws_test" onNext={onNext} onBack={onBack} />
</I18nProvider>
</QueryClientProvider>,
);
return { onNext, onBack };
}

View File

@@ -1,25 +1,18 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
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 { CloudWaitlistExpand } from "../components/cloud-waitlist-expand";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { useT } from "../../i18n";
@@ -43,15 +36,15 @@ export function StepRuntimeConnect({
wsId,
onNext,
onBack,
onWaitlistSubmitted,
onRefresh,
}: {
wsId: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Parent-level latch used to label the onboarding completion path
* as `cloud_waitlist` when the user ends up skipping this step
* after submitting the waitlist form. */
onWaitlistSubmitted?: () => 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);
@@ -65,7 +58,7 @@ export function StepRuntimeConnect({
setSelectedId={setSelectedId}
onNext={onNext}
onBack={onBack}
onWaitlistSubmitted={onWaitlistSubmitted}
onRefresh={onRefresh}
/>
);
}
@@ -87,7 +80,7 @@ function FancyView({
setSelectedId,
onNext,
onBack,
onWaitlistSubmitted,
onRefresh,
}: {
wsId: string;
runtimes: AgentRuntime[];
@@ -96,22 +89,28 @@ function FancyView({
setSelectedId: (id: string) => void;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
onWaitlistSubmitted?: () => 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 / cloud-waitlist exits.
// 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;
const t = window.setTimeout(() => setHasTimedOut(true), EMPTY_TIMEOUT_MS);
return () => window.clearTimeout(t);
}, [runtimes.length]);
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";
@@ -165,10 +164,27 @@ function FancyView({
}, [phase, runtimes, onlineCount]);
const [submitting, setSubmitting] = useState(false);
// Cloud waitlist submission state lives here (rather than in EmptyView)
// so it survives phase flips — e.g. a runtime coming online after the
// user has already submitted the waitlist form.
const [waitlistSubmitted, setWaitlistSubmitted] = 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
// the flow through the self-serve branch (agent=null), which still
@@ -202,9 +218,7 @@ function FancyView({
? t(($) => $.step_runtime.hint_pick)
: phase === "scanning"
? t(($) => $.step_runtime.hint_waiting)
: waitlistSubmitted
? t(($) => $.step_runtime.hint_waitlist_done)
: t(($) => $.step_runtime.hint_skip_or_waitlist);
: 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]">
@@ -259,16 +273,15 @@ function FancyView({
selectedId={selectedId}
onSelect={setSelectedId}
onlineCount={onlineCount}
onRefresh={handleRefresh}
refreshing={refreshing}
/>
)}
{phase === "empty" && (
<EmptyView
waitlistSubmitted={waitlistSubmitted}
onWaitlistSubmitted={() => {
setWaitlistSubmitted(true);
onWaitlistSubmitted?.();
}}
onSkip={() => onNext(null)}
onRefresh={handleRefresh}
refreshing={refreshing}
/>
)}
@@ -348,11 +361,15 @@ function FoundView({
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;
@@ -389,6 +406,11 @@ function FoundView({
/>
{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">
@@ -406,22 +428,28 @@ function FoundView({
}
function EmptyView({
waitlistSubmitted,
onWaitlistSubmitted,
onSkip,
onRefresh,
refreshing,
}: {
waitlistSubmitted: boolean;
onWaitlistSubmitted: () => void;
onSkip: () => void;
onRefresh: () => void;
refreshing: boolean;
}) {
const { t } = useT("onboarding");
const [waitlistOpen, setWaitlistOpen] = useState(false);
return (
<div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
{t(($) => $.step_runtime.empty_headline)}
</h1>
<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>
@@ -440,50 +468,82 @@ function EmptyView({
onAction={onSkip}
/>
<EmptyCard
<ComingSoonCard
title={t(($) => $.step_runtime.empty_waitlist_title)}
subtitle={t(($) => $.step_runtime.empty_waitlist_subtitle)}
actionLabel={
waitlistSubmitted
? t(($) => $.step_runtime.empty_waitlist_done)
: t(($) => $.step_runtime.empty_waitlist_action)
}
onAction={() => setWaitlistOpen(true)}
badgeLabel={t(($) => $.step_runtime.empty_waitlist_action)}
/>
</div>
<Dialog
open={waitlistOpen}
onOpenChange={(o) => (o ? null : setWaitlistOpen(false))}
>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{t(($) => $.step_runtime.dialog_title)}</DialogTitle>
<DialogDescription>
{t(($) => $.step_runtime.dialog_description)}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
<CloudWaitlistExpand
submitted={waitlistSubmitted}
onSubmitted={onWaitlistSubmitted}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setWaitlistOpen(false)}>
{waitlistSubmitted
? t(($) => $.step_runtime.dialog_close)
: t(($) => $.step_runtime.dialog_cancel)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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