mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "连接一台电脑",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 1–3s
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user