refactor(views): migrate agent/runtime/skill lists to TanStack DataTable

Replace the per-page CSS Grid + minmax(min, fr) + sticky-first-col + truncate
implementation with a TanStack Table backend rendered through a Dice UI-style
DataTable shell. Column widths are now px-based via column.size, so cells
no longer shrink or auto-truncate as the viewport narrows; when the sum of
columns exceeds the viewport, the container scrolls horizontally instead.

- Add @tanstack/react-table to the catalog (8.21.3) and wire it into
  packages/ui (dep) and packages/views (peerDep).
- packages/ui: new DataTable + DataTableColumnHeader + lib/data-table.ts
  (getColumnPinningStyle), adapted from Dice UI's registry. The shell
  renders <table> directly (skipping shadcn's <Table> wrapper) so its own
  outer overflow controls both axes — no nested overflow conflicts.
- packages/views: each list now declares ColumnDef[] with explicit
  cell renderers. Row click navigates to detail via onRowClick (instead of
  wrapping <tr> in <a>, which is invalid HTML); kebab dropdowns
  stopPropagation so they don't trigger the row navigation.
- Drop the previous AGENT_LIST_GRID / GRID_WITH_OWNER / ROW_GRID
  templates and the sticky-first-col / subgrid mechanics that came with
  them. agent-list-item.tsx is removed; runtime-list.tsx and
  skills-page.tsx are trimmed to thin wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Naiyuan Qing
2026-04-29 09:39:47 +08:00
co-authored by Claude Opus 4.7
parent f0c845b777
commit 2be0f28713
14 changed files with 1552 additions and 1251 deletions
@@ -0,0 +1,106 @@
"use client";
import type { Column } from "@tanstack/react-table";
import {
ChevronDown,
ChevronsUpDown,
ChevronUp,
EyeOff,
X,
} from "lucide-react";
import type * as React from "react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { cn } from "@multica/ui/lib/utils";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.ComponentProps<typeof DropdownMenuTrigger> {
column: Column<TData, TValue>;
label: string;
}
// Sort/hide-aware column header, adapted from Dice UI
// (https://diceui.com/r/data-table). Renders the label as plain text when
// the column has neither sorting nor hiding enabled (so non-interactive
// columns don't expose a useless dropdown). Otherwise wraps the label in
// a dropdown-menu trigger that toggles sort direction and hides the
// column on demand.
export function DataTableColumnHeader<TData, TValue>({
column,
label,
className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort() && !column.getCanHide()) {
return <div className={cn(className)}>{label}</div>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"-ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
className,
)}
{...props}
>
{label}
{column.getCanSort() &&
(column.getIsSorted() === "desc" ? (
<ChevronDown />
) : column.getIsSorted() === "asc" ? (
<ChevronUp />
) : (
<ChevronsUpDown />
))}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-28">
{column.getCanSort() && (
<>
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsSorted() === "asc"}
onClick={() => column.toggleSorting(false)}
>
<ChevronUp />
Asc
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsSorted() === "desc"}
onClick={() => column.toggleSorting(true)}
>
<ChevronDown />
Desc
</DropdownMenuCheckboxItem>
{column.getIsSorted() && (
<DropdownMenuItem
className="pl-2 [&_svg]:text-muted-foreground"
onClick={() => column.clearSorting()}
>
<X />
Reset
</DropdownMenuItem>
)}
</>
)}
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={!column.getIsVisible()}
onClick={() => column.toggleVisibility(false)}
>
<EyeOff />
Hide
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import {
flexRender,
type Row,
type Table as TanstackTable,
} from "@tanstack/react-table";
import type * as React from "react";
// Note: we deliberately use the lower-level shadcn primitives
// (TableHeader / TableBody / TableRow / TableHead / TableCell) but NOT the
// wrapping <Table> component. shadcn's <Table> nests the <table> inside an
// `overflow-x-auto` <div>, which would compete with our outer scroll
// container and pin the horizontal scrollbar to the bottom of the table
// rather than the viewport. The <table> below is rendered inline with
// `w-max min-w-full table-fixed` so it sizes to the sum of column widths
// and the outer container handles both axes of scroll.
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@multica/ui/components/ui/table";
import { getColumnPinningStyle } from "@multica/ui/lib/data-table";
import { cn } from "@multica/ui/lib/utils";
interface DataTableProps<TData> extends React.ComponentProps<"div"> {
table: TanstackTable<TData>;
// Optional bar shown above/below the table when ≥1 row is selected. We
// don't currently use selection — kept on the API surface for parity
// with Dice UI's component so future row-select features just work.
actionBar?: React.ReactNode;
// Slot rendered above the table viewport (toolbar, filters, etc.) so
// it stays anchored while the table area scrolls. Optional.
toolbar?: React.ReactNode;
// Override for the empty-state cell text.
emptyMessage?: React.ReactNode;
// Called when the user clicks a row (anywhere outside an interactive
// descendant — buttons / links / dropdowns inside cells should call
// event.stopPropagation in their own handlers). Used to navigate to a
// detail page on row click without nesting an <a> around <tr>, which
// is invalid HTML.
onRowClick?: (row: Row<TData>) => void;
}
// Headless data-table shell — adapted from Dice UI's data-table registry
// (https://diceui.com/r/data-table). Renders a TanStack Table instance
// using shadcn/ui's <Table> primitives. Column widths come from
// `column.getSize()` (set per column via the size/minSize/maxSize APIs in
// the column definition); when the sum of column widths exceeds the
// viewport, the inner wrapper's overflow-x: auto kicks in and the table
// scrolls horizontally.
//
// Differences from the upstream Dice UI component:
// - No built-in pagination footer (we render full lists, not paginated).
// - Toolbar slot is a named `toolbar` prop instead of children, so the
// toolbar lives outside the scroll wrapper and stays put while the
// table scrolls.
// - Inner wrapper grows to fill remaining vertical space and scrolls
// both axes.
export function DataTable<TData>({
table,
actionBar,
toolbar,
emptyMessage = "No results.",
onRowClick,
className,
...props
}: DataTableProps<TData>) {
return (
<div
className={cn("flex min-h-0 flex-1 flex-col", className)}
{...props}
>
{toolbar}
<div className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
<table className="w-max min-w-full table-fixed caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-muted/30 backdrop-blur">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={{
...getColumnPinningStyle({ column: header.column }),
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={
onRowClick ? () => onRowClick(row) : undefined
}
className={onRowClick ? "cursor-pointer" : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
...getColumnPinningStyle({ column: cell.column }),
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getAllColumns().length}
className="h-24 text-center text-muted-foreground"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
{actionBar &&
table.getFilteredSelectedRowModel().rows.length > 0 &&
actionBar}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Column } from "@tanstack/react-table";
import type * as React from "react";
// Pinning style helper from Dice UI's data-table registry
// (https://diceui.com/r/data-table). Returns the inline style needed to make
// a column stick to the left/right edge of the scroll container — used by
// the DataTable header and body cells via `style={...}`. Width is always
// applied (from column.getSize()) regardless of pinning, since TanStack
// Table is the source of truth for column widths.
export function getColumnPinningStyle<TData>({
column,
withBorder = false,
}: {
column: Column<TData>;
withBorder?: boolean;
}): React.CSSProperties {
const isPinned = column.getIsPinned();
const isLastLeftPinnedColumn =
isPinned === "left" && column.getIsLastColumn("left");
const isFirstRightPinnedColumn =
isPinned === "right" && column.getIsFirstColumn("right");
return {
boxShadow: withBorder
? isLastLeftPinnedColumn
? "-4px 0 4px -4px var(--border) inset"
: isFirstRightPinnedColumn
? "4px 0 4px -4px var(--border) inset"
: undefined
: undefined,
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
opacity: isPinned ? 0.97 : 1,
position: isPinned ? "sticky" : "relative",
background: isPinned ? "var(--background)" : undefined,
width: column.getSize(),
zIndex: isPinned ? 1 : undefined,
};
}
+2
View File
@@ -16,12 +16,14 @@
"./markdown/mentions": "./markdown/mentions.ts",
"./hooks/*": "./hooks/*.ts",
"./lib/utils": "./lib/utils.ts",
"./lib/data-table": "./lib/data-table.ts",
"./styles/tokens.css": "./styles/tokens.css",
"./styles/base.css": "./styles/base.css"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@emoji-mart/data": "^1.2.1",
"@tanstack/react-table": "catalog:",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
@@ -0,0 +1,328 @@
"use client";
import { Cloud, Lock, Monitor } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import type { Agent, AgentRuntime } from "@multica/core/types";
import {
type AgentActivity,
type AgentPresenceDetail,
summarizeActivityWindow,
} from "@multica/core/agents";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { availabilityConfig, taskStateConfig } from "../presence";
import { AgentRowActions } from "./agent-row-actions";
import { Sparkline } from "./sparkline";
// Per-row data shape. We assemble agent + runtime + presence + activity +
// run count into one struct at the page level so the column cells just
// read off `row.original` without each pulling its own queries.
export interface AgentRow {
agent: Agent;
runtime: AgentRuntime | null;
presence: AgentPresenceDetail | null | undefined;
activity: AgentActivity | null | undefined;
runCount: number;
// Inline owner avatar — non-null when the page wants to attribute the
// agent to a teammate (typically All scope on someone else's agent).
ownerIdToShow: string | null;
// True when the current user can archive / cancel-tasks on this agent.
canManage: boolean;
}
// Column widths in px. Sum determines the table's natural width; when the
// sum exceeds the viewport, the DataTable container scrolls horizontally.
// Values picked to fit content comfortably without truncating in typical
// cases — e.g. Agent at 360 holds avatar + 18-char name + a one-line
// description excerpt.
const COL_WIDTHS = {
agent: 360,
status: 120,
lastRun: 160,
runtime: 200,
activity: 100,
runs: 80,
actions: 48,
} as const;
export function createAgentColumns({
onDuplicate,
}: {
onDuplicate: (agent: Agent) => void;
}): ColumnDef<AgentRow>[] {
return [
{
id: "agent",
header: "Agent",
size: COL_WIDTHS.agent,
cell: ({ row }) => <AgentNameCell row={row.original} />,
},
{
id: "status",
header: "Status",
size: COL_WIDTHS.status,
cell: ({ row }) => {
if (row.original.agent.archived_at) {
return <span className="text-xs text-muted-foreground">—</span>;
}
return <AvailabilityCell presence={row.original.presence} />;
},
},
{
id: "lastRun",
header: "Last run",
size: COL_WIDTHS.lastRun,
cell: ({ row }) => {
if (row.original.agent.archived_at) {
return <span className="text-xs text-muted-foreground">—</span>;
}
return <LastRunCell presence={row.original.presence} />;
},
},
{
id: "runtime",
header: "Runtime",
size: COL_WIDTHS.runtime,
cell: ({ row }) => <RuntimeCell row={row.original} />,
},
{
id: "activity",
header: "Activity (7d)",
size: COL_WIDTHS.activity,
cell: ({ row }) => <ActivityCell row={row.original} />,
},
{
id: "runs",
header: () => <div className="text-right">Runs</div>,
size: COL_WIDTHS.runs,
cell: ({ row }) => (
<div className="text-right font-mono text-xs tabular-nums text-muted-foreground">
{row.original.runCount == null
? "—"
: row.original.runCount.toLocaleString()}
</div>
),
},
{
id: "actions",
header: () => null,
size: COL_WIDTHS.actions,
cell: ({ row }) => (
<div
className="flex justify-end"
// The kebab dropdown owns its own click target. Stop the row
// click handler from firing as a side-effect.
onClick={(e) => e.stopPropagation()}
>
<AgentRowActions
agent={row.original.agent}
presence={row.original.presence}
canManage={row.original.canManage}
onDuplicate={onDuplicate}
/>
</div>
),
},
];
}
// ---------------------------------------------------------------------------
// Cell renderers
// ---------------------------------------------------------------------------
function AgentNameCell({ row }: { row: AgentRow }) {
const { agent, ownerIdToShow } = row;
const isArchived = !!agent.archived_at;
const isPrivate = agent.visibility === "private";
return (
<div className="flex items-center gap-3">
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={28}
className={`shrink-0 rounded-md ${isArchived ? "opacity-50 grayscale" : ""}`}
showStatusDot
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={`truncate font-medium ${
isArchived ? "text-muted-foreground" : ""
}`}
>
{agent.name}
</span>
{isPrivate && !isArchived && (
<Tooltip>
<TooltipTrigger
render={
<Lock className="h-3 w-3 shrink-0 text-muted-foreground/60" />
}
/>
<TooltipContent>
Private — only the owner can assign work
</TooltipContent>
</Tooltip>
)}
{ownerIdToShow && (
<ActorAvatar
actorType="member"
actorId={ownerIdToShow}
size={14}
/>
)}
{isArchived && (
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Archived
</span>
)}
</div>
<div
className={`mt-0.5 line-clamp-1 text-xs ${
agent.description
? "text-muted-foreground"
: "italic text-muted-foreground/50"
}`}
>
{agent.description || "No description"}
</div>
</div>
</div>
);
}
function AvailabilityCell({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
if (!presence) {
return (
<span className="inline-flex h-3 w-16 animate-pulse rounded bg-muted/60" />
);
}
const av = availabilityConfig[presence.availability];
return (
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
</span>
);
}
function LastRunCell({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
if (!presence) {
return (
<span className="inline-flex h-3 w-20 animate-pulse rounded bg-muted/60" />
);
}
if (presence.lastTask === "idle") {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
const ts = taskStateConfig[presence.lastTask];
const isRunning = presence.lastTask === "running";
const counts =
isRunning && presence.queuedCount > 0
? `${presence.runningCount}/${presence.capacity} +${presence.queuedCount}q`
: isRunning
? `${presence.runningCount}/${presence.capacity}`
: null;
return (
<span className="inline-flex items-center gap-1 text-xs">
<ts.icon className={`h-3 w-3 shrink-0 ${ts.textClass}`} />
<span className={`shrink-0 ${ts.textClass}`}>{ts.label}</span>
{counts && (
<span className="truncate text-muted-foreground">{counts}</span>
)}
</span>
);
}
function RuntimeCell({ row }: { row: AgentRow }) {
const { agent, runtime } = row;
const isCloud = agent.runtime_mode === "cloud";
const RuntimeIcon = isCloud ? Cloud : Monitor;
const runtimeLabel = runtime?.name ?? (isCloud ? "Cloud" : "Local");
return (
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<RuntimeIcon className="h-3 w-3 shrink-0" />
<Tooltip>
<TooltipTrigger
render={<span className="truncate">{runtimeLabel}</span>}
/>
<TooltipContent>{runtimeLabel}</TooltipContent>
</Tooltip>
</div>
);
}
function ActivityCell({ row }: { row: AgentRow }) {
const { agent, activity } = row;
if (agent.archived_at) {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
if (!activity) {
return (
<span
className="inline-block animate-pulse rounded bg-muted/60"
style={{ width: 64, height: 20 }}
/>
);
}
const summary = summarizeActivityWindow(activity, 7);
return (
<Tooltip>
<TooltipTrigger
render={
<div className="inline-flex cursor-default items-center">
<Sparkline buckets={summary.buckets} width={64} height={20} />
</div>
}
/>
<TooltipContent>
<ActivityTooltipBody activity={activity} />
</TooltipContent>
</Tooltip>
);
}
function ActivityTooltipBody({ activity }: { activity: AgentActivity }) {
const summary = summarizeActivityWindow(activity, 7);
const { totalRuns, totalFailed } = summary;
const { daysSinceCreated } = activity;
const isPartial = daysSinceCreated < 7;
const headerText = isPartial
? `Created ${daysSinceCreated === 0 ? "today" : `${daysSinceCreated} day${daysSinceCreated === 1 ? "" : "s"} ago`}`
: "Last 7 days";
let bodyText: string;
if (totalRuns === 0) {
bodyText = "No activity";
} else {
const failedFragment =
totalFailed > 0
? ` · ${totalFailed} failed (${Math.round((totalFailed / totalRuns) * 100)}%)`
: "";
bodyText = `${totalRuns} run${totalRuns === 1 ? "" : "s"}${failedFragment}`;
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{headerText}
</span>
<span className="text-xs">{bodyText}</span>
</div>
);
}
@@ -1,354 +0,0 @@
"use client";
import { Cloud, Lock, Monitor } from "lucide-react";
import type { Agent, AgentRuntime } from "@multica/core/types";
import {
type AgentActivity,
type AgentPresenceDetail,
summarizeActivityWindow,
} from "@multica/core/agents";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { AppLink } from "../../navigation";
import { availabilityConfig, taskStateConfig } from "../presence";
import { AgentPresenceIndicator } from "./agent-presence-indicator";
import { AgentRowActions } from "./agent-row-actions";
import { Sparkline } from "./sparkline";
// Shared grid template used by both the list header and every row, so the
// sticky header columns stay aligned with the rows below it.
//
// Why grid (not <Table table-fixed>): table-fixed forces every column to a
// declared width, which means the Status column was always reserving space
// for the worst case ("Working · 0 / 6 · +5 queued"), even when no agent in
// the workspace was in that state. Switching to grid + `max-content` lets the
// column shrink automatically when the longest cell is just "Available", and
// only widen when there's an agent that actually needs the room. The freed
// space flows into the Agent column (the primary content), via its `1.6fr`
// share — same ratio Skills uses, so the two list pages read as one family.
//
// Leading-avatar column (1.75rem) is a dedicated grid track so the header's
// "Agent" label sits on the same x as the row's name text. Without this the
// avatar (28px) + the internal flex gap pushes the name 40px right of the
// header label, which reads as misalignment. Same pattern is used in Runtimes
// (icon-box and Health dot extracted into their own tracks).
//
// Responsive strategy mirrors the previous table:
// <md → [avatar] Agent · Status (compact dot only) · Actions
// md+ → adds Last run + Runtime + Runs
// lg+ → adds Activity sparkline
// Cells use `hidden md:block` / `hidden lg:block` so they participate in the
// grid only at their breakpoint; the grid template at each tier matches the
// number of visible cells exactly.
//
// Two presence columns at md+ (Status + Last run) instead of one merged
// cell — splitting them lets the user scan each axis independently. The
// dot column is the same 3-color availability everywhere; the Last run
// column shows the task icon + label (running/completed/failed/etc).
export const AGENT_LIST_GRID =
"grid items-center gap-4 " +
"grid-cols-[1.75rem_minmax(0,1fr)_max-content_2.5rem] " +
"md:grid-cols-[1.75rem_minmax(0,1.4fr)_5rem_minmax(0,max-content)_minmax(0,0.8fr)_4rem_2.5rem] " +
"lg:grid-cols-[1.75rem_minmax(0,1.5fr)_5rem_minmax(0,max-content)_minmax(0,0.8fr)_5rem_4rem_2.5rem]";
interface AgentListItemProps {
agent: Agent;
runtime: AgentRuntime | null;
presence: AgentPresenceDetail | null | undefined;
// 30d activity series for this agent. Page derives once for the whole
// workspace and passes a row-specific slice here, so the row component
// doesn't subscribe to its own query (avoids N timers / N subscriptions).
// The list only surfaces the trailing 7 days; we still take the 30d
// shape so the cache stays aligned with the agent detail panel.
activity: AgentActivity | null | undefined;
// 30-day cumulative run count for the RUNS column. Same single-source pattern.
runCount: number | null | undefined;
// Inline owner avatar — non-null when the page wants to attribute the
// agent to a teammate (typically All scope on someone else's agent).
// The page does the "scope === all && owner !== me" decision so the row
// stays pure presentation.
ownerIdToShow: string | null;
// True when the current user can archive / cancel-tasks on this agent.
// Mirrors the back-end's canManageAgent check; the row uses it to gate
// entries in the actions dropdown.
canManage: boolean;
// Page-level callback to open Create dialog with this agent as a
// template (Duplicate action).
onDuplicate: (agent: Agent) => void;
href: string;
}
export function AgentListItem({
agent,
runtime,
presence,
activity,
runCount,
ownerIdToShow,
canManage,
onDuplicate,
href,
}: AgentListItemProps) {
const isArchived = !!agent.archived_at;
const isPrivate = agent.visibility === "private";
const isCloud = agent.runtime_mode === "cloud";
const RuntimeIcon = isCloud ? Cloud : Monitor;
const runtimeLabel = runtime?.name ?? (isCloud ? "Cloud" : "Local");
return (
<AppLink
href={href}
className={`${AGENT_LIST_GRID} group border-b px-4 py-3 text-sm transition-colors last:border-b-0 hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none`}
>
{/* Avatar — dedicated leading column so the header label "Agent"
aligns with the name text below it. */}
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={28}
className={`rounded-md ${isArchived ? "opacity-50 grayscale" : ""}`}
showStatusDot
/>
{/* Agent — primary text column, eats remaining space. */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={`truncate font-medium ${
isArchived ? "text-muted-foreground" : ""
}`}
>
{agent.name}
</span>
{/* Lock = private visibility — back-end rejects assignment by
non-owners, so flag it visually. We deliberately do NOT
filter private agents out of the list (mirrors server's
ListAgents behaviour); the icon warns the viewer that
picking this in a picker will fail. */}
{isPrivate && !isArchived && (
<Tooltip>
<TooltipTrigger
render={
<Lock className="h-3 w-3 shrink-0 text-muted-foreground/60" />
}
/>
<TooltipContent>
Private — only the owner can assign work
</TooltipContent>
</Tooltip>
)}
{/* Owner attribution — only set in "All" scope when the agent
isn't yours. Tiny avatar (14px) keeps it lightweight; the
hover card on the agent's main avatar already covers the
full owner detail. */}
{ownerIdToShow && (
<ActorAvatar
actorType="member"
actorId={ownerIdToShow}
size={14}
/>
)}
{isArchived && (
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Archived
</span>
)}
</div>
<div
className={`mt-0.5 line-clamp-1 text-xs ${
agent.description
? "text-muted-foreground"
: "italic text-muted-foreground/50"
}`}
>
{agent.description || "No description"}
</div>
</div>
{/* Status — availability dimension only. Compact dot under md;
dot + label at md+. Always 3 colours. */}
<div className="flex items-center">
{isArchived ? (
<span className="text-xs text-muted-foreground">—</span>
) : (
<>
<span className="md:hidden">
<AgentPresenceIndicator detail={presence} compact />
</span>
<span className="hidden md:inline-flex">
<AvailabilityCell presence={presence} />
</span>
</>
)}
</div>
{/* Last run — md+. Task icon + label + (running counts | reason | time).
Dedicated column so the user can scan "what just happened" without
merging it into the availability cell. */}
<div className="hidden items-center md:flex">
{isArchived ? (
<span className="text-xs text-muted-foreground">—</span>
) : (
<LastRunCell presence={presence} />
)}
</div>
{/* Runtime — md+. Sans-font label fits ~25% more chars per pixel than
the previous mono treatment, so most hostnames no longer truncate. */}
<div className="hidden min-w-0 items-center gap-1.5 text-xs text-muted-foreground md:flex">
<RuntimeIcon className="h-3 w-3 shrink-0" />
<Tooltip>
<TooltipTrigger
render={<span className="min-w-0 truncate">{runtimeLabel}</span>}
/>
<TooltipContent>{runtimeLabel}</TooltipContent>
</Tooltip>
</div>
{/* Activity (7d) — lg+. The 7d sparkline is sliced off the 30d
workspace cache here (single source of truth shared with the
detail panel); no extra request. */}
<div className="hidden lg:block">
{isArchived ? (
<span className="text-xs text-muted-foreground/50">—</span>
) : !activity ? (
<span
className="inline-block animate-pulse rounded bg-muted/60"
style={{ width: 64, height: 20 }}
/>
) : (
<Tooltip>
<TooltipTrigger
render={
<div className="inline-flex cursor-default items-center">
<Sparkline
buckets={summarizeActivityWindow(activity, 7).buckets}
width={64}
height={20}
/>
</div>
}
/>
<TooltipContent>
<ActivityTooltip activity={activity} />
</TooltipContent>
</Tooltip>
)}
</div>
{/* Runs (30d) — md+. tabular-nums + right-align keeps the digit column
visually clean across orders of magnitude. */}
<div className="hidden text-right font-mono text-xs tabular-nums text-muted-foreground md:block">
{runCount == null ? "—" : runCount.toLocaleString()}
</div>
{/* Actions — kebab dropdown, always visible. Empty cell is preserved
(the column has fixed width) when the user has no operable
actions, so column alignment stays stable across rows. */}
<div className="flex justify-end">
<AgentRowActions
agent={agent}
presence={presence}
canManage={canManage}
onDuplicate={onDuplicate}
/>
</div>
</AppLink>
);
}
// Availability cell — dot + label, colour from availabilityConfig. Three
// states only; the colour reflects "can the agent take work right now".
function AvailabilityCell({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
if (!presence) {
return <span className="inline-flex h-3 w-16 animate-pulse rounded bg-muted/60" />;
}
const av = availabilityConfig[presence.availability];
return (
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
</span>
);
}
// Last-run cell — task icon + label, with running counts only when active.
// No timestamp: it eats horizontal space and the relative age of a
// completed task isn't actionable in a scan. Hidden when idle so brand-
// new agents show "—" instead of "Idle" everywhere.
function LastRunCell({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
if (!presence) {
return <span className="inline-flex h-3 w-20 animate-pulse rounded bg-muted/60" />;
}
if (presence.lastTask === "idle") {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
const ts = taskStateConfig[presence.lastTask];
const isRunning = presence.lastTask === "running";
const counts =
isRunning && presence.queuedCount > 0
? `${presence.runningCount}/${presence.capacity} +${presence.queuedCount}q`
: isRunning
? `${presence.runningCount}/${presence.capacity}`
: null;
return (
<span className="inline-flex min-w-0 items-center gap-1 text-xs">
<ts.icon className={`h-3 w-3 shrink-0 ${ts.textClass}`} />
<span className={`shrink-0 ${ts.textClass}`}>{ts.label}</span>
{counts && (
<span className="truncate text-muted-foreground">{counts}</span>
)}
</span>
);
}
/**
* Tooltip body for the activity sparkline. Header = window label, body =
* the actual counts (rolled up from the same 7-day slice the bars
* render). Two short lines so the eye lands on the numbers immediately
* on hover.
*/
function ActivityTooltip({ activity }: { activity: AgentActivity }) {
const summary = summarizeActivityWindow(activity, 7);
const { totalRuns, totalFailed } = summary;
const { daysSinceCreated } = activity;
// Header: when the agent is younger than the rendered window, label by
// age so an empty sparkline reads as "new agent" not "broken agent".
const isPartial = daysSinceCreated < 7;
const headerText = isPartial
? `Created ${daysSinceCreated === 0 ? "today" : `${daysSinceCreated} day${daysSinceCreated === 1 ? "" : "s"} ago`}`
: "Last 7 days";
let bodyText: string;
if (totalRuns === 0) {
bodyText = "No activity";
} else {
const failedFragment =
totalFailed > 0
? ` · ${totalFailed} failed (${Math.round((totalFailed / totalRuns) * 100)}%)`
: "";
bodyText = `${totalRuns} run${totalRuns === 1 ? "" : "s"}${failedFragment}`;
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{headerText}
</span>
<span className="text-xs">{bodyText}</span>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertCircle,
ArrowLeft,
@@ -10,6 +10,7 @@ import {
Search,
} from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import type { Agent, AgentRuntime, CreateAgentRequest } from "@multica/core/types";
import {
type AgentAvailability,
@@ -43,7 +44,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { DataTable } from "@multica/ui/components/ui/data-table";
import { useNavigation } from "../../navigation";
import { PageHeader } from "../../layout/page-header";
import {
@@ -53,7 +54,7 @@ import {
taskStateConfig,
} from "../presence";
import { CreateAgentDialog } from "./create-agent-dialog";
import { AGENT_LIST_GRID, AgentListItem } from "./agent-list-item";
import { type AgentRow, createAgentColumns } from "./agent-columns";
// Filter axes layered top → bottom by frequency:
//
@@ -143,9 +144,6 @@ export function AgentsPage() {
null,
);
const scrollRef = useRef<HTMLDivElement>(null);
const fadeStyle = useScrollFade(scrollRef);
const runtimesById = useMemo(() => {
const m = new Map<string, AgentRuntime>();
for (const r of runtimes) m.set(r.id, r);
@@ -353,10 +351,56 @@ export function AgentsPage() {
navigation.push(paths.agentDetail(agent.id));
};
const handleDuplicate = (agent: Agent) => {
const handleDuplicate = useCallback((agent: Agent) => {
setDuplicateTemplate(agent);
setShowCreate(true);
};
}, []);
// Assemble per-row data once per render — agent + runtime + presence +
// activity + role flags. The columns reach into `row.original` and never
// pull their own queries, which keeps each cell a pure function.
const agentRows = useMemo<AgentRow[]>(() => {
return sortedAgents.map((agent) => {
const isOwner =
!!currentUser?.id && agent.owner_id === currentUser.id;
const canManage = isWorkspaceAdmin || isOwner;
const ownerIdToShow =
scope === "all" &&
agent.owner_id &&
agent.owner_id !== currentUser?.id
? agent.owner_id
: null;
return {
agent,
runtime: runtimesById.get(agent.runtime_id) ?? null,
presence: presenceMap.get(agent.id) ?? null,
activity: activityMap.get(agent.id) ?? null,
runCount: runCountsById.get(agent.id) ?? 0,
ownerIdToShow,
canManage,
};
});
}, [
sortedAgents,
currentUser,
isWorkspaceAdmin,
scope,
runtimesById,
presenceMap,
activityMap,
runCountsById,
]);
const columns = useMemo(
() => createAgentColumns({ onDuplicate: handleDuplicate }),
[handleDuplicate],
);
const table = useReactTable({
data: agentRows,
columns,
getCoreRowModel: getCoreRowModel(),
});
// ---- Loading ----
if (isLoading) {
@@ -474,67 +518,12 @@ export function AgentsPage() {
scope={scope}
/>
) : (
<div
ref={scrollRef}
style={fadeStyle}
className="flex-1 min-h-0 overflow-y-auto"
>
{/*
Layout strategy — CSS Grid + `max-content` on Status, ratio
fr's elsewhere. The Status column shrinks to fit when no
agent is in a high-load Working state, and only widens
when the data demands it; the freed space flows into the
Agent (1.6fr) primary column. See AGENT_LIST_GRID for the
full breakpoint ladder. Sticky header reuses the same grid
template so column edges align with rows pixel-for-pixel.
*/}
<div
role="row"
className={`${AGENT_LIST_GRID} sticky top-0 z-10 border-b bg-muted/30 px-4 py-2 text-xs font-medium uppercase tracking-wider text-muted-foreground backdrop-blur`}
>
{/* Avatar leading slot — empty header cell so the "Agent"
label below aligns with the row's name text, not the
avatar's left edge. */}
<span aria-hidden />
<span>Agent</span>
<span>Status</span>
<span className="hidden md:block">Last run</span>
<span className="hidden md:block">Runtime</span>
<span className="hidden lg:block">Activity (7d)</span>
<span className="hidden text-right md:block">Runs</span>
{/* Operations column header — kept silent; the kebab
cell speaks for itself. */}
<span aria-label="Actions" />
</div>
{sortedAgents.map((agent) => {
const isOwner =
!!currentUser?.id && agent.owner_id === currentUser.id;
const canManage = isWorkspaceAdmin || isOwner;
// Inline owner avatar only in All scope on a teammate's
// agent — Mine scope means owner is always you, so a
// self-avatar everywhere would be visual noise.
const ownerIdToShow =
scope === "all" &&
agent.owner_id &&
agent.owner_id !== currentUser?.id
? agent.owner_id
: null;
return (
<AgentListItem
key={agent.id}
agent={agent}
runtime={runtimesById.get(agent.runtime_id) ?? null}
presence={presenceMap.get(agent.id) ?? null}
activity={activityMap.get(agent.id) ?? null}
runCount={runCountsById.get(agent.id) ?? 0}
ownerIdToShow={ownerIdToShow}
canManage={canManage}
onDuplicate={handleDuplicate}
href={paths.agentDetail(agent.id)}
/>
);
})}
</div>
<DataTable
table={table}
onRowClick={(row) =>
navigation.push(paths.agentDetail(row.original.agent.id))
}
/>
)}
</div>
)}
+1
View File
@@ -85,6 +85,7 @@
},
"peerDependencies": {
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "catalog:",
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
@@ -0,0 +1,527 @@
"use client";
import { useMemo, useState } from "react";
import {
ArrowUpCircle,
Cloud,
Monitor,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
import {
deriveRuntimeHealth,
runtimeUsageOptions,
} from "@multica/core/runtimes";
import { useDeleteRuntime } from "@multica/core/runtimes/mutations";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { HealthIcon, healthLabel } from "./shared";
import {
computeCostInWindow,
formatLastSeen,
isVersionNewer,
pctChange,
} from "../utils";
// Per-row data assembled at the page level. The columns reach into
// `row.original` and never pull their own data — except for the per-runtime
// usage query in CostCell, which intentionally keeps its 180-day cache key
// shared with the runtime-detail page (clicking a row pre-warms detail).
export interface RuntimeRow {
runtime: AgentRuntime;
ownerMember: MemberWithUser | null;
workload: { agentIds: string[]; runningCount: number; queuedCount: number };
canDelete: boolean;
}
const COL_WIDTHS = {
runtime: 280,
health: 200,
owner: 60,
agents: 120,
active: 100,
cost: 120,
cli: 160,
actions: 48,
} as const;
interface CreateColumnsArgs {
showOwner: boolean;
latestCliVersion: string | null;
wsId: string;
now: number;
}
export function createRuntimeColumns({
showOwner,
latestCliVersion,
wsId,
now,
}: CreateColumnsArgs): ColumnDef<RuntimeRow>[] {
const cols: ColumnDef<RuntimeRow>[] = [
{
id: "runtime",
header: "Runtime",
size: COL_WIDTHS.runtime,
cell: ({ row }) => <RuntimeNameCell runtime={row.original.runtime} />,
},
{
id: "health",
header: "Health",
size: COL_WIDTHS.health,
cell: ({ row }) => (
<HealthCell runtime={row.original.runtime} now={now} />
),
},
];
if (showOwner) {
cols.push({
id: "owner",
header: "Owner",
size: COL_WIDTHS.owner,
cell: ({ row }) =>
row.original.ownerMember ? (
<ActorAvatar
actorType="member"
actorId={row.original.ownerMember.user_id}
size={18}
/>
) : (
<span className="text-xs text-muted-foreground/50">—</span>
),
});
}
cols.push(
{
id: "agents",
header: "Agents",
size: COL_WIDTHS.agents,
cell: ({ row }) => (
<AgentStack agentIds={row.original.workload.agentIds} />
),
},
{
id: "active",
header: () => <div className="text-right">Active</div>,
size: COL_WIDTHS.active,
cell: ({ row }) => {
const health = deriveRuntimeHealth(row.original.runtime, now);
const offline = health === "offline" || health === "about_to_gc";
return (
<ActiveCell
running={row.original.workload.runningCount}
queued={row.original.workload.queuedCount}
offline={offline}
/>
);
},
},
{
id: "cost",
header: () => <div className="text-right">Cost · 7d</div>,
size: COL_WIDTHS.cost,
cell: ({ row }) => <CostCell runtimeId={row.original.runtime.id} />,
},
{
id: "cli",
header: "CLI",
size: COL_WIDTHS.cli,
cell: ({ row }) => (
<CliCell
runtime={row.original.runtime}
latestCliVersion={latestCliVersion}
/>
),
},
{
id: "actions",
header: () => null,
size: COL_WIDTHS.actions,
cell: ({ row }) => (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowMenu
runtime={row.original.runtime}
wsId={wsId}
canDelete={row.original.canDelete}
/>
</div>
),
},
);
return cols;
}
// ---------------------------------------------------------------------------
// 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);
return (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-card">
{runtime.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium">{baseName}</span>
{hostname && (
<Tooltip>
<TooltipTrigger
render={
<span className="truncate text-xs text-muted-foreground/70">
({hostname})
</span>
}
/>
<TooltipContent>{hostname}</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
}
function HealthCell({
runtime,
now,
}: {
runtime: AgentRuntime;
now: number;
}) {
const health = deriveRuntimeHealth(runtime, now);
const lastSeen = formatLastSeen(runtime.last_seen_at);
return (
<div className="flex items-center gap-1.5">
<HealthIcon health={health} />
<span className="truncate text-sm">
{healthLabel(health)}
{health !== "online" && runtime.last_seen_at && (
<span className="text-muted-foreground"> · {lastSeen}</span>
)}
</span>
</div>
);
}
function ActiveCell({
running,
queued,
offline,
}: {
running: number;
queued: number;
offline: boolean;
}) {
if (offline) {
return (
<div className="text-right">
<span className="text-xs text-muted-foreground/50">—</span>
</div>
);
}
if (running === 0 && queued === 0) {
return (
<div className="text-right">
<span className="text-sm text-muted-foreground">0</span>
</div>
);
}
return (
<div className="flex items-center justify-end gap-1.5 text-sm tabular-nums">
{running > 0 ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-brand" />
<span className="text-brand">{running}</span>
</>
) : (
<span className="text-muted-foreground">0</span>
)}
{queued > 0 && <span className="text-xs text-warning">+{queued}q</span>}
</div>
);
}
// Per-row cost — fetches the same 180-day usage window used by the
// runtime-detail page so clicking a row makes detail render instantly from
// cache. Cold load incurs N parallel requests, but each is cheap and they
// all share the same query key.
function CostCell({ runtimeId }: { runtimeId: string }) {
const { data: usage = [] } = useQuery(runtimeUsageOptions(runtimeId, 180));
const cost7d = useMemo(() => computeCostInWindow(usage, 7), [usage]);
const costPrev7d = useMemo(
() => computeCostInWindow(usage, 7, 7),
[usage],
);
const delta = pctChange(cost7d, costPrev7d);
if (usage.length === 0) {
return (
<div className="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
? "flat"
: `${delta > 0 ? "↑" : "↓"}${Math.abs(delta)}%`;
return (
<div className="flex 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>
);
}
function CliCell({
runtime,
latestCliVersion,
}: {
runtime: AgentRuntime;
latestCliVersion: string | null;
}) {
if (runtime.runtime_mode === "cloud") {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
const meta = runtime.metadata as Record<string, unknown> | null;
const cliVersion =
meta && typeof meta.cli_version === "string" ? meta.cli_version : null;
const launchedBy =
meta && typeof meta.launched_by === "string" ? meta.launched_by : null;
const isManaged = launchedBy === "desktop";
if (!cliVersion) {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
// Desktop-managed daemons can never self-update from this page (the
// Electron app ships and replaces the binary), so the upgrade marker
// would lie — suppress regardless of version comparison.
const hasUpdate =
!isManaged &&
!!latestCliVersion &&
isVersionNewer(latestCliVersion, cliVersion);
return (
<div className="flex min-w-0 items-center gap-1 text-xs">
{isManaged && (
<span className="shrink-0 rounded-sm bg-muted px-1 py-0.5 text-[10px] font-medium text-muted-foreground">
Desktop
</span>
)}
<span
className={`truncate font-mono ${
hasUpdate ? "text-warning" : "text-muted-foreground"
}`}
>
{cliVersion}
</span>
{hasUpdate && latestCliVersion && (
<Tooltip>
<TooltipTrigger
render={
<ArrowUpCircle
className="h-3 w-3 shrink-0 text-warning"
aria-label="Update available"
/>
}
/>
<TooltipContent>
Update available: {latestCliVersion}
</TooltipContent>
</Tooltip>
)}
</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={22}
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>
);
}
function RowMenu({
runtime,
wsId,
canDelete,
}: {
runtime: AgentRuntime;
wsId: string;
canDelete: boolean;
}) {
const deleteMutation = useDeleteRuntime(wsId);
const [deleteOpen, setDeleteOpen] = useState(false);
if (!canDelete) {
return <span aria-hidden />;
}
const handleDelete = () => {
deleteMutation.mutate(runtime.id, {
onSuccess: () => {
toast.success("Runtime deleted");
setDeleteOpen(false);
},
onError: (e) => {
toast.error(
e instanceof Error ? e.message : "Failed to delete runtime",
);
},
});
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Row actions"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog
open={deleteOpen}
onOpenChange={(v) => {
if (deleteMutation.isPending) return;
setDeleteOpen(v);
}}
>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Runtime</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &ldquo;{runtime.name}&rdquo;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,66 +1,26 @@
"use client";
import { useMemo, useRef, useState } from "react";
import {
ArrowUpCircle,
MoreHorizontal,
Trash2,
Cloud,
Monitor,
} from "lucide-react";
import { toast } from "sonner";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { AgentRuntime, AgentTask, Agent, MemberWithUser } from "@multica/core/types";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import type {
Agent,
AgentRuntime,
AgentTask,
MemberWithUser,
} 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 {
deriveRuntimeHealth,
runtimeUsageOptions,
latestCliVersionOptions,
} from "@multica/core/runtimes";
import { useDeleteRuntime } from "@multica/core/runtimes/mutations";
agentListOptions,
memberListOptions,
} from "@multica/core/workspace/queries";
import { latestCliVersionOptions } from "@multica/core/runtimes";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { paths, useWorkspaceSlug } from "@multica/core/paths";
import { AppLink } from "../../navigation";
import { ActorAvatar } from "../../common/actor-avatar";
import { Button } from "@multica/ui/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { HealthIcon, healthLabel } from "./shared";
import {
formatLastSeen,
computeCostInWindow,
pctChange,
isVersionNewer,
} from "../utils";
// ---------------------------------------------------------------------------
// 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 — zero extra requests.
// ---------------------------------------------------------------------------
import { DataTable } from "@multica/ui/components/ui/data-table";
import { useNavigation } from "../../navigation";
import { type RuntimeRow, createRuntimeColumns } from "./runtime-columns";
interface RuntimeWorkload {
agentIds: string[];
@@ -74,6 +34,10 @@ const EMPTY_WORKLOAD: RuntimeWorkload = {
queuedCount: 0,
};
// 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.
function buildWorkloadIndex(
agents: Agent[],
tasks: AgentTask[],
@@ -105,89 +69,43 @@ function buildWorkloadIndex(
return result;
}
// ---------------------------------------------------------------------------
// Grid template. Two variants because the Owner column only earns its keep
// in workspaces with multiple runtime owners — single-owner workspaces just
// see an entire column of identical avatars, which is noise. We pick at
// list-mount time and pass the chosen template down so the header and every
// row stay byte-identically aligned.
//
// Leading-visual columns are dedicated tracks so header labels sit on the
// same x as the row text, with no per-cell padding hacks. Same pattern as
// Agents (avatar) and Skills (source icon).
//
// Width choices (left → right):
// IconBox: 2rem — 32px Cloud / Monitor mode badge
// Runtime: minmax(0,1fr) — primary, fills remainder, truncates first
// HealthIcon: 0.875rem — 12px wifi-style icon
// Health: minmax(0,1fr) — text label, can shrink + truncate at
// narrow widths so it never overlaps the
// Runtime column. ("Recently lost · 2m
// 14s ago" is the worst case; the long
// suffix truncates first.)
// Owner: 2rem — single 18px avatar (multi-owner only)
// Agents: 6rem — 3 avatars overlapped + "+N" pill
// Active: 5rem — "2 +5q" worst case, right-aligned
// Cost: 7rem — "$879.00" + "↑18%" stacked, right-aligned
// CLI: 8rem — "Desktop · v0.2.17" worst case
// Kebab: 2.5rem — icon button
//
// Switched Health from a fixed 9.5rem to minmax(0,1fr) so it competes
// fairly with Runtime under width pressure. The previous fixed-width
// reservation guaranteed the worst-case copy fit, but at intermediate
// viewports the Runtime column was squeezed below its content width and
// overflowed visually.
// ---------------------------------------------------------------------------
const GRID_WITH_OWNER =
"grid items-center gap-4 " +
"grid-cols-[2rem_minmax(0,1fr)_0.875rem_minmax(0,1fr)_2rem_2.5rem] " +
"md:grid-cols-[2rem_minmax(0,1fr)_0.875rem_minmax(0,1fr)_2rem_6rem_5rem_7rem_2.5rem] " +
"lg:grid-cols-[2rem_minmax(0,1.2fr)_0.875rem_minmax(0,1fr)_2rem_6rem_5rem_7rem_8rem_2.5rem]";
const GRID_NO_OWNER =
"grid items-center gap-4 " +
"grid-cols-[2rem_minmax(0,1fr)_0.875rem_minmax(0,1fr)_2.5rem] " +
"md:grid-cols-[2rem_minmax(0,1fr)_0.875rem_minmax(0,1fr)_6rem_5rem_7rem_2.5rem] " +
"lg:grid-cols-[2rem_minmax(0,1.2fr)_0.875rem_minmax(0,1fr)_6rem_5rem_7rem_8rem_2.5rem]";
export function RuntimeList({
runtimes,
updatableIds,
now,
}: {
runtimes: AgentRuntime[];
// Kept on the API surface for callers (currently a noop here — the CLI
// column re-derives from `metadata.cli_version` + the GitHub-release query
// so per-row state is co-located. Removing it would break the page-level
// wrapper that still computes the set; left in place to avoid scope creep.)
// Kept on the API surface for callers — the CLI column re-derives
// update state per row via metadata.cli_version + the GitHub-release
// query, so this prop is now unused. Left to avoid scope creep on the
// page-level wrapper that still computes the set.
updatableIds?: Set<string>;
now: number;
}) {
// Kept to avoid an unused-prop lint; the data path is now per-row.
void updatableIds;
const wsId = useWorkspaceId();
const slug = useWorkspaceSlug();
const navigation = useNavigation();
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: latestCliVersion = null } = useQuery(latestCliVersionOptions());
const currentMember = user
? members.find((m) => m.user_id === user.id)
: null;
const isAdmin = currentMember
? currentMember.role === "owner" || currentMember.role === "admin"
: false;
// Snapshot also includes each agent's latest terminal task; the workload
// index iterates by status and only counts active ones, so terminal rows
// are silently ignored.
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const { data: latestCliVersion = null } = useQuery(latestCliVersionOptions());
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);
@@ -195,7 +113,8 @@ export function RuntimeList({
}, [members]);
// Owner column only earns its space when the page actually has multiple
// distinct owners; otherwise it's a column of identical avatars.
// 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) {
@@ -204,469 +123,43 @@ export function RuntimeList({
return owners.size > 1;
}, [runtimes]);
const gridClass = showOwner ? GRID_WITH_OWNER : GRID_NO_OWNER;
const rows = useMemo<RuntimeRow[]>(() => {
return runtimes.map((runtime) => ({
runtime,
ownerMember: runtime.owner_id
? memberById.get(runtime.owner_id) ?? null
: null,
workload: workloadIndex.get(runtime.id) ?? EMPTY_WORKLOAD,
canDelete: isAdmin || (!!user && runtime.owner_id === user.id),
}));
}, [runtimes, memberById, workloadIndex, isAdmin, user]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const fadeStyle = useScrollFade(scrollRef);
const columns = useMemo(
() =>
createRuntimeColumns({
showOwner,
latestCliVersion,
wsId,
now,
}),
[showOwner, latestCliVersion, wsId, now],
);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div
ref={scrollRef}
style={fadeStyle}
className="flex-1 min-h-0 overflow-y-auto"
>
<div
role="row"
className={`${gridClass} sticky top-0 z-10 border-b bg-muted/30 px-4 py-2 text-xs font-medium uppercase tracking-wider text-muted-foreground backdrop-blur`}
>
{/* Icon-box leading slot — empty in header so "Runtime" aligns with
the row's name text rather than the mode-badge edge. */}
<span aria-hidden />
<span>Runtime</span>
{/* Health-dot leading slot — empty in header so "Health" aligns
with the row's status text rather than the dot edge. */}
<span aria-hidden />
<span>Health</span>
{showOwner && <span className="hidden md:block">Owner</span>}
<span className="hidden md:block">Agents</span>
<span className="hidden text-right md:block">Active</span>
<span className="hidden text-right md:block">Cost · 7d</span>
<span className="hidden lg:block">CLI</span>
<span aria-label="Actions" />
</div>
{runtimes.map((runtime) => (
<RuntimeRow
key={runtime.id}
runtime={runtime}
now={now}
gridClass={gridClass}
showOwner={showOwner}
workload={workloadIndex.get(runtime.id) ?? EMPTY_WORKLOAD}
ownerMember={
runtime.owner_id ? memberById.get(runtime.owner_id) ?? null : null
}
latestCliVersion={latestCliVersion}
href={slug ? paths.workspace(slug).runtimeDetail(runtime.id) : "#"}
wsId={wsId}
canDelete={
isAdmin || (!!user && runtime.owner_id === user.id)
}
/>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Backend formats `runtime.name` as `"<base> (<hostname>)"` (e.g.
// "Claude (qingnaiyuandebijibendiannao-8.local)"). Every runtime on the same
// machine repeats the same hostname suffix, so it carries near-zero scan
// value once the eye has seen it on the first row — yet it dominates the
// column width by character count. Split it so the base name stays
// emphasised and the hostname renders muted + truncates first when the cell
// is tight. Falls back to the raw name when the format doesn't match.
// ---------------------------------------------------------------------------
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] };
}
function RuntimeRow({
runtime,
now,
gridClass,
showOwner,
workload,
ownerMember,
latestCliVersion,
href,
wsId,
canDelete,
}: {
runtime: AgentRuntime;
now: number;
gridClass: string;
showOwner: boolean;
workload: RuntimeWorkload;
ownerMember: MemberWithUser | null;
latestCliVersion: string | null;
href: string;
wsId: string;
canDelete: boolean;
}) {
const health = deriveRuntimeHealth(runtime, now);
const lastSeen = formatLastSeen(runtime.last_seen_at);
const { base: baseName, hostname } = splitRuntimeName(runtime.name);
const isOffline = health === "offline" || health === "about_to_gc";
// Per-row cost fetch reuses the SAME cache key as the runtime-detail page
// (`runtimeUsageOptions(rid, 180)`). On cold load that's N parallel
// requests, but every one of them populates the same cache the detail
// page reads — so clicking a row to drill in is instant.
const { data: usage = [] } = useQuery(runtimeUsageOptions(runtime.id, 180));
const cost7d = useMemo(() => computeCostInWindow(usage, 7), [usage]);
const costPrev7d = useMemo(() => computeCostInWindow(usage, 7, 7), [usage]);
const costDelta = pctChange(cost7d, costPrev7d);
return (
<AppLink
href={href}
className={`${gridClass} group border-b px-4 py-3 text-sm transition-colors last:border-b-0 hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none`}
>
{/* MODE BADGE — leading column so the "Runtime" header aligns with the
name text rather than the badge edge. */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-card">
{runtime.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* RUNTIME — base name + (hostname). Both can truncate at narrow
widths so the cell never overflows into the adjacent Health
column. The base name takes precedence (truncate later) by
virtue of being the first child; the hostname (more disposable)
shrinks first. Provider lives in the right-side CLI column. */}
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate text-sm font-medium">{baseName}</span>
{hostname && (
<span className="min-w-0 truncate text-xs text-muted-foreground/70">
({hostname})
</span>
)}
</div>
{/* HEALTH icon — wifi-style indicator carries both shape (Wifi / WifiOff)
and colour (success / warning / muted / destructive) for the four
health buckets. Leading column for the same alignment reason as
the icon-box on the left. */}
<HealthIcon health={health} />
{/* HEALTH label — last_seen folded in only when it carries information
(everything except the always-"Just now" online case). */}
<span className="min-w-0 truncate text-sm">
{healthLabel(health)}
{health !== "online" && runtime.last_seen_at && (
<span className="text-muted-foreground"> · {lastSeen}</span>
)}
</span>
{/* OWNER — small avatar only; name lives in the hover card. Conditional
on the workspace having more than one distinct owner. */}
{showOwner && (
<div className="hidden md:flex">
{ownerMember ? (
<ActorAvatar
actorType="member"
actorId={ownerMember.user_id}
size={18}
/>
) : (
<span className="text-xs text-muted-foreground/50">—</span>
)}
</div>
)}
{/* AGENTS — avatar stack (3 + "+N"). */}
<div className="hidden md:block">
<AgentStack agentIds={workload.agentIds} />
</div>
{/* ACTIVE — running count is the headline (brand colour) when active;
queued count piggy-backs as a smaller "+Nq" suffix when non-zero.
Offline runtimes show "—" instead of "0" because zero on an offline
row is a tautology, not information. */}
<div className="hidden text-right md:block">
<ActiveCell
running={workload.runningCount}
queued={workload.queuedCount}
offline={isOffline}
/>
</div>
{/* COST · 7D — main number + trend delta stacked. The 7d window is
fixed (vs the period selector on the detail page) so the column
stays comparable across rows. */}
<div className="hidden text-right md:block">
<CostCell cost={cost7d} delta={costDelta} hasData={usage.length > 0} />
</div>
{/* CLI — source badge + version + ↑ marker if behind. Pure status,
no clickable upgrade button — the actual upgrade lives on the
detail page where polling/error state has room to breathe. */}
<div className="hidden lg:block">
<CliCell runtime={runtime} latestCliVersion={latestCliVersion} />
</div>
{/* ⋯ menu */}
<div className="flex justify-end">
<RowMenu runtime={runtime} wsId={wsId} canDelete={canDelete} />
</div>
</AppLink>
);
}
function ActiveCell({
running,
queued,
offline,
}: {
running: number;
queued: number;
offline: boolean;
}) {
if (offline) {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
if (running === 0 && queued === 0) {
return <span className="text-sm text-muted-foreground">0</span>;
}
return (
<div className="flex items-center justify-end gap-1.5 text-sm tabular-nums">
{running > 0 ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-brand" />
<span className="text-brand">{running}</span>
</>
) : (
<span className="text-muted-foreground">0</span>
)}
{queued > 0 && (
<span className="text-xs text-warning">+{queued}q</span>
)}
</div>
);
}
function CostCell({
cost,
delta,
hasData,
}: {
cost: number;
delta: number | null;
hasData: boolean;
}) {
if (!hasData) {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
const fmt = cost >= 100 ? `$${cost.toFixed(0)}` : `$${cost.toFixed(2)}`;
// Tone choice: rising cost is a "watch out" signal (warning), falling is
// good news (success), no comparable prior is silent.
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
? "flat"
: `${delta > 0 ? "↑" : "↓"}${Math.abs(delta)}%`;
return (
<div className="flex 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>
);
}
function CliCell({
runtime,
latestCliVersion,
}: {
runtime: AgentRuntime;
latestCliVersion: string | null;
}) {
if (runtime.runtime_mode === "cloud") {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
const meta = runtime.metadata as Record<string, unknown> | null;
const cliVersion =
meta && typeof meta.cli_version === "string" ? meta.cli_version : null;
const launchedBy =
meta && typeof meta.launched_by === "string" ? meta.launched_by : null;
const isManaged = launchedBy === "desktop";
if (!cliVersion) {
return <span className="text-xs text-muted-foreground/50">—</span>;
}
// Desktop-managed daemons can never self-update from this page (the
// Electron app ships and replaces the binary), so the "↑" upgrade marker
// would be a lie — suppress it regardless of version comparison.
const hasUpdate =
!isManaged &&
!!latestCliVersion &&
isVersionNewer(latestCliVersion, cliVersion);
return (
<div className="flex min-w-0 items-center gap-1 text-xs">
{isManaged && (
<span className="shrink-0 rounded-sm bg-muted px-1 py-0.5 text-[10px] font-medium text-muted-foreground">
Desktop
</span>
)}
<span
className={`min-w-0 truncate font-mono ${
hasUpdate ? "text-warning" : "text-muted-foreground"
}`}
>
{cliVersion}
</span>
{hasUpdate && latestCliVersion && (
<Tooltip>
<TooltipTrigger
render={
<ArrowUpCircle
className="h-3 w-3 shrink-0 text-warning"
aria-label="Update available"
/>
}
/>
<TooltipContent>
Update available: {latestCliVersion}
</TooltipContent>
</Tooltip>
)}
</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 — same hover affordance Agents/Skills already
// use, no per-row extra wiring.
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={22} 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>
);
}
function RowMenu({
runtime,
wsId,
canDelete,
}: {
runtime: AgentRuntime;
wsId: string;
canDelete: boolean;
}) {
const deleteMutation = useDeleteRuntime(wsId);
const [deleteOpen, setDeleteOpen] = useState(false);
if (!canDelete) {
// Keep the grid cell occupied so column alignment doesn't shift between
// rows the user can act on and rows they can't.
return <span aria-hidden />;
}
const handleDelete = () => {
deleteMutation.mutate(runtime.id, {
onSuccess: () => {
toast.success("Runtime deleted");
setDeleteOpen(false);
},
onError: (e) => {
toast.error(
e instanceof Error ? e.message : "Failed to delete runtime",
<DataTable
table={table}
onRowClick={(row) => {
if (!slug) return;
navigation.push(
paths.workspace(slug).runtimeDetail(row.original.runtime.id),
);
},
});
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Row actions"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40"
// Prevent the row's anchor navigation from firing if a click on a
// menu item somehow bubbles back through the portal.
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog
open={deleteOpen}
onOpenChange={(v) => {
if (deleteMutation.isPending) return;
setDeleteOpen(v);
}}
>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Runtime</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &ldquo;{runtime.name}&rdquo;? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
}}
/>
);
}
@@ -0,0 +1,200 @@
"use client";
import { Download, FileText, HardDrive, Lock, Pencil } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import type {
Agent,
AgentRuntime,
MemberWithUser,
Skill,
} from "@multica/core/types";
import { timeAgo } from "@multica/core/utils";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { readOrigin, totalFileCount } from "../lib/origin";
// Per-row data assembled at the page level. The columns reach into
// `row.original` and never pull their own queries.
export interface SkillRow {
skill: Skill;
agents: Agent[];
creator: MemberWithUser | null;
// Originating runtime when the skill was imported from a runtime-local
// store; null for manually-created or remotely-sourced skills.
runtime: AgentRuntime | null;
canEdit: boolean;
}
const COL_WIDTHS = {
name: 320,
usedBy: 140,
source: 240,
updated: 100,
} as const;
export function createSkillColumns(): ColumnDef<SkillRow>[] {
return [
{
id: "name",
header: "Name",
size: COL_WIDTHS.name,
cell: ({ row }) => <SkillNameCell row={row.original} />,
},
{
id: "usedBy",
header: "Used by",
size: COL_WIDTHS.usedBy,
cell: ({ row }) => <AgentAssignees agents={row.original.agents} />,
},
{
id: "source",
header: "Source · Added by",
size: COL_WIDTHS.source,
cell: ({ row }) => (
<SourceCell
skill={row.original.skill}
creator={row.original.creator}
runtime={row.original.runtime}
/>
),
},
{
id: "updated",
header: "Updated",
size: COL_WIDTHS.updated,
cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{timeAgo(row.original.skill.updated_at)}
</span>
),
},
];
}
// ---------------------------------------------------------------------------
// Cell renderers
// ---------------------------------------------------------------------------
function SkillNameCell({ row }: { row: SkillRow }) {
const { skill, canEdit } = row;
return (
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{skill.name}</span>
{!canEdit && (
<Tooltip>
<TooltipTrigger
render={
<Lock className="h-3 w-3 shrink-0 text-muted-foreground/60" />
}
/>
<TooltipContent>
Read-only — only creator or admin can edit
</TooltipContent>
</Tooltip>
)}
<span className="inline-flex shrink-0 items-center gap-0.5 font-mono text-xs text-muted-foreground/70">
<FileText className="h-3 w-3" />
{totalFileCount(skill)}
</span>
</div>
<div
className={`mt-0.5 line-clamp-1 text-xs ${
skill.description
? "text-muted-foreground"
: "italic text-muted-foreground/50"
}`}
>
{skill.description || "No description"}
</div>
</div>
);
}
function AgentAssignees({ agents }: { agents: Agent[] }) {
if (agents.length === 0) {
return <span className="text-xs text-muted-foreground/70">— unused</span>;
}
const visible = agents.slice(0, 3);
const extra = agents.length - visible.length;
return (
<div className="flex items-center -space-x-1.5">
{visible.map((a) => (
<Tooltip key={a.id}>
<TooltipTrigger
render={
<span className="inline-flex rounded-full ring-2 ring-background">
<ActorAvatar
name={a.name}
initials={a.name.slice(0, 2).toUpperCase()}
avatarUrl={a.avatar_url}
isAgent
size={22}
/>
</span>
}
/>
<TooltipContent>{a.name}</TooltipContent>
</Tooltip>
))}
{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>
);
}
function SourceCell({
skill,
creator,
runtime,
}: {
skill: Skill;
creator: MemberWithUser | null;
runtime: AgentRuntime | null;
}) {
const origin = readOrigin(skill);
let icon = <Pencil className="h-3 w-3 shrink-0" />;
let label = "Created manually";
if (origin.type === "runtime_local") {
icon = <HardDrive className="h-3 w-3 shrink-0" />;
label = runtime
? `From ${runtime.name}`
: origin.provider
? `From ${origin.provider} runtime`
: "From a runtime";
} else if (origin.type === "clawhub") {
icon = <Download className="h-3 w-3 shrink-0" />;
label = "From ClawHub";
} else if (origin.type === "skills_sh") {
icon = <Download className="h-3 w-3 shrink-0" />;
label = "From Skills.sh";
}
return (
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span className="shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
{creator && (
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<ActorAvatar
name={creator.name}
initials={creator.name.slice(0, 2).toUpperCase()}
avatarUrl={creator.avatar_url}
size={14}
/>
<span className="truncate">by {creator.name}</span>
</div>
)}
</div>
);
}
+52 -256
View File
@@ -1,27 +1,20 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import {
AlertCircle,
AlertTriangle,
BookOpen,
ChevronRight,
Download,
FileText,
HardDrive,
Lock,
Pencil,
Plus,
Search,
} from "lucide-react";
import type {
Agent,
AgentRuntime,
MemberWithUser,
Skill,
} from "@multica/core/types";
import { useQuery } from "@tanstack/react-query";
import { timeAgo } from "@multica/core/utils";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
@@ -32,8 +25,8 @@ import {
skillListOptions,
} from "@multica/core/workspace/queries";
import { runtimeListOptions } from "@multica/core/runtimes";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import { Button } from "@multica/ui/components/ui/button";
import { DataTable } from "@multica/ui/components/ui/data-table";
import { Input } from "@multica/ui/components/ui/input";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
@@ -41,219 +34,15 @@ import {
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { AppLink, useNavigation } from "../../navigation";
import { useNavigation } from "../../navigation";
import { PageHeader } from "../../layout/page-header";
import { canEditSkill } from "../hooks/use-can-edit-skill";
import { readOrigin, totalFileCount } from "../lib/origin";
import { readOrigin } from "../lib/origin";
import { CreateSkillDialog } from "./create-skill-dialog";
import { type SkillRow, createSkillColumns } from "./skill-columns";
type FilterKey = "all" | "used" | "unused" | "mine";
// ---------------------------------------------------------------------------
// Source cell — "Source · Added by" column (order matches column header).
// ---------------------------------------------------------------------------
function SourceCell({
skill,
creator,
runtime,
}: {
skill: Skill;
creator: MemberWithUser | null;
runtime: AgentRuntime | null;
}) {
const origin = readOrigin(skill);
let icon = <Pencil className="h-3 w-3 shrink-0" />;
let label = "Created manually";
if (origin.type === "runtime_local") {
icon = <HardDrive className="h-3 w-3 shrink-0" />;
label = runtime
? `From ${runtime.name}`
: origin.provider
? `From ${origin.provider} runtime`
: "From a runtime";
} else if (origin.type === "clawhub") {
icon = <Download className="h-3 w-3 shrink-0" />;
label = "From ClawHub";
} else if (origin.type === "skills_sh") {
icon = <Download className="h-3 w-3 shrink-0" />;
label = "From Skills.sh";
}
// Two grid cells: leading icon track, then the text block. The icon needs
// `self-start` + a 1-line top inset so it lands on the top text row instead
// of the cell's vertical middle (the cell is 2-line when `creator` exists).
return (
<>
<span className="self-start pt-[3px] text-muted-foreground">{icon}</span>
<div className="min-w-0">
<div className="min-w-0 truncate text-xs text-muted-foreground">
{label}
</div>
{creator && (
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<ActorAvatar
name={creator.name}
initials={creator.name.slice(0, 2).toUpperCase()}
avatarUrl={creator.avatar_url}
size={14}
/>
<span className="min-w-0 truncate">by {creator.name}</span>
</div>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Agent avatar stack
// ---------------------------------------------------------------------------
function AgentAssignees({ agents }: { agents: Agent[] }) {
if (agents.length === 0) {
return <span className="text-xs text-muted-foreground/70">— unused</span>;
}
const visible = agents.slice(0, 3);
const extra = agents.length - visible.length;
return (
<div className="flex items-center -space-x-1.5">
{visible.map((a) => (
<Tooltip key={a.id}>
<TooltipTrigger
render={
<span className="inline-flex rounded-full ring-2 ring-background">
<ActorAvatar
name={a.name}
initials={a.name.slice(0, 2).toUpperCase()}
avatarUrl={a.avatar_url}
isAgent
size={22}
/>
</span>
}
/>
<TooltipContent>{a.name}</TooltipContent>
</Tooltip>
))}
{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>
);
}
// ---------------------------------------------------------------------------
// Row + header
// ---------------------------------------------------------------------------
// Source column has a leading 12px icon (HardDrive / Download / Pencil) that
// would otherwise push its text 18px right of the column header, so we extract
// the icon into its own 0.875rem track. `<SourceCell>` returns two cells (icon
// + body) and the header inserts a placeholder cell to match.
//
// Same pattern as Agents (avatar) and Runtimes (icon-box, Health dot) — header
// label and row primary text share the same x without per-column padding hacks.
// Responsive column strategy:
// <md → Name + Used by + Chevron (drop Source / Updated to free room)
// md+ → adds Source · Added by + Updated
// Source icon leading slot (0.875rem) is part of the Source group, hidden
// together at <md. Each visible column uses minmax(0,…fr) so it can shrink
// and the cell content (with min-w-0 + truncate) trims gracefully instead
// of overflowing into adjacent cells.
const ROW_GRID =
"grid items-center gap-4 " +
"grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_auto] " +
"md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_0.875rem_minmax(0,1.2fr)_minmax(0,6rem)_auto]";
function SkillRow({
skill,
agents,
creator,
runtime,
canEdit,
href,
}: {
skill: Skill;
agents: Agent[];
creator: MemberWithUser | null;
runtime: AgentRuntime | null;
canEdit: boolean;
href: string;
}) {
return (
<AppLink
href={href}
className={`group ${ROW_GRID} border-b px-4 py-3 text-sm transition-colors hover:bg-accent/60`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{skill.name}</span>
{!canEdit && (
<Tooltip>
<TooltipTrigger
render={
<Lock className="h-3 w-3 shrink-0 text-muted-foreground/60" />
}
/>
<TooltipContent>
Read-only — only creator or admin can edit
</TooltipContent>
</Tooltip>
)}
<span className="inline-flex shrink-0 items-center gap-0.5 font-mono text-xs text-muted-foreground/70">
<FileText className="h-3 w-3" />
{totalFileCount(skill)}
</span>
</div>
<div
className={`mt-0.5 line-clamp-1 text-xs ${
skill.description
? "text-muted-foreground"
: "italic text-muted-foreground/50"
}`}
>
{skill.description || "No description"}
</div>
</div>
<div className="min-w-0">
<AgentAssignees agents={agents} />
</div>
{/* Source group (icon + label) — md+. The icon is its own grid cell
for header alignment; both hide together at <md. */}
<span className="hidden md:contents">
<SourceCell skill={skill} creator={creator} runtime={runtime} />
</span>
<div className="hidden min-w-0 whitespace-nowrap text-xs text-muted-foreground md:block">
{timeAgo(skill.updated_at)}
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground" />
</AppLink>
);
}
function ListColumnHeader() {
return (
<div
className={`${ROW_GRID} shrink-0 border-b bg-muted/30 px-4 py-2 text-xs font-medium uppercase tracking-wider text-muted-foreground`}
>
<span>Name</span>
<span>Used by</span>
{/* Source icon leading slot — empty in header so the label below
aligns with the row's "From X" / "Created manually" text.
Hidden together with the Source label at <md. */}
<span aria-hidden className="hidden md:block" />
<span className="hidden md:block">Source · Added by</span>
<span className="hidden md:block">Updated</span>
<span className="w-4" />
</div>
);
}
// ---------------------------------------------------------------------------
// Scope tab — matches Issues/MyIssues header pattern
// ---------------------------------------------------------------------------
@@ -415,9 +204,6 @@ export default function SkillsPage() {
const [filter, setFilter] = useState<FilterKey>("all");
const [createOpen, setCreateOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const fadeStyle = useScrollFade(scrollRef);
// Derive assignments ONCE per agents-identity. Stable reference across
// unrelated agent refetches — see selectSkillAssignments' doc.
const assignments = useMemo(
@@ -464,6 +250,46 @@ export default function SkillsPage() {
navigation.push(paths.skillDetail(skill.id));
};
// Assemble per-row data once per render — skill + agents + creator +
// origin-runtime + permission flag. The table's column cells read off
// `row.original` and never pull their own queries.
const skillRows = useMemo<SkillRow[]>(() => {
return filtered.map((skill) => {
const origin = readOrigin(skill);
const runtime =
origin.type === "runtime_local" && origin.runtime_id
? runtimesById.get(origin.runtime_id) ?? null
: null;
return {
skill,
agents: assignments.get(skill.id) ?? [],
creator: skill.created_by
? membersById.get(skill.created_by) ?? null
: null,
runtime,
canEdit: canEditSkill(skill, {
userId: currentUserId,
role: myRole,
}),
};
});
}, [
filtered,
assignments,
membersById,
runtimesById,
currentUserId,
myRole,
]);
const columns = useMemo(() => createSkillColumns(), []);
const table = useReactTable({
data: skillRows,
columns,
getCoreRowModel: getCoreRowModel(),
});
// --- Loading ---
if (isLoading) {
return (
@@ -575,42 +401,12 @@ export default function SkillsPage() {
</p>
</div>
) : (
<>
<ListColumnHeader />
<div
ref={scrollRef}
style={fadeStyle}
className="flex-1 min-h-0 overflow-y-auto"
>
<div>
{filtered.map((skill) => {
const origin = readOrigin(skill);
const runtime =
origin.type === "runtime_local" && origin.runtime_id
? runtimesById.get(origin.runtime_id) ?? null
: null;
return (
<SkillRow
key={skill.id}
skill={skill}
agents={assignments.get(skill.id) ?? []}
creator={
skill.created_by
? membersById.get(skill.created_by) ?? null
: null
}
runtime={runtime}
canEdit={canEditSkill(skill, {
userId: currentUserId,
role: myRole,
})}
href={paths.skillDetail(skill.id)}
/>
);
})}
</div>
</div>
</>
<DataTable
table={table}
onRowClick={(row) =>
navigation.push(paths.skillDetail(row.original.skill.id))
}
/>
)}
</div>
)}
+28
View File
@@ -12,6 +12,9 @@ catalogs:
'@tanstack/react-query':
specifier: ^5.96.2
version: 5.96.2
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
@@ -525,6 +528,9 @@ importers:
'@emoji-mart/data':
specifier: ^1.2.1
version: 1.2.1
'@tanstack/react-table':
specifier: 'catalog:'
version: 8.21.3([email protected]([email protected]))([email protected])
class-variance-authority:
specifier: 'catalog:'
version: 0.7.1
@@ -652,6 +658,9 @@ importers:
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.96.2([email protected])
'@tanstack/react-table':
specifier: 'catalog:'
version: 8.21.3([email protected]([email protected]))([email protected])
'@tiptap/core':
specifier: ^3.22.1
version: 3.22.1(@tiptap/[email protected])
@@ -2607,6 +2616,17 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/[email protected]':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/[email protected]':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@testing-library/[email protected]':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -9192,6 +9212,14 @@ snapshots:
'@tanstack/query-core': 5.96.2
react: 19.2.3
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.2.3
react-dom: 19.2.3([email protected])
'@tanstack/[email protected]': {}
'@testing-library/[email protected]':
dependencies:
'@babel/code-frame': 7.29.0
+1
View File
@@ -16,6 +16,7 @@ catalog:
# State Management
zustand: "^5.0.0"
"@tanstack/react-query": "^5.96.2"
"@tanstack/react-table": "^8.21.3"
# UI & Styling
tailwindcss: "^4"