feat(ui): comprehensive UI consistency fixes and list view accordion redesign

- Runtime page: add ResizablePanelGroup with persistent layout, fix scroll
- Agents page: replace hand-rolled dropdowns with shadcn Popover/DropdownMenu,
  remove redundant wrapper div, fix header height to h-12
- Skills page: widen create dialog to sm:max-w-md, stabilize tab height
- Settings: use variant="destructive" on AlertDialogAction instead of hardcoded className
- Issues list view: rewrite with base-ui Accordion grouped by status,
  show all statuses (including empty), add per-group create button,
  persist expand/collapse state, apply sort settings
- Issues header: show filtered issue count next to New Issue button
- Extract shared sortIssues utility from board-column for reuse
- Remove redundant StatusIcon from ListRow (already grouped by status)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-28 16:18:42 +08:00
parent 1d08057dd8
commit 572d033b95
12 changed files with 358 additions and 224 deletions

View file

@ -48,6 +48,17 @@ import {
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -174,13 +185,10 @@ function CreateAgentDialog({
<div>
<Label className="text-xs text-muted-foreground">Runtime</Label>
<div className="relative mt-1.5">
<Button
type="button"
variant="outline"
onClick={() => setRuntimeOpen(!runtimeOpen)}
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
<PopoverTrigger
disabled={runtimes.length === 0}
className="flex w-full items-center gap-3 px-3 py-2.5 h-auto text-left text-sm"
className="flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{selectedRuntime?.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
@ -203,50 +211,44 @@ function CreateAgentDialog({
</div>
</div>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
</Button>
{runtimeOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setRuntimeOpen(false)} />
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border bg-popover p-1 shadow-md">
{runtimes.map((device) => (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
>
{device.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
{runtimes.map((device) => (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
>
{device.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
))}
</div>
</>
)}
</div>
</div>
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
))}
</PopoverContent>
</Popover>
</div>
</div>
@ -997,19 +999,18 @@ function AgentDetail({
const st = statusConfig[agent.status];
const runtimeDevice = getRuntimeDevice(agent, runtimes);
const [activeTab, setActiveTab] = useState<DetailTab>("skills");
const [showMenu, setShowMenu] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-start gap-4 border-b px-6 py-5">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-muted text-sm font-bold">
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-bold">
{getInitials(agent.name)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h2 className="text-base font-semibold truncate">{agent.name}</h2>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold truncate">{agent.name}</h2>
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
{st.label}
@ -1023,36 +1024,25 @@ function AgentDetail({
{runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")}
</span>
</div>
{agent.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{agent.description}</p>
)}
</div>
<div className="relative">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setShowMenu(!showMenu)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" />
}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 top-8 z-50 w-40 rounded-lg border bg-popover p-1 shadow-md">
<button
onClick={() => {
setShowMenu(false);
setConfirmDelete(true);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-accent"
>
<Trash2 className="h-3.5 w-3.5" />
Delete Agent
</button>
</div>
</>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tabs */}
@ -1252,29 +1242,27 @@ export default function AgentsPage() {
<ResizablePanel id="detail" minSize="50%">
{/* Right column — agent detail */}
<div className="flex-1 overflow-hidden h-full">
{selected ? (
<AgentDetail
agent={selected}
runtimes={runtimes}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Bot className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select an agent to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
)}
</div>
{selected ? (
<AgentDetail
agent={selected}
runtimes={runtimes}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Bot className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select an agent to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
</div>
)}
</ResizablePanel>
{showCreate && (

View file

@ -256,7 +256,7 @@ export function MembersTab() {
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={confirmAction?.variant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
variant={confirmAction?.variant === "destructive" ? "destructive" : "default"}
onClick={async () => {
await confirmAction?.onConfirm();
setConfirmAction(null);

View file

@ -311,7 +311,7 @@ export function WorkspaceTab() {
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={confirmAction?.variant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
variant={confirmAction?.variant === "destructive" ? "destructive" : "default"}
onClick={async () => {
await confirmAction?.onConfirm();
setConfirmAction(null);

View file

@ -13,39 +13,13 @@ import {
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG, PRIORITY_ORDER } from "@/features/issues/config";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useIssueViewStore, type SortField, type SortDirection } from "@/features/issues/stores/view-store";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { sortIssues } from "@/features/issues/utils/sort";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
const PRIORITY_RANK: Record<string, number> = Object.fromEntries(
PRIORITY_ORDER.map((p, i) => [p, i])
);
function sortIssues(issues: Issue[], field: SortField, direction: SortDirection): Issue[] {
const sorted = [...issues].sort((a, b) => {
switch (field) {
case "priority":
return (PRIORITY_RANK[a.priority] ?? 99) - (PRIORITY_RANK[b.priority] ?? 99);
case "due_date": {
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime();
}
case "created_at":
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
case "title":
return a.title.localeCompare(b.title);
case "position":
default:
return a.position - b.position;
}
});
return direction === "desc" ? sorted.reverse() : sorted;
}
export function BoardColumn({
status,
issues,

View file

@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import {
ArrowDown,
ArrowUp,
@ -11,6 +12,7 @@ import {
SlidersHorizontal,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useIssueStore } from "@/features/issues/store";
import {
DropdownMenu,
DropdownMenuTrigger,
@ -56,6 +58,21 @@ export function IssuesHeader() {
const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty);
const clearFilters = useIssueViewStore((s) => s.clearFilters);
const allIssues = useIssueStore((s) => s.issues);
const filteredCount = useMemo(() => {
return allIssues.filter((i) => {
if (statusFilters.length > 0 && !statusFilters.includes(i.status))
return false;
if (
priorityFilters.length > 0 &&
!priorityFilters.includes(i.priority)
)
return false;
return true;
}).length;
}, [allIssues, statusFilters, priorityFilters]);
const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
const hasActiveFilters =
@ -191,12 +208,14 @@ export function IssuesHeader() {
{/* Reset */}
{hasActiveFilters && (
<div className="px-3 py-2">
<button
className="text-xs text-muted-foreground hover:text-foreground"
<Button
variant="link"
size="xs"
className="text-muted-foreground hover:text-foreground"
onClick={clearFilters}
>
Reset filters
</button>
</Button>
</div>
)}
</PopoverContent>
@ -285,7 +304,10 @@ export function IssuesHeader() {
</Popover>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"}
</span>
{/* New issue */}
<Button
variant="outline"

View file

@ -130,7 +130,7 @@ export function IssuesPage() {
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} />
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
</div>

View file

@ -3,7 +3,6 @@
import Link from "next/link";
import type { Issue } from "@/shared/types";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";
function formatDate(date: string): string {
@ -23,7 +22,6 @@ export function ListRow({ issue }: { issue: Issue }) {
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.id.slice(0, 8)}
</span>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
{issue.due_date && (
<span className="shrink-0 text-xs text-muted-foreground">

View file

@ -1,34 +1,118 @@
"use client";
import type { Issue } from "@/shared/types";
import { STATUS_ORDER, STATUS_CONFIG } from "@/features/issues/config";
import { useMemo } from "react";
import { ChevronRight, Plus } from "lucide-react";
import { Accordion } from "@base-ui/react/accordion";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import type { Issue, IssueStatus } from "@/shared/types";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { sortIssues } from "@/features/issues/utils/sort";
import { StatusIcon } from "./status-icon";
import { ListRow } from "./list-row";
export function ListView({ issues }: { issues: Issue[] }) {
const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled");
export function ListView({
issues,
visibleStatuses,
}: {
issues: Issue[];
visibleStatuses: IssueStatus[];
}) {
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const listCollapsedStatuses = useIssueViewStore(
(s) => s.listCollapsedStatuses
);
const toggleListCollapsed = useIssueViewStore(
(s) => s.toggleListCollapsed
);
const issuesByStatus = useMemo(() => {
const map = new Map<IssueStatus, Issue[]>();
for (const status of visibleStatuses) {
const filtered = issues.filter((i) => i.status === status);
map.set(status, sortIssues(filtered, sortBy, sortDirection));
}
return map;
}, [issues, visibleStatuses, sortBy, sortDirection]);
const expandedStatuses = useMemo(
() =>
visibleStatuses.filter(
(s) => !listCollapsedStatuses.includes(s)
),
[visibleStatuses, listCollapsedStatuses]
);
return (
<div className="flex-1 min-h-0 overflow-y-auto">
{groupOrder.map((status) => {
const cfg = STATUS_CONFIG[status];
const filtered = issues.filter((i) => i.status === status);
if (filtered.length === 0) return null;
return (
<div key={status}>
<div className="flex h-12 items-center gap-2 border-b px-4">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">
{filtered.length}
</span>
</div>
{filtered.map((issue) => (
<ListRow key={issue.id} issue={issue} />
))}
</div>
);
})}
<div className="flex-1 min-h-0 overflow-y-auto p-2">
<Accordion.Root
multiple
className="space-y-1"
value={expandedStatuses}
onValueChange={(value: string[]) => {
for (const status of visibleStatuses) {
const wasExpanded = expandedStatuses.includes(status);
const isExpanded = value.includes(status);
if (wasExpanded !== isExpanded) {
toggleListCollapsed(status as IssueStatus);
}
}
}}
>
{visibleStatuses.map((status) => {
const cfg = STATUS_CONFIG[status];
const statusIssues = issuesByStatus.get(status) ?? [];
return (
<Accordion.Item key={status} value={status}>
<Accordion.Header className="group/header flex h-10 items-center rounded-lg bg-muted/40 transition-colors hover:bg-accent/30">
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-3 h-full text-left outline-none">
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm font-medium">{cfg.label}</span>
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-xs text-muted-foreground">
{statusIssues.length}
</span>
</Accordion.Trigger>
<div className="pr-2">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground opacity-0 group-hover/header:opacity-100 transition-opacity"
onClick={() =>
useModalStore
.getState()
.open("create-issue", { status })
}
/>
}
>
<Plus className="size-3.5" />
</TooltipTrigger>
<TooltipContent>Add issue</TooltipContent>
</Tooltip>
</div>
</Accordion.Header>
<Accordion.Panel>
{statusIssues.length > 0 ? (
statusIssues.map((issue) => (
<ListRow key={issue.id} issue={issue} />
))
) : (
<p className="py-6 text-center text-xs text-muted-foreground">
No issues
</p>
)}
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion.Root>
</div>
);
}

View file

@ -38,6 +38,7 @@ interface IssueViewState {
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
listCollapsedStatuses: IssueStatus[];
setViewMode: (mode: ViewMode) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
@ -47,6 +48,7 @@ interface IssueViewState {
setSortBy: (field: SortField) => void;
setSortDirection: (dir: SortDirection) => void;
toggleCardProperty: (key: keyof CardProperties) => void;
toggleListCollapsed: (status: IssueStatus) => void;
}
export const useIssueViewStore = create<IssueViewState>()(
@ -63,6 +65,7 @@ export const useIssueViewStore = create<IssueViewState>()(
assignee: true,
dueDate: true,
},
listCollapsedStatuses: [],
setViewMode: (mode) => set({ viewMode: mode }),
toggleStatusFilter: (status) =>
@ -107,6 +110,12 @@ export const useIssueViewStore = create<IssueViewState>()(
[key]: !state.cardProperties[key],
},
})),
toggleListCollapsed: (status) =>
set((state) => ({
listCollapsedStatuses: state.listCollapsedStatuses.includes(status)
? state.listCollapsedStatuses.filter((s) => s !== status)
: [...state.listCollapsedStatuses, status],
})),
}),
{
name: "multica_issues_view",
@ -117,6 +126,7 @@ export const useIssueViewStore = create<IssueViewState>()(
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
}),
}
)

View file

@ -0,0 +1,41 @@
import type { Issue } from "@/shared/types";
import { PRIORITY_ORDER } from "@/features/issues/config";
import type { SortField, SortDirection } from "@/features/issues/stores/view-store";
const PRIORITY_RANK: Record<string, number> = Object.fromEntries(
PRIORITY_ORDER.map((p, i) => [p, i])
);
export function sortIssues(
issues: Issue[],
field: SortField,
direction: SortDirection
): Issue[] {
const sorted = [...issues].sort((a, b) => {
switch (field) {
case "priority":
return (
(PRIORITY_RANK[a.priority] ?? 99) -
(PRIORITY_RANK[b.priority] ?? 99)
);
case "due_date": {
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return (
new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
);
}
case "created_at":
return (
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
case "title":
return a.title.localeCompare(b.title);
case "position":
default:
return a.position - b.position;
}
});
return direction === "desc" ? sorted.reverse() : sorted;
}

View file

@ -13,8 +13,15 @@ import {
XCircle,
Zap,
} from "lucide-react";
import { useDefaultLayout } from "react-resizable-panels";
import type { AgentRuntime, RuntimeUsage, RuntimePingStatus } from "@/shared/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
@ -83,12 +90,13 @@ function RuntimeModeIcon({ mode }: { mode: string }) {
function StatusBadge({ status }: { status: string }) {
const isOnline = status === "online";
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
<Badge
variant="secondary"
className={
isOnline
? "bg-success/10 text-success"
: "bg-muted text-muted-foreground"
}`}
: ""
}
>
{isOnline ? (
<Wifi className="h-3 w-3" />
@ -96,7 +104,7 @@ function StatusBadge({ status }: { status: string }) {
<WifiOff className="h-3 w-3" />
)}
{isOnline ? "Online" : "Offline"}
</span>
</Badge>
);
}
@ -394,12 +402,12 @@ function PingSection({ runtimeId }: { runtimeId: string }) {
function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex min-w-0 items-center gap-2">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${
runtime.status === "online" ? "bg-success/10" : "bg-muted"
}`}
>
@ -407,10 +415,6 @@ function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
</div>
<div className="min-w-0">
<h2 className="text-sm font-semibold truncate">{runtime.name}</h2>
<p className="text-xs text-muted-foreground truncate">
{runtime.provider} &middot;{" "}
{runtime.device_info || "Unknown device"}
</p>
</div>
</div>
<StatusBadge status={runtime.status} />
@ -512,6 +516,9 @@ export default function RuntimesPage() {
const [runtimes, setRuntimes] = useState<AgentRuntime[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [fetching, setFetching] = useState(true);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_runtimes_layout",
});
const fetchRuntimes = useCallback(async () => {
if (!workspace) return;
@ -553,46 +560,55 @@ export default function RuntimesPage() {
}
return (
<div className="flex flex-1 min-h-0">
{/* Left column - runtime list */}
<div className="w-72 shrink-0 overflow-y-auto border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Runtimes</h1>
<span className="text-xs text-muted-foreground">
{runtimes.filter((r) => r.status === "online").length}/
{runtimes.length} online
</span>
<ResizablePanelGroup
orientation="horizontal"
className="flex-1 min-h-0"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left column — runtime list */}
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Runtimes</h1>
<span className="text-xs text-muted-foreground">
{runtimes.filter((r) => r.status === "online").length}/
{runtimes.length} online
</span>
</div>
{runtimes.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Server className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">
No runtimes registered
</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Run{" "}
<code className="rounded bg-muted px-1 py-0.5">
multica daemon start
</code>{" "}
to register a local runtime.
</p>
</div>
) : (
<div className="divide-y">
{runtimes.map((runtime) => (
<RuntimeListItem
key={runtime.id}
runtime={runtime}
isSelected={runtime.id === selectedId}
onClick={() => setSelectedId(runtime.id)}
/>
))}
</div>
)}
</div>
{runtimes.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Server className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">
No runtimes registered
</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Run{" "}
<code className="rounded bg-muted px-1 py-0.5">
multica daemon start
</code>{" "}
to register a local runtime.
</p>
</div>
) : (
<div className="divide-y">
{runtimes.map((runtime) => (
<RuntimeListItem
key={runtime.id}
runtime={runtime}
isSelected={runtime.id === selectedId}
onClick={() => setSelectedId(runtime.id)}
/>
))}
</div>
)}
</div>
</ResizablePanel>
{/* Right column - runtime detail */}
<div className="flex-1 overflow-hidden">
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{/* Right column — runtime detail */}
{selected ? (
<RuntimeDetail key={selected.id} runtime={selected} />
) : (
@ -601,7 +617,7 @@ export default function RuntimesPage() {
<p className="mt-3 text-sm">Select a runtime to view details</p>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -25,6 +25,7 @@ import {
ResizableHandle,
} from "@/components/ui/resizable";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -89,7 +90,7 @@ function CreateSkillDialog({
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Skill</DialogTitle>
<DialogDescription>
@ -109,7 +110,7 @@ function CreateSkillDialog({
</TabsTrigger>
</TabsList>
<TabsContent value="create" className="space-y-4 mt-4">
<TabsContent value="create" className="space-y-4 mt-4 min-h-[180px]">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
@ -134,7 +135,7 @@ function CreateSkillDialog({
</div>
</TabsContent>
<TabsContent value="import" className="space-y-4 mt-4">
<TabsContent value="import" className="space-y-4 mt-4 min-h-[180px]">
<div>
<Label className="text-xs text-muted-foreground">Skill URL</Label>
<Input
@ -244,9 +245,9 @@ function SkillListItem({
)}
</div>
{(skill.files?.length ?? 0) > 0 && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
<Badge variant="secondary">
{skill.files.length} file{skill.files.length !== 1 ? "s" : ""}
</span>
</Badge>
)}
</button>
);