Merge pull request #158 from multica-ai/forrestchang/theme-card-selector
feat(settings): visual theme preview card selector
This commit is contained in:
commit
5fc03c61fe
15 changed files with 508 additions and 244 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 && (
|
||||
|
|
|
|||
|
|
@ -195,8 +195,8 @@ describe("IssueDetailPage", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Implement authentication"),
|
||||
).toBeInTheDocument();
|
||||
screen.getAllByText("Implement authentication").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
|
|
@ -302,10 +302,10 @@ describe("IssueDetailPage", () => {
|
|||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test WS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const issuesLink = screen.getByText("Issues");
|
||||
expect(issuesLink.closest("a")).toHaveAttribute("href", "/issues");
|
||||
const wsLink = screen.getByText("Test WS");
|
||||
expect(wsLink.closest("a")).toHaveAttribute("href", "/issues");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -105,10 +105,20 @@ const mockViewState = {
|
|||
viewMode: "board" as const,
|
||||
statusFilters: [] as string[],
|
||||
priorityFilters: [] as string[],
|
||||
sortBy: "position" as const,
|
||||
sortDirection: "asc" as const,
|
||||
cardProperties: { priority: true, description: true, assignee: true, dueDate: true },
|
||||
listCollapsedStatuses: [] as string[],
|
||||
setViewMode: vi.fn(),
|
||||
toggleStatusFilter: vi.fn(),
|
||||
togglePriorityFilter: vi.fn(),
|
||||
hideStatus: vi.fn(),
|
||||
showStatus: vi.fn(),
|
||||
clearFilters: vi.fn(),
|
||||
setSortBy: vi.fn(),
|
||||
setSortDirection: vi.fn(),
|
||||
toggleCardProperty: vi.fn(),
|
||||
toggleListCollapsed: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/features/issues/stores/view-store", () => ({
|
||||
|
|
@ -116,6 +126,19 @@ vi.mock("@/features/issues/stores/view-store", () => ({
|
|||
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
|
||||
{ getState: () => mockViewState, setState: vi.fn() },
|
||||
),
|
||||
SORT_OPTIONS: [
|
||||
{ value: "position", label: "Manual" },
|
||||
{ value: "priority", label: "Priority" },
|
||||
{ value: "due_date", label: "Due date" },
|
||||
{ value: "created_at", label: "Created date" },
|
||||
{ value: "title", label: "Title" },
|
||||
],
|
||||
CARD_PROPERTY_OPTIONS: [
|
||||
{ key: "priority", label: "Priority" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "assignee", label: "Assignee" },
|
||||
{ key: "dueDate", label: "Due date" },
|
||||
],
|
||||
}));
|
||||
|
||||
// Mock issue config
|
||||
|
|
@ -162,6 +185,8 @@ vi.mock("@dnd-kit/core", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: any) => children,
|
||||
verticalListSortingStrategy: {},
|
||||
useSortable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
|
|
@ -306,8 +331,8 @@ describe("IssuesPage", () => {
|
|||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Status: All")).toBeInTheDocument();
|
||||
expect(screen.getByText("Priority: All")).toBeInTheDocument();
|
||||
expect(screen.getByText("Filter")).toBeInTheDocument();
|
||||
expect(screen.getByText("Display")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty board view when no issues exist", () => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LIGHT_COLORS = {
|
||||
titleBar: "#e8e8e8",
|
||||
content: "#ffffff",
|
||||
sidebar: "#f4f4f5",
|
||||
bar: "#e4e4e7",
|
||||
barMuted: "#d4d4d8",
|
||||
};
|
||||
|
||||
const DARK_COLORS = {
|
||||
titleBar: "#333338",
|
||||
content: "#27272a",
|
||||
sidebar: "#1e1e21",
|
||||
bar: "#3f3f46",
|
||||
barMuted: "#52525b",
|
||||
};
|
||||
|
||||
function WindowMockup({
|
||||
variant,
|
||||
className,
|
||||
}: {
|
||||
variant: "light" | "dark";
|
||||
className?: string;
|
||||
}) {
|
||||
const colors = variant === "light" ? LIGHT_COLORS : DARK_COLORS;
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full w-full flex-col", className)}>
|
||||
{/* Title bar */}
|
||||
<div
|
||||
className="flex items-center gap-[3px] px-2 py-1.5"
|
||||
style={{ backgroundColor: colors.titleBar }}
|
||||
>
|
||||
<span className="size-[6px] rounded-full bg-[#ff5f57]" />
|
||||
<span className="size-[6px] rounded-full bg-[#febc2e]" />
|
||||
<span className="size-[6px] rounded-full bg-[#28c840]" />
|
||||
</div>
|
||||
{/* Content area */}
|
||||
<div
|
||||
className="flex flex-1"
|
||||
style={{ backgroundColor: colors.content }}
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className="w-[30%] space-y-1 p-2"
|
||||
style={{ backgroundColor: colors.sidebar }}
|
||||
>
|
||||
<div
|
||||
className="h-1 w-3/4 rounded-full"
|
||||
style={{ backgroundColor: colors.bar }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 w-1/2 rounded-full"
|
||||
style={{ backgroundColor: colors.bar }}
|
||||
/>
|
||||
</div>
|
||||
{/* Main */}
|
||||
<div className="flex-1 space-y-1.5 p-2">
|
||||
<div
|
||||
className="h-1.5 w-4/5 rounded-full"
|
||||
style={{ backgroundColor: colors.bar }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 w-full rounded-full"
|
||||
style={{ backgroundColor: colors.barMuted }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 w-3/5 rounded-full"
|
||||
style={{ backgroundColor: colors.barMuted }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const themeOptions = [
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
] as const;
|
||||
{ value: "light" as const, label: "Light" },
|
||||
{ value: "dark" as const, label: "Dark" },
|
||||
{ value: "system" as const, label: "System" },
|
||||
];
|
||||
|
||||
export function AppearanceTab() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
|
@ -16,21 +91,51 @@ export function AppearanceTab() {
|
|||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">Theme</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-6" role="radiogroup" aria-label="Theme">
|
||||
{themeOptions.map((opt) => {
|
||||
const active = theme === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={`Select ${opt.label} theme`}
|
||||
onClick={() => setTheme(opt.value)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
className="group flex flex-col items-center gap-2"
|
||||
>
|
||||
<opt.icon className="h-3.5 w-3.5" />
|
||||
{opt.label}
|
||||
<div
|
||||
className={cn(
|
||||
"aspect-[4/3] w-36 overflow-hidden rounded-lg ring-1 transition-all",
|
||||
active
|
||||
? "ring-2 ring-brand"
|
||||
: "ring-border hover:ring-2 hover:ring-border"
|
||||
)}
|
||||
>
|
||||
{opt.value === "system" ? (
|
||||
<div className="relative h-full w-full">
|
||||
<WindowMockup
|
||||
variant="light"
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
<WindowMockup
|
||||
variant="dark"
|
||||
className="absolute inset-0 [clip-path:inset(0_0_0_50%)]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<WindowMockup variant={opt.value} />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition-colors",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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