Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
06242c7b77 Redesign runtimes machine layout 2026-05-17 22:07:19 +08:00
8 changed files with 1132 additions and 500 deletions

View File

@@ -4,7 +4,6 @@ import {
Play,
Square,
RotateCw,
Server,
Activity,
ScrollText,
} from "lucide-react";
@@ -12,15 +11,7 @@ import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeListOptions } from "@multica/core/runtimes";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import {
Card,
CardAction,
CardDescription,
CardHeader,
CardTitle,
} from "@multica/ui/components/ui/card";
import {
Dialog,
DialogContent,
@@ -32,24 +23,13 @@ import {
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import type { DaemonStatus } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
daemonStateDescription,
formatUptime,
} from "../../../shared/daemon-types";
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
/**
* Header card on the desktop Runtimes page that surfaces the daemon embedded
* in this Electron app. The same daemon process registers N runtimes with the
* server (one per detected CLI), which appear in the runtime list below — so
* this card is the parent control surface for "what's running on this Mac".
*
* Why this lives only on desktop: web users don't have an embedded daemon;
* they bring their own (CLI-launched or remote VM) and just see runtimes in
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
* Desktop-only controls for the daemon embedded in this Electron app. The
* shared runtimes page renders this inside the selected local machine header.
*/
export function DaemonRuntimeCard() {
export function DaemonRuntimeActions() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
@@ -57,14 +37,8 @@ export function DaemonRuntimeCard() {
const wsId = useWorkspaceId();
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
// Snapshot also includes each agent's latest terminal; the filter below
// drops anything that isn't running/dispatched, so terminal rows pass
// through harmlessly.
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
// Used both to count "how many CLIs am I contributing" and to figure
// out which active tasks would be impacted by a Stop.
const localRuntimeIds = useMemo(() => {
if (!status.daemonId) return new Set<string>();
return new Set(
@@ -76,10 +50,6 @@ export function DaemonRuntimeCard() {
const runtimeCount = localRuntimeIds.size;
// Tasks that are actually doing work on this daemon right now —
// running or dispatched. Queued tasks haven't claimed a runtime yet,
// so stopping the daemon won't break them (they'll wait for any
// available daemon). The number drives the Stop-confirmation dialog.
const affectedTasks = useMemo(
() =>
snapshot.filter(
@@ -108,9 +78,6 @@ export function DaemonRuntimeCard() {
}
}, []);
// The actual stop call, separated from the click handler so we can call
// it both from the direct path (no active tasks) and from the confirm
// dialog's confirm button.
const performStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
@@ -119,8 +86,6 @@ export function DaemonRuntimeCard() {
}
}, []);
// Click on the Stop button. If there's nothing running, just stop;
// otherwise pop a confirm dialog explaining the blast radius.
const handleStopClick = useCallback(() => {
if (affectedTasks.length === 0) {
void performStop();
@@ -136,9 +101,6 @@ export function DaemonRuntimeCard() {
toast.error("Failed to restart daemon", { description: result.error });
return;
}
// Success feedback — the daemon takes a few seconds to come back online,
// and the only other UI signal is the state badge flipping briefly. A
// toast confirms the click was received and tells the user what to expect.
toast.success("Restarting daemon", {
description: "Runtimes will be back online in a few seconds.",
});
@@ -162,106 +124,64 @@ export function DaemonRuntimeCard() {
return (
<>
<Card size="sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-4 text-muted-foreground" />
Local daemon
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
/>
<span
className={cn(
"tabular-nums",
isRunning ? "text-foreground" : "text-muted-foreground",
)}
>
{DAEMON_STATE_LABELS[status.state]}
</span>
{isRunning && status.uptime && (
<span className="text-muted-foreground">
· {formatUptime(status.uptime)}
</span>
)}
</span>
</CardTitle>
<CardDescription>
{daemonStateDescription(status.state, runtimeCount)}
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-1.5">
{isRunning && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setPanelOpen(true)}
>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
<div className="flex flex-wrap items-center justify-end gap-1.5">
{isRunning && (
<>
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
{isStopped && (
<Button
size="sm"
onClick={handleStart}
disabled={actionLoading}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isStopped && (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleRetryInstall}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry setup
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleRetryInstall}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry setup
</Button>
)}
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</CardAction>
</CardHeader>
</Card>
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
<DaemonPanel
open={panelOpen}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeCard } from "./daemon-runtime-card";
import { DaemonRuntimeActions } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
@@ -32,7 +32,9 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
topSlot={<DaemonRuntimeCard />}
localDaemonId={status.daemonId ?? null}
localMachineName={status.deviceName ?? null}
localMachineActions={<DaemonRuntimeActions />}
bootstrapping={bootstrapping}
/>
);

View File

@@ -27,6 +27,42 @@
"hint": "This usually takes a few seconds. Your daemon is registering with the workspace."
}
},
"machine": {
"search_placeholder": "Search machines…",
"filters": {
"all": "All",
"online": "Online",
"issues": "Issues"
},
"section_local": "Local",
"section_remote": "Remote",
"section_cloud": "Cloud",
"this_machine": "This Mac",
"local_badge": "Local · this machine",
"runtime_count_one": "{{count}} runtime",
"runtime_count_other": "{{count}} runtimes",
"busy_count_one": "{{count}} busy",
"busy_count_other": "{{count}} busy",
"no_matches_title": "No machines",
"no_matches_hint": "No machines match the current search or filter.",
"select_machine": "Select a machine to inspect its runtimes.",
"metrics": {
"runtimes": "Runtimes",
"runtimes_hint_one": "{{count}} online",
"runtimes_hint_other": "{{count}} online",
"health": "Health",
"health_clear": "No issues",
"health_issues_one": "{{count}} issue",
"health_issues_other": "{{count}} issues",
"workload": "Workload",
"workload_value_idle": "Idle",
"workload_idle": "All idle",
"workload_hint": "{{running}} running · {{queued}} queued",
"cli": "CLI",
"cloud_worker": "Cloud worker",
"local_daemon": "Local daemon"
}
},
"health": {
"online": {
"label": "Online",

View File

@@ -27,6 +27,38 @@
"hint": "通常需要几秒钟。守护进程正在向工作区注册。"
}
},
"machine": {
"search_placeholder": "搜索机器...",
"filters": {
"all": "全部",
"online": "在线",
"issues": "异常"
},
"section_local": "本机",
"section_remote": "远程",
"section_cloud": "云端",
"this_machine": "本机",
"local_badge": "本地 · 这台机器",
"runtime_count_other": "{{count}} 个运行时",
"busy_count_other": "{{count}} 个忙碌",
"no_matches_title": "没有机器",
"no_matches_hint": "没有机器匹配当前搜索或筛选。",
"select_machine": "选择一台机器查看它的运行时。",
"metrics": {
"runtimes": "运行时",
"runtimes_hint_other": "{{count}} 个在线",
"health": "健康度",
"health_clear": "无异常",
"health_issues_other": "{{count}} 个异常",
"workload": "工作负载",
"workload_value_idle": "空闲",
"workload_idle": "全部空闲",
"workload_hint": "{{running}} 个运行中 · {{queued}} 个排队中",
"cli": "CLI",
"cloud_worker": "云端 worker",
"local_daemon": "本地守护进程"
}
},
"health": {
"online": {
"label": "在线",

View File

@@ -50,6 +50,7 @@ import {
isVersionNewer,
pctChange,
} from "../utils";
import { splitRuntimeName } from "./runtime-machines";
import { useT } from "../../i18n";
// Per-row data assembled at the page level. The columns reach into
@@ -63,18 +64,19 @@ export interface RuntimeRow {
canDelete: boolean;
}
// Column widths in px. Runtime, Health, and CLI grow together until the
// user resizes them. Their `size` values still flow into table.getTotalSize()
// to set the table's min-width, giving each grow column a real floor below
// which the container scrolls horizontally instead of shrinking further.
// Column widths in px. Runtime is the primary scanning column, so it keeps
// the only grow slot and receives the extra width until the user resizes it.
// The size values still flow into table.getTotalSize() to set the table's
// min-width, giving each column a real floor below which the container
// scrolls horizontally instead of shrinking further.
const COL_WIDTHS = {
runtime: 240,
health: 200,
owner: 60,
agents: 100,
workload: 140,
cost: 100,
cli: 140,
runtime: 340,
health: 150,
owner: 72,
agents: 92,
workload: 120,
cost: 96,
cli: 112,
// 60 = 16 left padding + 28 kebab + 16 right padding. Keeps the
// kebab's right edge 16px from the card so it lines up with the
// toolbar's px-4 right inset.
@@ -110,7 +112,6 @@ export function createRuntimeColumns({
id: "health",
header: () => t(($) => $.list.col_health),
size: COL_WIDTHS.health,
meta: { grow: true },
cell: ({ row }) => (
<HealthCell runtime={row.original.runtime} now={now} />
),
@@ -175,7 +176,6 @@ export function createRuntimeColumns({
id: "cli",
header: () => t(($) => $.list.col_cli),
size: COL_WIDTHS.cli,
meta: { grow: true },
cell: ({ row }) => (
<CliCell
runtime={row.original.runtime}
@@ -210,25 +210,12 @@ export function createRuntimeColumns({
// Helpers
// ---------------------------------------------------------------------------
// Backend formats `runtime.name` as `"<base> (<hostname>)"`. Every runtime on
// the same machine repeats the hostname suffix, so it dominates column width
// while carrying near-zero scan value once seen on the first row. Split it
// so the base name stays emphasised and the hostname renders muted.
export function splitRuntimeName(name: string): {
base: string;
hostname: string | null;
} {
const m = name.match(/^(.+?)\s+\(([^)]+)\)$/);
if (!m || !m[1] || !m[2]) return { base: name, hostname: null };
return { base: m[1], hostname: m[2] };
}
// ---------------------------------------------------------------------------
// Cell renderers
// ---------------------------------------------------------------------------
function RuntimeNameCell({ runtime }: { runtime: AgentRuntime }) {
const { base: baseName, hostname } = splitRuntimeName(runtime.name);
const { base: baseName } = splitRuntimeName(runtime.name);
return (
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
@@ -238,18 +225,6 @@ function RuntimeNameCell({ runtime }: { runtime: AgentRuntime }) {
<span className="block min-w-0 shrink truncate text-sm font-medium">
{baseName}
</span>
{hostname && (
<Tooltip>
<TooltipTrigger
render={
<span className="block min-w-0 flex-1 basis-0 truncate text-xs text-muted-foreground/70">
({hostname})
</span>
}
/>
<TooltipContent>{hostname}</TooltipContent>
</Tooltip>
)}
<VisibilityBadge runtime={runtime} />
</div>
</div>

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import type { AgentRuntime } from "@multica/core/types";
import {
buildRuntimeMachines,
filterRuntimeMachines,
runtimeMachineCounts,
splitRuntimeName,
} from "./runtime-machines";
const NOW = new Date("2026-05-17T12:00:00Z").getTime();
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
return {
id: "runtime-1",
workspace_id: "ws-1",
daemon_id: "daemon-1",
name: "Claude (dev-machine.local)",
runtime_mode: "local",
provider: "claude",
launch_header: "",
status: "online",
device_info: "dev-machine.local · claude 1.0.0",
metadata: { cli_version: "0.3.0" },
owner_id: "user-1",
visibility: "private",
timezone: "UTC",
last_seen_at: new Date(NOW - 10_000).toISOString(),
created_at: "2026-05-17T11:00:00Z",
updated_at: "2026-05-17T11:00:00Z",
...overrides,
};
}
describe("runtime machine grouping", () => {
it("groups multiple provider runtimes by daemon id", () => {
const machines = buildRuntimeMachines(
[
makeRuntime({ id: "rt-claude", provider: "claude", name: "Claude (dev.local)" }),
makeRuntime({ id: "rt-codex", provider: "codex", name: "Codex (dev.local)" }),
],
{ now: NOW, localDaemonId: "daemon-1" },
);
expect(machines).toHaveLength(1);
expect(machines[0]).toMatchObject({
id: "local:daemon-1",
title: "dev.local",
section: "local",
isCurrent: true,
onlineCount: 2,
issueCount: 0,
providerNames: ["claude", "codex"],
});
});
it("counts machines with any offline runtime as issues", () => {
const machines = buildRuntimeMachines(
[
makeRuntime({ id: "rt-online", provider: "claude" }),
makeRuntime({
id: "rt-offline",
provider: "codex",
status: "offline",
last_seen_at: new Date(NOW - 10 * 60_000).toISOString(),
}),
],
{ now: NOW },
);
expect(runtimeMachineCounts(machines)).toEqual({
all: 1,
online: 1,
issues: 1,
});
expect(filterRuntimeMachines(machines, "", "issues")).toHaveLength(1);
});
it("keeps cloud runtimes as cloud workers when they have no daemon", () => {
const machines = buildRuntimeMachines(
[
makeRuntime({
id: "cloud-1",
daemon_id: null,
runtime_mode: "cloud",
provider: "codex",
name: "Codex cloud",
device_info: "",
}),
],
{ now: NOW },
);
expect(machines[0]).toMatchObject({
id: "cloud:runtime:cloud-1",
title: "Codex cloud",
subtitle: "Cloud worker",
section: "cloud",
});
});
});
describe("splitRuntimeName", () => {
it("separates daemon host suffix from provider name", () => {
expect(splitRuntimeName("Claude (build-server-01)")).toEqual({
base: "Claude",
hostname: "build-server-01",
});
});
it("falls back to the full name when no host suffix exists", () => {
expect(splitRuntimeName("Codex cloud")).toEqual({
base: "Codex cloud",
hostname: null,
});
});
});

View File

@@ -0,0 +1,315 @@
import { deriveRuntimeHealth, type RuntimeHealth } from "@multica/core/runtimes";
import type { AgentRuntime } from "@multica/core/types";
import { formatDeviceInfo } from "../utils";
export type RuntimeMachineSection = "local" | "remote" | "cloud";
export type RuntimeMachineFilter = "all" | "online" | "issues";
export interface RuntimeWorkloadSummary {
runningCount: number;
queuedCount: number;
}
export interface RuntimeMachine {
id: string;
daemonId: string | null;
title: string;
subtitle: string | null;
deviceInfo: string | null;
cliVersion: string | null;
mode: AgentRuntime["runtime_mode"];
section: RuntimeMachineSection;
isCurrent: boolean;
health: RuntimeHealth;
runtimes: AgentRuntime[];
onlineCount: number;
issueCount: number;
runningCount: number;
queuedCount: number;
providerNames: string[];
lastSeenAt: string | null;
}
interface RuntimeMachineOptions {
now: number;
localDaemonId?: string | null;
localMachineName?: string | null;
workloadByRuntimeId?: Map<string, RuntimeWorkloadSummary>;
}
interface RuntimeMachineDraft {
id: string;
daemonId: string | null;
mode: AgentRuntime["runtime_mode"];
runtimes: AgentRuntime[];
}
const HEALTH_SEVERITY: Record<RuntimeHealth, number> = {
online: 0,
recently_lost: 1,
offline: 2,
about_to_gc: 3,
};
export function splitRuntimeName(name: string): {
base: string;
hostname: string | null;
} {
const m = name.match(/^(.+?)\s+\(([^)]+)\)$/);
if (!m || !m[1] || !m[2]) return { base: name, hostname: null };
return { base: m[1], hostname: m[2] };
}
export function buildRuntimeMachines(
runtimes: AgentRuntime[],
options: RuntimeMachineOptions,
): RuntimeMachine[] {
const drafts = new Map<string, RuntimeMachineDraft>();
for (const runtime of runtimes) {
const id = runtimeMachineId(runtime);
const draft =
drafts.get(id) ??
({
id,
daemonId: runtime.daemon_id,
mode: runtime.runtime_mode,
runtimes: [],
} satisfies RuntimeMachineDraft);
draft.runtimes.push(runtime);
drafts.set(id, draft);
}
return Array.from(drafts.values())
.map((draft) => finalizeRuntimeMachine(draft, options))
.sort(compareRuntimeMachines);
}
export function filterRuntimeMachines(
machines: RuntimeMachine[],
query: string,
filter: RuntimeMachineFilter,
): RuntimeMachine[] {
const q = query.trim().toLowerCase();
return machines.filter((machine) => {
if (filter === "online" && machine.onlineCount === 0) return false;
if (filter === "issues" && machine.issueCount === 0) return false;
if (!q) return true;
const haystack = [
machine.title,
machine.subtitle,
machine.deviceInfo,
machine.daemonId,
machine.providerNames.join(" "),
machine.runtimes.map((runtime) => runtime.name).join(" "),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(q);
});
}
export function runtimeMachineCounts(machines: RuntimeMachine[]): {
all: number;
online: number;
issues: number;
} {
return {
all: machines.length,
online: machines.filter((machine) => machine.onlineCount > 0).length,
issues: machines.filter((machine) => machine.issueCount > 0).length,
};
}
function finalizeRuntimeMachine(
draft: RuntimeMachineDraft,
options: RuntimeMachineOptions,
): RuntimeMachine {
const runtimes = [...draft.runtimes].sort((a, b) =>
a.provider.localeCompare(b.provider),
);
const first = runtimes[0];
const providerNames = Array.from(new Set(runtimes.map((r) => r.provider))).sort();
const isCurrent =
!!options.localDaemonId && draft.daemonId === options.localDaemonId;
const title = machineTitle(runtimes, {
isCurrent,
localMachineName: options.localMachineName,
});
const deviceInfo = first ? formatDeviceInfo(first.device_info ?? null) : null;
const subtitle = machineSubtitle({
title,
deviceInfo,
daemonId: draft.daemonId,
mode: draft.mode,
});
const healthByRuntime = runtimes.map((runtime) =>
deriveRuntimeHealth(runtime, options.now),
);
const onlineCount = healthByRuntime.filter((h) => h === "online").length;
const issueCount = runtimes.length - onlineCount;
const health =
onlineCount > 0
? "online"
: healthByRuntime.reduce<RuntimeHealth>(
(worst, current) =>
HEALTH_SEVERITY[current] > HEALTH_SEVERITY[worst] ? current : worst,
"recently_lost",
);
const workload = runtimes.reduce(
(sum, runtime) => {
const entry = options.workloadByRuntimeId?.get(runtime.id);
return {
runningCount: sum.runningCount + (entry?.runningCount ?? 0),
queuedCount: sum.queuedCount + (entry?.queuedCount ?? 0),
};
},
{ runningCount: 0, queuedCount: 0 },
);
return {
id: draft.id,
daemonId: draft.daemonId,
title,
subtitle,
deviceInfo,
cliVersion: commonCliVersion(runtimes),
mode: draft.mode,
section: isCurrent ? "local" : draft.mode === "cloud" ? "cloud" : "remote",
isCurrent,
health,
runtimes,
onlineCount,
issueCount,
runningCount: workload.runningCount,
queuedCount: workload.queuedCount,
providerNames,
lastSeenAt: latestLastSeenAt(runtimes),
};
}
function runtimeMachineId(runtime: AgentRuntime): string {
if (runtime.daemon_id) return `${runtime.runtime_mode}:${runtime.daemon_id}`;
const deviceName = runtimeDeviceName(runtime);
if (deviceName) return `${runtime.runtime_mode}:device:${deviceName}`;
return `${runtime.runtime_mode}:runtime:${runtime.id}`;
}
function runtimeDeviceName(runtime: AgentRuntime): string | null {
const host = splitRuntimeName(runtime.name).hostname;
if (host) return host;
const raw = runtime.device_info?.trim();
if (!raw) return null;
return raw.split(" · ")[0]?.trim() || null;
}
function machineTitle(
runtimes: AgentRuntime[],
options: { isCurrent: boolean; localMachineName?: string | null },
): string {
if (options.isCurrent && options.localMachineName) {
return options.localMachineName;
}
const first = runtimes[0];
if (!first) return "Unknown machine";
const deviceName = runtimeDeviceName(first);
if (deviceName) return deviceName;
if (first.runtime_mode === "cloud") {
return `${capitalize(first.provider)} cloud`;
}
return first.daemon_id ? shortDaemonId(first.daemon_id) : "Unknown machine";
}
function machineSubtitle({
title,
deviceInfo,
daemonId,
mode,
}: {
title: string;
deviceInfo: string | null;
daemonId: string | null;
mode: AgentRuntime["runtime_mode"];
}): string | null {
const compact = compactDeviceInfo(deviceInfo, title);
if (compact) return compact;
if (daemonId) return `daemon ${shortDaemonId(daemonId)}`;
return mode === "cloud" ? "Cloud worker" : null;
}
function compactDeviceInfo(
deviceInfo: string | null,
title: string,
): string | null {
if (!deviceInfo) return null;
const parts = deviceInfo
.split(" · ")
.map((part) => part.trim())
.filter(Boolean)
.filter((part) => part !== title);
const primary = parts[0];
if (!primary) return null;
const runtimeVersion = primary.match(/^(.+?)\s+\(([^)]+)\)$/);
if (runtimeVersion?.[1] && runtimeVersion[2]) {
return `${runtimeVersion[2]} ${runtimeVersion[1]}`;
}
return primary;
}
function latestLastSeenAt(runtimes: AgentRuntime[]): string | null {
let latest: string | null = null;
for (const runtime of runtimes) {
if (!runtime.last_seen_at) continue;
if (!latest || new Date(runtime.last_seen_at) > new Date(latest)) {
latest = runtime.last_seen_at;
}
}
return latest;
}
function commonCliVersion(runtimes: AgentRuntime[]): string | null {
const versions = new Set<string>();
for (const runtime of runtimes) {
const version = runtime.metadata?.cli_version;
if (typeof version === "string" && version.trim()) {
versions.add(version.trim());
}
}
return versions.size === 1 ? Array.from(versions)[0] ?? null : null;
}
function shortDaemonId(daemonId: string): string {
return daemonId.length > 12 ? `${daemonId.slice(0, 8)}...` : daemonId;
}
function capitalize(value: string): string {
if (!value) return "Runtime";
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
}
function compareRuntimeMachines(a: RuntimeMachine, b: RuntimeMachine): number {
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
const sectionDelta = sectionRank(a.section) - sectionRank(b.section);
if (sectionDelta !== 0) return sectionDelta;
if (a.onlineCount !== b.onlineCount) return b.onlineCount - a.onlineCount;
return a.title.localeCompare(b.title);
}
function sectionRank(section: RuntimeMachineSection): number {
switch (section) {
case "local":
return 0;
case "remote":
return 1;
case "cloud":
return 2;
}
}

View File

@@ -1,49 +1,55 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Search, Server } from "lucide-react";
import { useDefaultLayout } from "react-resizable-panels";
import {
Cloud,
Monitor,
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";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { runtimeListOptions, runtimeKeys } from "@multica/core/runtimes/queries";
import { useUpdatableRuntimeIds } from "@multica/core/runtimes/hooks";
import { deriveRuntimeHealth } from "@multica/core/runtimes";
import { useWSEvent } from "@multica/core/realtime";
import { agentListOptions } from "@multica/core/workspace/queries";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@multica/ui/components/ui/resizable";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { cn } from "@multica/ui/lib/utils";
import { PageHeader } from "../../layout/page-header";
import { ConnectRemoteDialog } from "./connect-remote-dialog";
import { RuntimeList } from "./runtime-list";
import { ProviderLogo } from "./provider-logo";
import { RuntimeList, buildWorkloadIndex } from "./runtime-list";
import {
buildRuntimeMachines,
filterRuntimeMachines,
runtimeMachineCounts,
type RuntimeMachine,
type RuntimeMachineFilter,
} from "./runtime-machines";
import { HealthDot, HealthIcon, useHealthLabel } from "./shared";
import { useT } from "../../i18n";
type RuntimeFilter = "mine" | "all";
type HealthFilter = "all" | "online" | "recently_lost" | "offline" | "about_to_gc";
const HEALTH_ORDER: HealthFilter[] = [
"all",
"online",
"recently_lost",
"offline",
"about_to_gc",
];
// Dot tokens stay in code — labels/descriptions flow through useT.
const HEALTH_DOT: Record<Exclude<HealthFilter, "all">, string> = {
online: "bg-success",
recently_lost: "bg-warning",
offline: "bg-muted-foreground/40",
about_to_gc: "bg-destructive",
};
const MACHINE_FILTERS: RuntimeMachineFilter[] = ["all", "online", "issues"];
interface RuntimesPageProps {
/** Desktop-only slot rendered above the runtimes table (e.g. local daemon card) */
topSlot?: React.ReactNode;
/** Desktop-only daemon id used to mark the row for this Mac. */
localDaemonId?: string | null;
/** Desktop-only friendly device name for the local daemon. */
localMachineName?: string | null;
/** Desktop-only controls shown when the local machine is selected. */
localMachineActions?: React.ReactNode;
/**
* Desktop-only signal: the bundled daemon is still booting / hasn't
* registered with the server yet. Forwarded so the empty state can show
@@ -64,23 +70,32 @@ function useNowTick(intervalMs = 30_000): number {
return now;
}
export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {}) {
export function RuntimesPage({
localDaemonId,
localMachineName,
localMachineActions,
bootstrapping,
}: RuntimesPageProps = {}) {
const isLoading = useAuthStore((s) => s.isLoading);
const wsId = useWorkspaceId();
const qc = useQueryClient();
const [scope, setScope] = useState<RuntimeFilter>("mine");
const [healthFilter, setHealthFilter] = useState<HealthFilter>("all");
const [search, setSearch] = useState("");
const [machineFilter, setMachineFilter] =
useState<RuntimeMachineFilter>("all");
const [machineSearch, setMachineSearch] = useState("");
const [selectedMachineId, setSelectedMachineId] = useState<string | null>(
null,
);
const [showConnectDialog, setShowConnectDialog] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_runtimes_layout",
});
const isMobile = useIsMobile();
// 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
// slots holding independent snapshots of the same runtime — switching scope
// surfaced stale `last_seen_at` from whichever slot was older.
const { data: runtimes = [], isLoading: fetching } = useQuery(
runtimeListOptions(wsId),
);
const currentUserId = useAuthStore((s) => s.user?.id);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const handleDaemonEvent = useCallback(() => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
@@ -90,46 +105,47 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
const updatableIds = useUpdatableRuntimeIds(wsId);
const now = useNowTick();
// Apply scope first, then everything downstream (health counts, list filter)
// operates on the post-scope set — so chip counts and filter results stay
// consistent with what the user sees.
const scopedRuntimes = useMemo(() => {
if (scope !== "mine") return runtimes;
if (!currentUserId) return [];
return runtimes.filter((r) => r.owner_id === currentUserId);
}, [runtimes, scope, currentUserId]);
const workloadIndex = useMemo(
() => buildWorkloadIndex(agents, snapshot),
[agents, snapshot],
);
const healthCounts = useMemo(() => {
const counts: Record<Exclude<HealthFilter, "all">, number> = {
online: 0,
recently_lost: 0,
offline: 0,
about_to_gc: 0,
};
for (const r of scopedRuntimes) {
counts[deriveRuntimeHealth(r, now)] += 1;
const machines = useMemo(
() =>
buildRuntimeMachines(runtimes, {
now,
localDaemonId,
localMachineName,
workloadByRuntimeId: workloadIndex,
}),
[runtimes, now, localDaemonId, localMachineName, workloadIndex],
);
const machineCounts = useMemo(() => runtimeMachineCounts(machines), [machines]);
const filteredMachines = useMemo(
() => filterRuntimeMachines(machines, machineSearch, machineFilter),
[machines, machineSearch, machineFilter],
);
useEffect(() => {
if (filteredMachines.length === 0) {
if (selectedMachineId !== null) setSelectedMachineId(null);
return;
}
return counts;
}, [scopedRuntimes, now]);
if (!selectedMachineId || !filteredMachines.some((m) => m.id === selectedMachineId)) {
setSelectedMachineId(filteredMachines[0]?.id ?? null);
}
}, [filteredMachines, selectedMachineId]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return scopedRuntimes.filter((r) => {
if (healthFilter !== "all") {
if (deriveRuntimeHealth(r, now) !== healthFilter) return false;
}
if (q) {
const haystack = `${r.name} ${r.provider} ${r.device_info ?? ""}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}, [scopedRuntimes, healthFilter, search, now]);
const selectedMachine =
machines.find((machine) => machine.id === selectedMachineId) ??
filteredMachines[0] ??
null;
if (isLoading || fetching) return <RuntimesPageSkeleton />;
const totalCount = runtimes.length;
const scopedTotal = scopedRuntimes.length;
const showEmpty = totalCount === 0 && !bootstrapping;
return (
@@ -139,39 +155,76 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
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 onConnectRemote={() => setShowConnectDialog(true)} />
</div>
) : (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
<CardToolbar
search={search}
setSearch={setSearch}
scope={scope}
setScope={setScope}
/>
<FilterChipsRow
healthFilter={healthFilter}
setHealthFilter={setHealthFilter}
healthCounts={healthCounts}
total={scopedTotal}
/>
{filtered.length === 0 ? (
<NoMatchesState search={search} healthFilter={healthFilter} scope={scope} bootstrapping={bootstrapping} />
) : (
<RuntimeList
runtimes={filtered}
{showEmpty ? (
<div className="flex flex-1 items-center justify-center p-6">
<EmptyState onConnectRemote={() => setShowConnectDialog(true)} />
</div>
) : isMobile ? (
<div className="flex min-h-0 flex-1 flex-col border-t bg-background">
<MachineSidebar
machines={filteredMachines}
totalMachines={machines.length}
counts={machineCounts}
selectedMachineId={selectedMachine?.id ?? null}
search={machineSearch}
setSearch={setMachineSearch}
filter={machineFilter}
setFilter={setMachineFilter}
onSelect={setSelectedMachineId}
/>
<MachineDetail
machine={selectedMachine}
updatableIds={updatableIds}
now={now}
bootstrapping={bootstrapping}
actions={
selectedMachine?.isCurrent ? localMachineActions : undefined
}
/>
</div>
) : (
<div className="min-h-0 flex-1 border-t bg-background">
<ResizablePanelGroup
orientation="horizontal"
className="min-h-0 flex-1"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel
id="machines"
defaultSize={300}
minSize={240}
maxSize={420}
groupResizeBehavior="preserve-pixel-size"
>
<MachineSidebar
machines={filteredMachines}
totalMachines={machines.length}
counts={machineCounts}
selectedMachineId={selectedMachine?.id ?? null}
search={machineSearch}
setSearch={setMachineSearch}
filter={machineFilter}
setFilter={setMachineFilter}
onSelect={setSelectedMachineId}
className="h-full border-b-0 border-r"
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="45%">
<MachineDetail
machine={selectedMachine}
updatableIds={updatableIds}
now={now}
bootstrapping={bootstrapping}
actions={
selectedMachine?.isCurrent ? localMachineActions : undefined
}
/>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)}
{showConnectDialog && (
<ConnectRemoteDialog onClose={() => setShowConnectDialog(false)} />
@@ -223,194 +276,409 @@ function PageHeaderBar({
);
}
// ---------------------------------------------------------------------------
// Intro block — sits between the page header and the table card. Mirrors
// Skills' two-paragraph pattern: a one-liner plus a brand-accented callout
// pinning down a single non-obvious fact about the surface.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Card toolbar — search + scope toggle + live indicator. Skills puts its
// search and filter buttons here; we follow the same convention so the card
// owns its own interactions.
// ---------------------------------------------------------------------------
function CardToolbar({
function MachineSidebar({
machines,
totalMachines,
counts,
selectedMachineId,
search,
setSearch,
scope,
setScope,
filter,
setFilter,
onSelect,
className,
}: {
machines: RuntimeMachine[];
totalMachines: number;
counts: { all: number; online: number; issues: number };
selectedMachineId: string | null;
search: string;
setSearch: (v: string) => void;
scope: RuntimeFilter;
setScope: (v: RuntimeFilter) => void;
setSearch: (value: string) => void;
filter: RuntimeMachineFilter;
setFilter: (value: RuntimeMachineFilter) => void;
onSelect: (id: string) => void;
className?: string;
}) {
const { t } = useT("runtimes");
return (
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t(($) => $.page.search_placeholder)}
className="h-8 w-64 pl-8 text-sm"
/>
</div>
<ScopeSegment value={scope} onChange={setScope} />
<Tooltip>
<TooltipTrigger
render={
<div className="ml-auto inline-flex cursor-default select-none items-center gap-1.5 text-xs text-muted-foreground">
<span className="relative inline-flex h-2 w-2 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success/60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-success" />
</span>
{t(($) => $.page.live)}
</div>
}
/>
<TooltipContent side="top">
{t(($) => $.page.live_tooltip)}
</TooltipContent>
</Tooltip>
</div>
);
}
const sections = [
{
key: "local" as const,
label: t(($) => $.machine.section_local),
machines: machines.filter((machine) => machine.section === "local"),
},
{
key: "remote" as const,
label: t(($) => $.machine.section_remote),
machines: machines.filter((machine) => machine.section === "remote"),
},
{
key: "cloud" as const,
label: t(($) => $.machine.section_cloud),
machines: machines.filter((machine) => machine.section === "cloud"),
},
].filter((section) => section.machines.length > 0);
function ScopeSegment({
value,
onChange,
}: {
value: RuntimeFilter;
onChange: (v: RuntimeFilter) => void;
}) {
const { t } = useT("runtimes");
return (
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
onClick={() => onChange("mine")}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
value === "mine"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(($) => $.page.scope_mine)}
</button>
<button
onClick={() => onChange("all")}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
value === "all"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(($) => $.page.scope_all)}
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Filter chips — 4 health states + "All", each with a tooltip explaining
// what the state actually means in operational terms. Counts come from the
// pre-filter set so users can see "what would happen" before clicking.
// ---------------------------------------------------------------------------
function FilterChipsRow({
healthFilter,
setHealthFilter,
healthCounts,
total,
}: {
healthFilter: HealthFilter;
setHealthFilter: (v: HealthFilter) => void;
healthCounts: Record<Exclude<HealthFilter, "all">, number>;
total: number;
}) {
const { t } = useT("runtimes");
return (
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
{HEALTH_ORDER.map((key) => {
const count = key === "all" ? total : healthCounts[key];
const label =
key === "all"
? t(($) => $.page.filter_all)
: t(($) => $.health[key].label);
const description =
key === "all"
? t(($) => $.page.filter_all_description)
: t(($) => $.health[key].description);
return (
<HealthChip
key={key}
active={healthFilter === key}
onClick={() => setHealthFilter(key)}
label={label}
count={count}
dotClass={key === "all" ? undefined : HEALTH_DOT[key]}
description={description}
<aside
className={cn(
"flex min-h-0 shrink-0 flex-col border-b bg-muted/20",
className,
)}
>
<div className="shrink-0 border-b bg-background p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t(($) => $.machine.search_placeholder)}
className="h-9 pl-8 text-sm"
/>
);
})}
</div>
</div>
<div className="mt-2 flex items-center gap-1.5 overflow-x-auto">
{MACHINE_FILTERS.map((key) => (
<MachineFilterChip
key={key}
active={filter === key}
onClick={() => setFilter(key)}
label={t(($) => $.machine.filters[key])}
count={counts[key]}
tone={key}
/>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto py-2">
{sections.length > 0 ? (
sections.map((section) => (
<div key={section.key} className="mb-3 last:mb-0">
<div className="mb-1 flex items-center gap-2 px-4 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{section.label}</span>
<span className="h-px flex-1 bg-border" />
</div>
<div>
{section.machines.map((machine) => (
<MachineRow
key={machine.id}
machine={machine}
active={machine.id === selectedMachineId}
onClick={() => onSelect(machine.id)}
/>
))}
</div>
</div>
))
) : (
<div className="flex h-full flex-col items-center justify-center px-6 text-center">
<Search className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm font-medium">
{t(($) => $.machine.no_matches_title)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{totalMachines > 0
? t(($) => $.machine.no_matches_hint)
: t(($) => $.page.bootstrapping.hint)}
</p>
</div>
)}
</div>
</aside>
);
}
// Mirrors Agents' `PresenceChip` — same `Button outline + size sm` shell so
// any future polish to the chip token cascades to both surfaces. The active
// state uses `bg-accent text-accent-foreground hover:bg-accent/80`, matching
// Skills' filter chip selection.
function HealthChip({
function MachineFilterChip({
active,
onClick,
label,
count,
dotClass,
description,
tone,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
dotClass?: string;
description: string;
tone: RuntimeMachineFilter;
}) {
const dotClass =
tone === "online"
? "bg-success"
: tone === "issues"
? "bg-warning"
: "bg-muted-foreground/40";
return (
<Button
variant="outline"
size="sm"
onClick={onClick}
className={cn(
"h-7 gap-1.5 px-2 text-xs",
active
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "bg-background text-muted-foreground",
)}
>
{tone !== "all" && <span className={cn("h-1.5 w-1.5 rounded-full", dotClass)} />}
<span>{label}</span>
<span className="font-mono tabular-nums text-muted-foreground/70">
{count}
</span>
</Button>
);
}
function MachineRow({
machine,
active,
onClick,
}: {
machine: RuntimeMachine;
active: boolean;
onClick: () => void;
}) {
const { t } = useT("runtimes");
const Icon = machine.section === "cloud" ? Cloud : Monitor;
const busyCount = machine.runningCount + machine.queuedCount;
const runtimeCount = t(($) => $.machine.runtime_count, {
count: machine.runtimes.length,
});
return (
<button
type="button"
onClick={onClick}
className={cn(
"group flex w-full min-w-0 items-start gap-3 px-4 py-2.5 text-left transition-colors",
active ? "bg-accent" : "hover:bg-accent/50",
)}
>
<span className="relative mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-background">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<HealthDot
health={machine.health}
className="absolute -bottom-0.5 -right-0.5 ring-2 ring-background"
/>
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium">{machine.title}</span>
{machine.isCurrent && (
<span className="shrink-0 rounded bg-foreground px-1.5 py-0.5 text-[10px] font-medium text-background">
{t(($) => $.machine.this_machine)}
</span>
)}
</span>
{machine.subtitle && (
<span className="mt-0.5 block truncate font-mono text-xs text-muted-foreground">
{machine.subtitle}
</span>
)}
<span className="mt-2 flex min-w-0 items-center gap-1.5">
<ProviderIconStack providers={machine.providerNames} />
{busyCount > 0 ? (
<span className="ml-auto shrink-0 text-xs font-medium text-primary">
{t(($) => $.machine.busy_count, { count: busyCount })}
</span>
) : (
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{runtimeCount}
</span>
)}
</span>
</span>
</button>
);
}
function ProviderIconStack({ providers }: { providers: string[] }) {
const visible = providers.slice(0, 4);
const extra = providers.length - visible.length;
return (
<span className="flex min-w-0 items-center -space-x-1">
{visible.map((provider) => (
<span
key={provider}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-background ring-1 ring-border"
>
<ProviderLogo provider={provider} className="h-3.5 w-3.5" />
</span>
))}
{extra > 0 && (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground ring-1 ring-border">
+{extra}
</span>
)}
</span>
);
}
function MachineDetail({
machine,
updatableIds,
now,
bootstrapping,
actions,
}: {
machine: RuntimeMachine | null;
updatableIds: Set<string>;
now: number;
bootstrapping?: boolean;
actions?: React.ReactNode;
}) {
const { t } = useT("runtimes");
const healthLabel = useHealthLabel();
if (!machine) {
return (
<main className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
{bootstrapping ? (
<>
<Server className="h-8 w-8 animate-pulse text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">
{t(($) => $.page.bootstrapping.title)}
</p>
<p className="mt-1 max-w-xs text-xs text-muted-foreground/70">
{t(($) => $.page.bootstrapping.hint)}
</p>
</>
) : (
<>
<Monitor className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">
{t(($) => $.machine.select_machine)}
</p>
</>
)}
</main>
);
}
const onlineRuntimeCount = machine.onlineCount;
const issueCount = machine.issueCount;
const workloadLabel =
machine.runningCount > 0 || machine.queuedCount > 0
? t(($) => $.machine.metrics.workload_hint, {
running: machine.runningCount,
queued: machine.queuedCount,
})
: t(($) => $.machine.metrics.workload_idle);
const runtimeTotal = machine.runtimes.length;
const metaItems = [machine.subtitle].filter(Boolean);
return (
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="shrink-0 border-b bg-background px-5 py-5">
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="truncate text-xl font-semibold tracking-tight">
{machine.title}
</h2>
<span className="inline-flex items-center gap-1 rounded-md border bg-background px-2 py-1 text-xs text-muted-foreground">
<HealthIcon health={machine.health} />
{healthLabel(machine.health)}
</span>
{machine.isCurrent && (
<span className="rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background">
{t(($) => $.machine.local_badge)}
</span>
)}
<span className="rounded-md border bg-muted px-2 py-1 text-xs text-muted-foreground">
{machine.section === "cloud"
? t(($) => $.machine.section_cloud)
: machine.section === "local"
? t(($) => $.machine.section_local)
: t(($) => $.machine.section_remote)}
</span>
</div>
{metaItems.length > 0 && (
<p className="mt-2 max-w-4xl truncate text-xs text-muted-foreground">
{metaItems.join(" · ")}
</p>
)}
</div>
{actions && <div className="shrink-0">{actions}</div>}
</div>
<div className="mt-5 grid overflow-hidden rounded-lg border bg-muted/20 sm:grid-cols-2 lg:grid-cols-4">
<MachineMetric
label={t(($) => $.machine.metrics.runtimes)}
value={String(runtimeTotal)}
hint={t(($) => $.machine.metrics.runtimes_hint, {
count: onlineRuntimeCount,
})}
/>
<MachineMetric
label={t(($) => $.machine.metrics.health)}
value={healthLabel(machine.health)}
hint={
issueCount > 0
? t(($) => $.machine.metrics.health_issues, { count: issueCount })
: t(($) => $.machine.metrics.health_clear)
}
/>
<MachineMetric
label={t(($) => $.machine.metrics.workload)}
value={
machine.runningCount > 0 || machine.queuedCount > 0
? String(machine.runningCount + machine.queuedCount)
: t(($) => $.machine.metrics.workload_value_idle)
}
hint={workloadLabel}
/>
<MachineMetric
label={t(($) => $.machine.metrics.cli)}
value={machine.cliVersion ?? "—"}
hint={
machine.mode === "cloud"
? t(($) => $.machine.metrics.cloud_worker)
: t(($) => $.machine.metrics.local_daemon)
}
mono={!!machine.cliVersion}
/>
</div>
</div>
<RuntimeList
runtimes={machine.runtimes}
updatableIds={updatableIds}
now={now}
/>
</main>
);
}
function MachineMetric({
label,
value,
hint,
mono,
}: {
label: string;
value: string;
hint: string;
mono?: boolean;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
onClick={onClick}
className={
active
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground"
}
>
{dotClass && (
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
)}
<span>{label}</span>
<span className="font-mono tabular-nums text-muted-foreground/70">
{count}
</span>
</Button>
}
/>
<TooltipContent side="top">{description}</TooltipContent>
</Tooltip>
<div className="min-w-0 border-b px-4 py-3 last:border-b-0 sm:odd:border-r lg:border-b-0 lg:border-r lg:last:border-r-0">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div
className={cn(
"mt-1 truncate text-base font-semibold tabular-nums",
mono && "font-mono text-sm",
)}
>
{value}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{hint}</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state — shown when zero runtimes have ever registered in this
// workspace. Different from "filter matches nothing" (NoMatchesState).
// workspace.
// ---------------------------------------------------------------------------
function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
@@ -438,57 +706,8 @@ function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
}
// ---------------------------------------------------------------------------
// No matches state — runtimes exist but the current filter combination
// hides all of them. Keeps the user oriented by reflecting *which* filters
// are in play.
// ---------------------------------------------------------------------------
function NoMatchesState({
search,
healthFilter,
scope,
bootstrapping,
}: {
search: string;
healthFilter: HealthFilter;
scope: RuntimeFilter;
bootstrapping?: boolean;
}) {
const { t } = useT("runtimes");
if (bootstrapping) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-16 text-center">
<Server className="h-8 w-8 animate-pulse text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">{t(($) => $.page.bootstrapping.title)}</p>
<p className="max-w-xs text-xs text-muted-foreground/70">
{t(($) => $.page.bootstrapping.hint)}
</p>
</div>
);
}
const hasSearch = search.length > 0;
const hasHealthFilter = healthFilter !== "all";
const hasScope = scope === "mine";
const filterSuffix = hasHealthFilter || hasScope ? t(($) => $.page.no_matches.with_query_filter_suffix) : "";
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-16 text-center text-muted-foreground">
<Search className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm">{t(($) => $.page.no_matches.title)}</p>
<p className="max-w-xs text-xs">
{hasSearch
? t(($) => $.page.no_matches.with_query, { query: search, filterSuffix })
: t(($) => $.page.no_matches.no_query)}
{t(($) => $.page.no_matches.try_widening)}
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Loading skeleton — laid out the same as the real page (header + intro
// + card) so the layout doesn't jump on first paint.
// Loading skeleton — laid out like the split runtime page so the layout
// does not jump on first paint.
// ---------------------------------------------------------------------------
function RuntimesPageSkeleton() {
@@ -497,18 +716,35 @@ function RuntimesPageSkeleton() {
<PageHeader className="justify-between px-5">
<Skeleton className="h-4 w-24" />
</PageHeader>
<div className="flex flex-1 min-h-0 flex-col gap-4 p-6">
<div className="space-y-3 pl-4">
<Skeleton className="h-5 w-full max-w-2xl rounded-md" />
<Skeleton className="h-14 w-full max-w-3xl rounded-md" />
</div>
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-lg border">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-8 w-64 rounded-md" />
<div className="flex min-h-0 flex-1 border-t">
<div className="hidden w-[300px] shrink-0 border-r p-3 md:block">
<Skeleton className="h-9 w-full rounded-md" />
<div className="mt-3 flex gap-2">
<Skeleton className="h-7 w-16 rounded-md" />
<Skeleton className="h-7 w-20 rounded-md" />
<Skeleton className="h-7 w-20 rounded-md" />
</div>
<div className="mt-5 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-lg" />
))}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="border-b p-5">
<Skeleton className="h-6 w-64 rounded-md" />
<Skeleton className="mt-3 h-4 w-full max-w-2xl rounded-md" />
<div className="mt-5 grid gap-px overflow-hidden rounded-lg border sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-none" />
))}
</div>
</div>
<div className="h-12 border-b px-4 py-2">
<Skeleton className="h-8 w-40 rounded-full" />
</div>
<div className="space-y-2 p-4">
{Array.from({ length: 4 }).map((_, i) => (
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>