Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan
411fd6a5a1 fix(views): improve connect-remote dialog layout and usability
- Widen dialog from sm:max-w-lg to sm:max-w-xl for longer commands
- Add max-h-[85vh] + overflow-y-auto so content scrolls on small screens
- Split monolithic code block into 4 separate labeled steps (install,
  configure, login, start daemon) — each with its own copy button
- Make copy buttons always visible instead of hover-only
- Condense security tips into a single compact paragraph
- Tighten vertical spacing throughout
2026-04-29 17:28:41 +02:00
Jiayuan
de0d30509e feat(views): add remote machine / AWS EC2 connection wizard to Runtimes page
Add a "Connect remote machine" CTA to the Runtimes page header and
empty state that opens a 3-step wizard dialog guiding users through:

1. Installing the Multica CLI on a remote machine
2. Configuring, logging in with a PAT, and starting the daemon
3. Monitoring for runtime registration via WebSocket

Includes security tips (IAM roles, no root keys), troubleshooting
guidance (daemon status/logs, CLI version check), and post-connection
flow to create an agent on the newly registered runtime.

Closes MUL-1588
2026-04-29 15:34:22 +02:00
2 changed files with 424 additions and 14 deletions

View File

@@ -0,0 +1,388 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Check,
ChevronRight,
Copy,
Loader2,
Server,
ShieldAlert,
Terminal,
Wrench,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeKeys } from "@multica/core/runtimes/queries";
import { useWSEvent } from "@multica/core/realtime";
import { paths, useWorkspaceSlug } from "@multica/core/paths";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Button } from "@multica/ui/components/ui/button";
import { useNavigation } from "../../navigation";
type Step = "instructions" | "waiting" | "success";
export function ConnectRemoteDialog({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState<Step>("instructions");
const [copied, setCopied] = useState<string | null>(null);
const wsId = useWorkspaceId();
const slug = useWorkspaceSlug();
const qc = useQueryClient();
const navigation = useNavigation();
const newRuntimeIdRef = useRef<string | null>(null);
// Listen for a new runtime registration while the dialog is open
const handleDaemonRegister = useCallback(
(payload: unknown) => {
if (step === "waiting" || step === "instructions") {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
const p = payload as Record<string, unknown> | null;
if (p?.runtime_id && typeof p.runtime_id === "string") {
newRuntimeIdRef.current = p.runtime_id;
}
setStep("success");
}
},
[step, qc, wsId],
);
useWSEvent("daemon:register", handleDaemonRegister);
const copyToClipboard = useCallback(
(text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
},
[],
);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(null), 2000);
return () => clearTimeout(t);
}, [copied]);
const handleGoToAgents = () => {
onClose();
if (slug) {
navigation.push(paths.workspace(slug).agents());
}
};
const handleGoToRuntime = () => {
onClose();
if (slug && newRuntimeIdRef.current) {
navigation.push(
paths.workspace(slug).runtimeDetail(newRuntimeIdRef.current),
);
}
};
return (
<Dialog open onOpenChange={(v) => !v && onClose()}>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-xl">
{step === "instructions" && (
<InstructionsStep
copied={copied}
onCopy={copyToClipboard}
onNext={() => setStep("waiting")}
onClose={onClose}
/>
)}
{step === "waiting" && (
<WaitingStep onBack={() => setStep("instructions")} />
)}
{step === "success" && (
<SuccessStep
onGoToAgents={handleGoToAgents}
onGoToRuntime={
newRuntimeIdRef.current ? handleGoToRuntime : undefined
}
/>
)}
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Step 1: Installation instructions
// ---------------------------------------------------------------------------
const INSTALL_CMD = "curl -fsSL https://multica.ai/install.sh | sh";
const CONFIGURE_CMD = `multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai`;
const LOGIN_CMD = "multica login --token <YOUR_TOKEN>";
const START_CMD = `multica daemon start --device-name "my-ec2-instance"
multica daemon status`;
function CodeBlock({
code,
copyKey,
copied,
onCopy,
}: {
code: string;
copyKey: string;
copied: string | null;
onCopy: (text: string, key: string) => void;
}) {
const isCopied = copied === copyKey;
return (
<div className="relative rounded-md border bg-muted/50">
<pre className="overflow-x-auto p-2.5 pr-10 font-mono text-xs leading-relaxed text-foreground">
{code}
</pre>
<button
type="button"
onClick={() => onCopy(code, copyKey)}
className="absolute top-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded border bg-background text-muted-foreground transition-colors hover:text-foreground"
>
{isCopied ? (
<Check className="h-3 w-3 text-success" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
);
}
function InstructionsStep({
copied,
onCopy,
onNext,
onClose,
}: {
copied: string | null;
onCopy: (text: string, key: string) => void;
onNext: () => void;
onClose: () => void;
}) {
return (
<>
<DialogHeader>
<DialogTitle>Connect a remote machine</DialogTitle>
<DialogDescription>
Run these commands on your remote machine (e.g. AWS EC2) to install the
Multica CLI and register it as a runtime.
</DialogDescription>
</DialogHeader>
<div className="-mx-4 min-h-0 flex-1 overflow-y-auto px-4">
<div className="space-y-3">
{/* Step 1: Install */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Terminal className="h-3.5 w-3.5" />
1. Install the CLI
</div>
<CodeBlock
code={INSTALL_CMD}
copyKey="install"
copied={copied}
onCopy={onCopy}
/>
</div>
{/* Step 2: Configure */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Server className="h-3.5 w-3.5" />
2. Configure
</div>
<CodeBlock
code={CONFIGURE_CMD}
copyKey="config"
copied={copied}
onCopy={onCopy}
/>
</div>
{/* Step 3: Login */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
3. Login with a personal access token
</div>
<CodeBlock
code={LOGIN_CMD}
copyKey="login"
copied={copied}
onCopy={onCopy}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Create one in{" "}
<span className="font-medium text-foreground">
Settings Tokens
</span>
.
</p>
</div>
{/* Step 4: Start daemon */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
4. Start the daemon
</div>
<CodeBlock
code={START_CMD}
copyKey="start"
copied={copied}
onCopy={onCopy}
/>
</div>
{/* Security tips */}
<div className="rounded-md border border-warning/30 bg-warning/5 p-2.5">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<div className="text-[11px] leading-relaxed text-muted-foreground">
<span className="font-medium text-foreground">Security: </span>
Use an EC2 IAM role or least-privilege credentials. Never put
root keys into agent{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
custom_env
</code>
. The daemon uses outbound connections only no inbound ports
needed.
</div>
</div>
</div>
{/* Troubleshooting */}
<details className="group pb-1">
<summary className="flex cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
<Wrench className="h-3.5 w-3.5" />
Troubleshooting
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
</summary>
<ul className="mt-1.5 list-disc space-y-0.5 pl-8 text-[11px] text-muted-foreground">
<li>
Check status:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
multica daemon status
</code>
</li>
<li>
View logs:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
multica daemon logs -f
</code>
</li>
<li>
Verify provider:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
claude --version
</code>
</li>
<li>
Desktop auto-scans only your local machine. Remote machines must
run{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
multica daemon
</code>{" "}
separately.
</li>
</ul>
</details>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={onNext}>
I&apos;ve started the daemon
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</DialogFooter>
</>
);
}
// ---------------------------------------------------------------------------
// Step 2: Waiting for registration
// ---------------------------------------------------------------------------
function WaitingStep({ onBack }: { onBack: () => void }) {
return (
<>
<DialogHeader>
<DialogTitle>Waiting for runtime</DialogTitle>
<DialogDescription>
Listening for your remote daemon to register. This page updates
automatically no need to refresh.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Run{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
multica daemon status
</code>{" "}
on the remote machine to verify it&apos;s running.
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onBack}>
Back
</Button>
</DialogFooter>
</>
);
}
// ---------------------------------------------------------------------------
// Step 3: Success
// ---------------------------------------------------------------------------
function SuccessStep({
onGoToAgents,
onGoToRuntime,
}: {
onGoToAgents: () => void;
onGoToRuntime?: () => void;
}) {
return (
<>
<DialogHeader>
<DialogTitle>Runtime connected!</DialogTitle>
<DialogDescription>
Your remote machine has registered as a runtime. You can now create an
agent that dispatches tasks to it.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-success/10">
<Check className="h-6 w-6 text-success" />
</div>
</div>
<DialogFooter>
{onGoToRuntime && (
<Button variant="ghost" onClick={onGoToRuntime}>
View runtime
</Button>
)}
<Button onClick={onGoToAgents}>
Create an agent
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</DialogFooter>
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Search, Server } from "lucide-react";
import { Plus, Search, Server } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -18,6 +18,7 @@ import {
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { PageHeader } from "../../layout/page-header";
import { ConnectRemoteDialog } from "./connect-remote-dialog";
import { RuntimeList } from "./runtime-list";
type RuntimeFilter = "mine" | "all";
@@ -92,6 +93,7 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
const [scope, setScope] = useState<RuntimeFilter>("mine");
const [healthFilter, setHealthFilter] = useState<HealthFilter>("all");
const [search, setSearch] = useState("");
const [showConnectDialog, setShowConnectDialog] = useState(false);
// One unified cache per workspace: scope (Mine/All) is a view filter, not
// a fetch dimension. Splitting on owner used to give us two TanStack cache
@@ -154,14 +156,17 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
return (
<div className="flex flex-1 min-h-0 flex-col">
<PageHeaderBar totalCount={totalCount} />
<PageHeaderBar
totalCount={totalCount}
onConnectRemote={() => setShowConnectDialog(true)}
/>
<div className="flex flex-1 min-h-0 flex-col gap-4 p-6">
{topSlot}
{showEmpty ? (
<div className="flex flex-1 items-center justify-center">
<EmptyState />
<EmptyState onConnectRemote={() => setShowConnectDialog(true)} />
</div>
) : (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
@@ -189,6 +194,10 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
</div>
)}
</div>
{showConnectDialog && (
<ConnectRemoteDialog onClose={() => setShowConnectDialog(false)} />
)}
</div>
);
}
@@ -198,9 +207,15 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
// Page-level actions (Search, scope, filter) live in the card below.
// ---------------------------------------------------------------------------
function PageHeaderBar({ totalCount }: { totalCount: number }) {
function PageHeaderBar({
totalCount,
onConnectRemote,
}: {
totalCount: number;
onConnectRemote: () => void;
}) {
return (
<PageHeader className="px-5">
<PageHeader className="justify-between px-5">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">Runtimes</h1>
@@ -209,9 +224,6 @@ function PageHeaderBar({ totalCount }: { totalCount: number }) {
{totalCount}
</span>
)}
{/* Tagline sits right next to the title — same flex group, single
sentence + docs link. Hidden below md so it never collides with
the title on narrow screens. */}
<p className="ml-2 hidden text-xs text-muted-foreground md:block">
Machines and cloud workers running CLI sessions for your agents.{" "}
<a
@@ -224,6 +236,10 @@ function PageHeaderBar({ totalCount }: { totalCount: number }) {
</a>
</p>
</div>
<Button type="button" size="sm" onClick={onConnectRemote}>
<Plus className="h-3 w-3" />
Connect remote machine
</Button>
</PageHeader>
);
}
@@ -413,7 +429,7 @@ function HealthChip({
// workspace. Different from "filter matches nothing" (NoMatchesState).
// ---------------------------------------------------------------------------
function EmptyState() {
function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
@@ -421,12 +437,18 @@ function EmptyState() {
</div>
<h2 className="mt-4 text-base font-semibold">No runtimes yet</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Runtimes register automatically when a daemon connects. Run{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
multica daemon start
</code>{" "}
on your machine, or invite a teammate whose daemon is already running.
Desktop auto-scans your local machine. For AWS EC2 or other remote
machines, connect them using the setup wizard.
</p>
<Button
type="button"
size="sm"
onClick={onConnectRemote}
className="mt-5"
>
<Plus className="h-3 w-3" />
Connect remote machine
</Button>
</div>
);
}