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:
parent
1d08057dd8
commit
572d033b95
12 changed files with 358 additions and 224 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export function IssuesPage() {
|
|||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} />
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
41
apps/web/features/issues/utils/sort.ts
Normal file
41
apps/web/features/issues/utils/sort.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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} ·{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue