mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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:
co-authored by
Claude Opus 4.7
parent
f0c845b777
commit
2be0f28713
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 “{runtime.name}”?
|
||||
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 “{runtime.name}”? 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Generated
+28
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user