mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
* refactor(ui): converge ActorAvatar size to semantic tiers (MUL-4277) Replace the free-form numeric `size` on ActorAvatar with a constrained `AvatarSize` union (xs/sm/md/lg/xl/2xl) so avatar dimensions are chosen by role instead of ad-hoc pixels. This eliminates the magic-number drift where the same role rendered at different sizes across pages. - Add `@multica/ui/lib/avatar-size` (AvatarSize union + AVATAR_SIZE_PX map + default tier). - Base `ActorAvatar` (packages/ui) and business `ActorAvatar`/`AgentStatusDot` (packages/views) now take `AvatarSize`; internal font/icon math and the presence-dot threshold read px from the map. - Migrate all web/desktop call sites (packages/ui + packages/views) from numeric sizes to tiers using the role table (12,14->xs 16,18,20->sm 22,24,28->md 30,32,34->lg 40,44->xl 56,64->2xl). - Token-ise the derived consumers `AgentAvatarStack` and `IssueAgentActivityIndicator` (px looked up internally for overlap/+N math). Out of scope (per plan): ui/avatar.tsx primitive, account-tab/AvatarPicker, mobile, and component-name disambiguation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(ui): unify all avatars and the upload cropper to round (MUL-4277, MUL-4184) main's avatar-shape decision rendered non-human actors (agent, squad, system) and the workspace logo as rounded squares, and the upload cropper mirrored that with a square crop window. Per the updated decision (avatars_and_cropper_round_required), every avatar and the crop UI are now circular; the square path is removed rather than left as dead config. - Base ActorAvatar: always rounded-full (drop the isHuman/rounded-md split). - avatar-crop-dialog: remove the AvatarCropShape/square path; crop window is always cropShape="round". - avatar-upload-control: drop VARIANT_SHAPE; the control is always round and no longer threads a shape to the dialog (variant still drives the fallback). - Strip rounded-md/rounded-none square overrides from agent/squad/member ActorAvatar call sites; round the read-only agent/squad static wrappers. - WorkspaceAvatar: round the org logo so it matches the (now round) workspace upload/crop and the shared avatar shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(ui): make round avatar shape a hard invariant (MUL-4277) Close the two remaining square squad-avatar paths flagged in review and prevent call sites from re-squaring the avatar: - base ActorAvatar: keep `rounded-full` as the last class in cn() so a call-site `className` can no longer override the circle. - SquadHeaderAvatar: drop `className="rounded"` (was overriding the base circle into a small rounded square). - SquadsPage no-avatar fallback: route through the shared ActorAvatarBase (`isSquad size="lg"`) instead of a hand-written rounded-md tile, so the fallback matches the image path — one shape source of truth. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(views): round the agent/squad avatar loading skeletons (MUL-4277) The avatar placeholder skeletons on the agent/squad list, detail, and profile-card loading states were still rounded squares (rounded-md/lg) from the pre-round era, so the avatar visibly popped from square to circle on load — inconsistent with the round avatars and with the member/inbox/issue skeletons that already use rounded-full. Round all five: agents-page, agent-detail-page, squads-page, squad-detail-page, squad-profile-card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
759 lines
25 KiB
TypeScript
759 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import {
|
||
AlertTriangle,
|
||
Globe,
|
||
Loader2,
|
||
MoreHorizontal,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import type {
|
||
Agent,
|
||
AgentRuntime,
|
||
AgentTask,
|
||
MemberWithUser,
|
||
RuntimeProfile,
|
||
} from "@multica/core/types";
|
||
import { useAuthStore } from "@multica/core/auth";
|
||
import { useWorkspaceId } from "@multica/core/hooks";
|
||
import {
|
||
agentListOptions,
|
||
memberListOptions,
|
||
} from "@multica/core/workspace/queries";
|
||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||
import {
|
||
deriveRuntimeHealth,
|
||
runtimeProfileListOptions,
|
||
runtimeUsageOptions,
|
||
} from "@multica/core/runtimes";
|
||
import { useWorkspacePaths } from "@multica/core/paths";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@multica/ui/components/ui/dropdown-menu";
|
||
import {
|
||
ListGrid,
|
||
ListGridCell,
|
||
ListGridHeader,
|
||
ListGridHeaderCell,
|
||
ListGridRow,
|
||
} from "@multica/ui/components/ui/list-grid";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipTrigger,
|
||
} from "@multica/ui/components/ui/tooltip";
|
||
import { useRowLink } from "../../navigation";
|
||
import { ActorAvatar } from "../../common/actor-avatar";
|
||
import { useViewingTimezone } from "../../common/use-viewing-timezone";
|
||
import { ProviderLogo } from "./provider-logo";
|
||
import { HealthIcon, useHealthLabel } from "./shared";
|
||
import { DeleteRuntimeDialog } from "./delete-runtime-dialog";
|
||
import { DeleteRuntimeProfileDialog } from "./delete-runtime-profile-dialog";
|
||
import {
|
||
computeCostInWindow,
|
||
formatLastSeen,
|
||
pctChange,
|
||
} from "../utils";
|
||
import { splitRuntimeName } from "./runtime-machines";
|
||
import {
|
||
customRuntimeRegistrationFailure,
|
||
isPendingCustomRuntime,
|
||
isPendingCustomRuntimeWarning,
|
||
pendingRuntimeCommandName,
|
||
} from "./pending-runtime";
|
||
import { useT } from "../../i18n";
|
||
|
||
// The machine detail's runtimes table on the shared ListGrid. Paradigm
|
||
// pieces are taken À LA CARTE here: subgrid template + var-width tracks +
|
||
// two-zone responsiveness (the detail pane gets squeezed by the machine
|
||
// list, so the container-driven core-set collapse matters more than on the
|
||
// full-width pages), but NO virtualization / sorting / filters / column
|
||
// toggles / batch selection — a machine hosts 1-5 runtimes, those would all
|
||
// be dead weight, and batch-deleting runtimes (a cascade-confirm heavy
|
||
// operation) is deliberately not offered.
|
||
const GRID_COLS =
|
||
"grid-cols-[0.75rem_minmax(120px,1fr)_var(--rtc-health)_var(--rtc-kebab)_0.75rem] " +
|
||
"@2xl:grid-cols-[0.75rem_minmax(140px,1fr)_var(--rtc-health)_var(--rtc-owner)_var(--rtc-agents)_var(--rtc-cost)_var(--rtc-cli)_var(--rtc-kebab)_0.75rem]";
|
||
|
||
const COLUMN_WIDTHS = {
|
||
// Health folds the workload in as a suffix ("Healthy · 2 running") —
|
||
// same merge as the agents list's status cell.
|
||
health: 176,
|
||
owner: 96,
|
||
agents: 92,
|
||
cost: 96,
|
||
cli: 112,
|
||
} as const;
|
||
|
||
// Fixed tracks (edges 12+12, name min 140) plus the 8 gap-x-3 gaps
|
||
// between the wide template's 9 tracks (zero-width tracks still carry
|
||
// gaps).
|
||
const FIXED_TRACKS_WIDTH = 164 + 8 * 12;
|
||
|
||
// The kebab track is conditional like the owner column: on a list where
|
||
// no row carries a delete-permission, EVERY row's only action is hidden,
|
||
// and an unconditionally reserved 28px action track would hang a
|
||
// permanent dead zone off the last column.
|
||
function columnTrackVars(
|
||
showOwner: boolean,
|
||
showActions: boolean,
|
||
): React.CSSProperties {
|
||
const minWidth =
|
||
FIXED_TRACKS_WIDTH +
|
||
COLUMN_WIDTHS.health +
|
||
(showOwner ? COLUMN_WIDTHS.owner : 0) +
|
||
COLUMN_WIDTHS.agents +
|
||
COLUMN_WIDTHS.cost +
|
||
COLUMN_WIDTHS.cli +
|
||
(showActions ? 28 : 0);
|
||
return {
|
||
"--rtc-health": `${COLUMN_WIDTHS.health}px`,
|
||
"--rtc-owner": showOwner ? `${COLUMN_WIDTHS.owner}px` : "0px",
|
||
"--rtc-agents": `${COLUMN_WIDTHS.agents}px`,
|
||
"--rtc-cost": `${COLUMN_WIDTHS.cost}px`,
|
||
"--rtc-cli": `${COLUMN_WIDTHS.cli}px`,
|
||
"--rtc-kebab": showActions ? "1.75rem" : "0px",
|
||
"--rtc-minw": `${minWidth}px`,
|
||
} as React.CSSProperties;
|
||
}
|
||
|
||
interface RuntimeWorkload {
|
||
agentIds: string[];
|
||
runningCount: number;
|
||
queuedCount: number;
|
||
}
|
||
|
||
const EMPTY_WORKLOAD: RuntimeWorkload = {
|
||
agentIds: [],
|
||
runningCount: 0,
|
||
queuedCount: 0,
|
||
};
|
||
|
||
export interface RuntimeRow {
|
||
runtime: AgentRuntime;
|
||
profile: RuntimeProfile | null;
|
||
ownerMember: MemberWithUser | null;
|
||
workload: RuntimeWorkload;
|
||
canDelete: boolean;
|
||
}
|
||
|
||
// Per-runtime workload snapshot — agent IDs serving this runtime (drives
|
||
// the avatar stack; .length doubles as the agent count) plus task counts
|
||
// split by status. Built once per render off the workspace-wide
|
||
// agents / agent-task-snapshot caches; filtered locally — no extra requests.
|
||
export function buildWorkloadIndex(
|
||
agents: Agent[],
|
||
tasks: AgentTask[],
|
||
): Map<string, RuntimeWorkload> {
|
||
const result = new Map<string, RuntimeWorkload>();
|
||
const agentToRuntime = new Map<string, string>();
|
||
|
||
for (const a of agents) {
|
||
if (!a.runtime_id || a.archived_at) continue;
|
||
agentToRuntime.set(a.id, a.runtime_id);
|
||
const entry =
|
||
result.get(a.runtime_id) ?? {
|
||
agentIds: [],
|
||
runningCount: 0,
|
||
queuedCount: 0,
|
||
};
|
||
entry.agentIds.push(a.id);
|
||
result.set(a.runtime_id, entry);
|
||
}
|
||
for (const t of tasks) {
|
||
const rid = agentToRuntime.get(t.agent_id);
|
||
if (!rid) continue;
|
||
const entry = result.get(rid);
|
||
if (!entry) continue;
|
||
if (t.status === "running") entry.runningCount += 1;
|
||
else if (t.status === "queued" || t.status === "dispatched")
|
||
entry.queuedCount += 1;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cells
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function RuntimeNameCell({ runtime }: { runtime: AgentRuntime }) {
|
||
const { base: baseName } = splitRuntimeName(runtime.name);
|
||
return (
|
||
<ListGridCell className="gap-2">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||
<ProviderLogo provider={runtime.provider} className="h-5 w-5" />
|
||
</div>
|
||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||
<span className="block min-w-0 shrink truncate text-sm font-medium">
|
||
{baseName}
|
||
</span>
|
||
<RuntimeKindBadge runtime={runtime} />
|
||
<PendingRuntimeBadge runtime={runtime} />
|
||
<VisibilityBadge runtime={runtime} />
|
||
</div>
|
||
</ListGridCell>
|
||
);
|
||
}
|
||
|
||
// Distinguishes a built-in protocol-family runtime from one launched off a
|
||
// custom runtime profile. `profile_id` is the discriminator: a non-null /
|
||
// non-empty value means the runtime was started from a custom profile.
|
||
// Older backends omit the field — treated as built-in.
|
||
function RuntimeKindBadge({ runtime }: { runtime: AgentRuntime }) {
|
||
const { t } = useT("runtimes");
|
||
const isCustom = !!runtime.profile_id;
|
||
return (
|
||
<span
|
||
className={
|
||
isCustom
|
||
? "inline-flex shrink-0 items-center rounded bg-info/10 px-1 text-[10px] font-medium text-info"
|
||
: "inline-flex shrink-0 items-center rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground"
|
||
}
|
||
>
|
||
{isCustom
|
||
? t(($) => $.list.badge_custom)
|
||
: t(($) => $.list.badge_builtin)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PendingRuntimeBadge({ runtime }: { runtime: AgentRuntime }) {
|
||
const { t } = useT("runtimes");
|
||
if (!isPendingCustomRuntime(runtime)) return null;
|
||
return (
|
||
<span className="inline-flex shrink-0 items-center rounded bg-warning/10 px-1 text-[10px] font-medium text-warning">
|
||
{t(($) => $.list.badge_registering)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// Only public is worth a badge — private is the default and rendering a
|
||
// `🔒 Private` chip on every row turns the whole column into noise.
|
||
function VisibilityBadge({ runtime }: { runtime: AgentRuntime }) {
|
||
const { t } = useT("runtimes");
|
||
if (runtime.visibility !== "public") return null;
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-info/10 px-1 text-[10px] font-medium text-info">
|
||
<Globe className="h-2.5 w-2.5" />
|
||
{t(($) => $.detail.visibility_label.public)}
|
||
</span>
|
||
}
|
||
/>
|
||
<TooltipContent>
|
||
{t(($) => $.detail.visibility_hint.public)}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
// Health with the load folded in as a "· N tasks" suffix — verbatim the
|
||
// same form as the agents list's status cell, so the two surfaces speak
|
||
// one language. The suffix is a unit-bearing count (running + queued);
|
||
// offline-ish rows skip it (health already says it all), idle rows skip
|
||
// it (idle is the unremarkable default). If "queued but nothing running"
|
||
// ever becomes a signal worth surfacing, it belongs to the HEALTH layer
|
||
// (a new deriveRuntimeHealth state), not to vocabulary hints here.
|
||
function HealthCell({
|
||
runtime,
|
||
workload,
|
||
now,
|
||
}: {
|
||
runtime: AgentRuntime;
|
||
workload: RuntimeWorkload;
|
||
now: number;
|
||
}) {
|
||
const { t } = useT("runtimes");
|
||
const { t: tAgents } = useT("agents");
|
||
const labelOf = useHealthLabel();
|
||
const registrationFailure = customRuntimeRegistrationFailure(runtime);
|
||
if (registrationFailure) {
|
||
return (
|
||
<ListGridCell className="gap-1.5">
|
||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||
<span
|
||
className="block min-w-0 truncate text-xs text-destructive"
|
||
title={registrationFailure}
|
||
>
|
||
{t(($) => $.list.pending_health_error)}
|
||
</span>
|
||
</ListGridCell>
|
||
);
|
||
}
|
||
if (isPendingCustomRuntime(runtime)) {
|
||
const warning = isPendingCustomRuntimeWarning(runtime, now);
|
||
return (
|
||
<ListGridCell className="gap-1.5">
|
||
{warning ? (
|
||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
|
||
) : (
|
||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-info" />
|
||
)}
|
||
<span className="block min-w-0 truncate text-xs">
|
||
{warning
|
||
? t(($) => $.list.pending_health_warning)
|
||
: t(($) => $.list.pending_health)}
|
||
</span>
|
||
</ListGridCell>
|
||
);
|
||
}
|
||
|
||
const health = deriveRuntimeHealth(runtime, now);
|
||
const offline = health === "offline" || health === "about_to_gc";
|
||
const lastSeen = formatLastSeen(runtime.last_seen_at);
|
||
const active = workload.runningCount + workload.queuedCount;
|
||
|
||
return (
|
||
<ListGridCell className="gap-1.5">
|
||
<HealthIcon health={health} />
|
||
<span className="block min-w-0 truncate text-xs">
|
||
{labelOf(health)}
|
||
{health !== "online" && runtime.last_seen_at && (
|
||
<span className="text-muted-foreground"> · {lastSeen}</span>
|
||
)}
|
||
{!offline && active > 0 && (
|
||
<span className="text-muted-foreground">
|
||
{" · "}
|
||
{tAgents(($) => $.row.task_count, { count: active })}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</ListGridCell>
|
||
);
|
||
}
|
||
|
||
// Per-row cost — only renders a 7d total + delta vs the prior 7d, so we
|
||
// only need 14 days of usage. Previously this fetched a 180-day window to
|
||
// share the cache key with the runtime-detail page, but that turned the
|
||
// list page into N × 180d in-line aggregations against `task_usage` (one
|
||
// per runtime row) and dominated DB load for this view. Detail still
|
||
// fetches its own 180d window on navigation; the cold-load difference for
|
||
// detail is one extra request, while the steady-state savings on the list
|
||
// page are large.
|
||
const COST_CELL_DAYS = 14;
|
||
|
||
export function CostCell({ runtimeId }: { runtimeId: string }) {
|
||
const { t } = useT("runtimes");
|
||
const tz = useViewingTimezone();
|
||
const { data: usage = [] } = useQuery(
|
||
runtimeUsageOptions(runtimeId, COST_CELL_DAYS, tz),
|
||
);
|
||
const cost7d = useMemo(() => computeCostInWindow(usage, 7, tz), [usage, tz]);
|
||
const costPrev7d = useMemo(
|
||
() => computeCostInWindow(usage, 7, tz, 7),
|
||
[usage, tz],
|
||
);
|
||
const delta = pctChange(cost7d, costPrev7d);
|
||
|
||
if (usage.length === 0) {
|
||
return (
|
||
<div className="w-full text-right">
|
||
<span className="text-xs text-muted-foreground/50">—</span>
|
||
</div>
|
||
);
|
||
}
|
||
const fmt = cost7d >= 100 ? `$${cost7d.toFixed(0)}` : `$${cost7d.toFixed(2)}`;
|
||
const deltaTone =
|
||
delta == null
|
||
? "text-muted-foreground"
|
||
: delta > 0
|
||
? "text-warning"
|
||
: delta < 0
|
||
? "text-success"
|
||
: "text-muted-foreground";
|
||
const deltaLabel =
|
||
delta == null
|
||
? null
|
||
: delta === 0
|
||
? t(($) => $.list.cost_delta_flat)
|
||
: `${delta > 0 ? "↑" : "↓"}${Math.abs(delta)}%`;
|
||
return (
|
||
<div className="flex w-full flex-col items-end leading-tight">
|
||
<span className="text-sm font-medium tabular-nums">{fmt}</span>
|
||
{deltaLabel && (
|
||
<span className={`text-[11px] tabular-nums ${deltaTone}`}>
|
||
{deltaLabel}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CliCell({ runtime }: { runtime: AgentRuntime }) {
|
||
const { t } = useT("runtimes");
|
||
const failure = customRuntimeRegistrationFailure(runtime);
|
||
if (failure) {
|
||
const command = pendingRuntimeCommandName(runtime);
|
||
return (
|
||
<div className="flex min-w-0 flex-col text-xs">
|
||
{command && (
|
||
<span
|
||
className="truncate font-mono text-muted-foreground"
|
||
title={command}
|
||
>
|
||
{command}
|
||
</span>
|
||
)}
|
||
<span className="truncate text-destructive" title={failure}>
|
||
{failure}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
if (isPendingCustomRuntime(runtime)) {
|
||
const command = pendingRuntimeCommandName(runtime);
|
||
if (!command) {
|
||
return (
|
||
<span className="text-xs text-muted-foreground/50">
|
||
{t(($) => $.list.pending_cli_unknown)}
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<div className="flex min-w-0 items-center text-xs">
|
||
<span
|
||
className="truncate font-mono text-muted-foreground"
|
||
title={command}
|
||
>
|
||
{command}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (runtime.runtime_mode === "cloud") {
|
||
return <span className="text-xs text-muted-foreground/50">—</span>;
|
||
}
|
||
const meta = runtime.metadata as Record<string, unknown> | null;
|
||
// `version` is the agent's own underlying CLI tool version — distinct per
|
||
// provider (e.g. "2.1.5 (Claude Code)", "codex-cli 0.118.0", "0.42.0").
|
||
// The separate `cli_version` is the shared multica daemon CLI, identical
|
||
// for every runtime on one machine; surfacing it here made all agents
|
||
// show the same number (#3838). The daemon CLI version and its update
|
||
// prompt belong to the machine — they live in the machine meta strip and
|
||
// the detail page's UpdateSection, not on a per-agent row.
|
||
const version =
|
||
meta && typeof meta.version === "string" ? meta.version : null;
|
||
|
||
if (!version) {
|
||
return <span className="text-xs text-muted-foreground/50">—</span>;
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-w-0 items-center text-xs">
|
||
<span className="truncate font-mono text-muted-foreground">
|
||
{version}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Stacks up to 3 agent avatars, then a "+N" pill if more bind to this
|
||
// runtime. Each avatar uses the wrapping ActorAvatar so hover automatically
|
||
// surfaces AgentProfileCard.
|
||
function AgentStack({ agentIds }: { agentIds: string[] }) {
|
||
if (agentIds.length === 0) {
|
||
return <span className="text-xs text-muted-foreground/50">—</span>;
|
||
}
|
||
const visible = agentIds.slice(0, 3);
|
||
const extra = agentIds.length - visible.length;
|
||
return (
|
||
<div className="flex items-center -space-x-1.5">
|
||
{visible.map((id) => (
|
||
<span
|
||
key={id}
|
||
className="inline-flex rounded-full ring-2 ring-background"
|
||
>
|
||
<ActorAvatar
|
||
actorType="agent"
|
||
actorId={id}
|
||
size="md"
|
||
enableHoverCard
|
||
/>
|
||
</span>
|
||
))}
|
||
{extra > 0 && (
|
||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground ring-2 ring-background">
|
||
+{extra}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function RuntimeRowMenu({
|
||
runtime,
|
||
profile,
|
||
wsId,
|
||
canDelete,
|
||
}: {
|
||
runtime: AgentRuntime;
|
||
profile: RuntimeProfile | null;
|
||
wsId: string;
|
||
canDelete: boolean;
|
||
}) {
|
||
const { t } = useT("runtimes");
|
||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||
const isCustomRuntime = !!runtime.profile_id;
|
||
// Delete is currently the only row action; if the row can't run it, drop
|
||
// the kebab entirely so the column doesn't render an empty popover. We
|
||
// used to also hide it for self-healing runtimes (live local daemon
|
||
// re-registers within seconds), but MUL-3352 surfaced that owners read
|
||
// a missing kebab as "I lost my permission" rather than "the daemon
|
||
// would undo this". The dialog now carries the self-heal warning and
|
||
// the user gets to decide.
|
||
|
||
if (!canDelete) {
|
||
return <span aria-hidden />;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<button
|
||
type="button"
|
||
aria-label={t(($) => $.list.row_actions_aria)}
|
||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/row:opacity-100 data-popup-open:bg-accent data-popup-open:opacity-100 data-popup-open:text-accent-foreground"
|
||
>
|
||
<MoreHorizontal className="size-4" />
|
||
</button>
|
||
}
|
||
/>
|
||
<DropdownMenuContent align="end" className="w-40">
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onClick={() => setDeleteOpen(true)}
|
||
title={t(($) => $.list.delete_permission_hint)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
{t(($) => $.list.delete_action)}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
{isCustomRuntime && profile ? (
|
||
<DeleteRuntimeProfileDialog
|
||
open={deleteOpen}
|
||
onOpenChange={setDeleteOpen}
|
||
profile={profile}
|
||
wsId={wsId}
|
||
onDeleted={() => setDeleteOpen(false)}
|
||
/>
|
||
) : (
|
||
<DeleteRuntimeDialog
|
||
open={deleteOpen}
|
||
onOpenChange={setDeleteOpen}
|
||
runtime={runtime}
|
||
wsId={wsId}
|
||
onDeleted={() => {
|
||
setDeleteOpen(false);
|
||
toast.success(t(($) => $.detail.toast_deleted));
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// List
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function RuntimeList({
|
||
runtimes,
|
||
updatableIds,
|
||
now,
|
||
}: {
|
||
runtimes: AgentRuntime[];
|
||
// Kept on the API surface for callers, but unused here: the CLI column
|
||
// shows each agent's own tool version, while the multica daemon CLI
|
||
// update prompt lives at the machine/detail level (UpdateSection), so the
|
||
// table no longer derives per-row update state. Left to avoid scope creep
|
||
// on the page-level wrapper that still computes the set.
|
||
updatableIds?: Set<string>;
|
||
now: number;
|
||
}) {
|
||
void updatableIds;
|
||
|
||
const { t } = useT("runtimes");
|
||
const wsId = useWorkspaceId();
|
||
const wsPaths = useWorkspacePaths();
|
||
const rowLink = useRowLink();
|
||
const user = useAuthStore((s) => s.user);
|
||
|
||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||
const { data: profiles = [] } = useQuery(runtimeProfileListOptions(wsId));
|
||
|
||
const currentMember = user
|
||
? members.find((m) => m.user_id === user.id)
|
||
: null;
|
||
const isAdmin = currentMember
|
||
? currentMember.role === "owner" || currentMember.role === "admin"
|
||
: false;
|
||
|
||
const workloadIndex = useMemo(
|
||
() => buildWorkloadIndex(agents, snapshot),
|
||
[agents, snapshot],
|
||
);
|
||
|
||
const memberById = useMemo(() => {
|
||
const map = new Map<string, MemberWithUser>();
|
||
for (const m of members) map.set(m.user_id, m);
|
||
return map;
|
||
}, [members]);
|
||
|
||
const profileById = useMemo(() => {
|
||
const map = new Map<string, RuntimeProfile>();
|
||
for (const p of profiles) map.set(p.id, p);
|
||
return map;
|
||
}, [profiles]);
|
||
|
||
// Owner column only earns its space when the page actually has multiple
|
||
// distinct owners — otherwise it would just be a column of identical
|
||
// avatars.
|
||
const showOwner = useMemo(() => {
|
||
const owners = new Set<string>();
|
||
for (const r of runtimes) {
|
||
if (r.owner_id) owners.add(r.owner_id);
|
||
}
|
||
return owners.size > 1;
|
||
}, [runtimes]);
|
||
|
||
const rows = useMemo<RuntimeRow[]>(() => {
|
||
return runtimes.map((runtime) => {
|
||
const profile = runtime.profile_id
|
||
? profileById.get(runtime.profile_id) ?? null
|
||
: null;
|
||
const isCustomRuntime = !!runtime.profile_id;
|
||
return {
|
||
runtime,
|
||
profile,
|
||
ownerMember: runtime.owner_id
|
||
? memberById.get(runtime.owner_id) ?? null
|
||
: null,
|
||
workload: workloadIndex.get(runtime.id) ?? EMPTY_WORKLOAD,
|
||
canDelete:
|
||
!isPendingCustomRuntime(runtime) &&
|
||
(isCustomRuntime
|
||
? isAdmin && !!profile
|
||
: isAdmin || (!!user && runtime.owner_id === user.id)),
|
||
};
|
||
});
|
||
}, [runtimes, profileById, memberById, workloadIndex, isAdmin, user]);
|
||
|
||
// Mirrors RuntimeRowMenu's render guard: the kebab track only earns its
|
||
// width when at least one row will actually show the menu.
|
||
const showActions = rows.some((row) => row.canDelete);
|
||
|
||
return (
|
||
<div className="overflow-x-auto overflow-y-hidden @container">
|
||
<ListGrid
|
||
className={`${GRID_COLS} @2xl:min-w-[var(--rtc-minw)]`}
|
||
style={columnTrackVars(showOwner, showActions)}
|
||
>
|
||
<ListGridHeader>
|
||
<ListGridHeaderCell>
|
||
{t(($) => $.list.col_runtime)}
|
||
</ListGridHeaderCell>
|
||
<ListGridHeaderCell>{t(($) => $.list.col_health)}</ListGridHeaderCell>
|
||
{showOwner ? (
|
||
<ListGridHeaderCell className="hidden @2xl:flex">
|
||
{t(($) => $.list.col_owner)}
|
||
</ListGridHeaderCell>
|
||
) : (
|
||
<ListGridHeaderCell className="hidden px-0 @2xl:flex" />
|
||
)}
|
||
<ListGridHeaderCell className="hidden @2xl:flex">
|
||
{t(($) => $.list.col_agents)}
|
||
</ListGridHeaderCell>
|
||
<ListGridHeaderCell className="hidden @2xl:flex" align="right">
|
||
{t(($) => $.list.col_cost)}
|
||
</ListGridHeaderCell>
|
||
<ListGridHeaderCell className="hidden @2xl:flex">
|
||
{t(($) => $.list.col_cli)}
|
||
</ListGridHeaderCell>
|
||
<span aria-hidden="true" />
|
||
</ListGridHeader>
|
||
{rows.map((row) => {
|
||
const pending = isPendingCustomRuntime(row.runtime);
|
||
return (
|
||
<ListGridRow
|
||
key={row.runtime.id}
|
||
className={pending ? "cursor-default" : "cursor-pointer"}
|
||
{...(!pending
|
||
? rowLink(wsPaths.runtimeDetail(row.runtime.id))
|
||
: {})}
|
||
>
|
||
<RuntimeNameCell runtime={row.runtime} />
|
||
<HealthCell
|
||
runtime={row.runtime}
|
||
workload={row.workload}
|
||
now={now}
|
||
/>
|
||
{showOwner ? (
|
||
<ListGridCell className="hidden gap-1.5 @2xl:flex">
|
||
{row.ownerMember ? (
|
||
<>
|
||
<ActorAvatar
|
||
actorType="member"
|
||
actorId={row.ownerMember.user_id}
|
||
size="sm"
|
||
/>
|
||
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
||
{row.ownerMember.name}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground/50">—</span>
|
||
)}
|
||
</ListGridCell>
|
||
) : (
|
||
<ListGridCell className="hidden px-0 @2xl:flex" />
|
||
)}
|
||
<ListGridCell className="hidden @2xl:flex">
|
||
<AgentStack agentIds={row.workload.agentIds} />
|
||
</ListGridCell>
|
||
<ListGridCell className="hidden @2xl:flex">
|
||
{pending ? (
|
||
<div className="w-full text-right">
|
||
<span className="text-xs text-muted-foreground/50">—</span>
|
||
</div>
|
||
) : (
|
||
<CostCell runtimeId={row.runtime.id} />
|
||
)}
|
||
</ListGridCell>
|
||
<ListGridCell className="hidden @2xl:flex">
|
||
<CliCell runtime={row.runtime} />
|
||
</ListGridCell>
|
||
<ListGridCell className="justify-end px-0">
|
||
<span
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="flex items-center"
|
||
>
|
||
<RuntimeRowMenu
|
||
runtime={row.runtime}
|
||
profile={row.profile}
|
||
wsId={wsId}
|
||
canDelete={row.canDelete}
|
||
/>
|
||
</span>
|
||
</ListGridCell>
|
||
</ListGridRow>
|
||
);
|
||
})}
|
||
</ListGrid>
|
||
</div>
|
||
);
|
||
}
|