feat(agent-team): dashboard UI redesign (Board + DAG tabs, Tailwind/shadcn) [read-only] #64
22 changed files with 2630 additions and 36 deletions
1863
agent-team/web/package-lock.json
generated
1863
agent-team/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,14 +10,25 @@
|
|||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.14",
|
||||
"@radix-ui/react-scroll-area": "^1.2.12",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-separator": "^1.1.10",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-markdown": "9.0.1",
|
||||
"reactflow": "11.11.4"
|
||||
"reactflow": "11.11.4",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "6.4.8",
|
||||
|
|
@ -26,7 +37,11 @@
|
|||
"@types/react": "18.3.5",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@vitejs/plugin-react": "4.3.1",
|
||||
"autoprefixer": "^10.5.1",
|
||||
"jsdom": "24.1.3",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.5.4",
|
||||
"vite": "5.4.8",
|
||||
"vitest": "1.6.0"
|
||||
|
|
|
|||
6
agent-team/web/postcss.config.js
Normal file
6
agent-team/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LayoutGrid, Workflow as WorkflowIcon } from "lucide-react";
|
||||
import {
|
||||
fetchState,
|
||||
fetchTask,
|
||||
|
|
@ -7,17 +8,22 @@ import {
|
|||
type TaskDetail,
|
||||
type Topology,
|
||||
} from "./api";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Board } from "./components/Board";
|
||||
import { PipelineMap } from "./components/PipelineMap";
|
||||
import { TaskDrawer } from "./components/TaskDrawer";
|
||||
import { TaskList } from "./components/TaskList";
|
||||
import { TopBar } from "./components/TopBar";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
|
||||
type View = "board" | "pipeline";
|
||||
|
||||
export default function App() {
|
||||
const [state, setState] = useState<StateResponse | null>(null);
|
||||
const [topology, setTopology] = useState<Topology | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [view, setView] = useState<View>("board");
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<TaskDetail | null>(null);
|
||||
|
|
@ -81,6 +87,7 @@ export default function App() {
|
|||
|
||||
const toggleNodeFilter = useCallback((nodeId: string) => {
|
||||
setNodeFilter((cur) => (cur === nodeId ? null : nodeId));
|
||||
setView("board"); // node clicks filter the board, so surface it
|
||||
}, []);
|
||||
|
||||
// Highlight the nodes a selected task has visited.
|
||||
|
|
@ -105,42 +112,68 @@ export default function App() {
|
|||
return looped;
|
||||
}, [selectedId, detail, topology]);
|
||||
|
||||
const drawerOpen = selectedId != null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<TopBar
|
||||
state={state}
|
||||
connected={connected}
|
||||
generatedAt={state?.generated_at ?? null}
|
||||
/>
|
||||
<div className="body">
|
||||
<TaskList
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex h-full flex-col bg-background text-foreground">
|
||||
<TopBar
|
||||
state={state}
|
||||
topology={topology}
|
||||
selectedId={selectedId}
|
||||
nodeFilter={nodeFilter}
|
||||
onSelect={selectTask}
|
||||
onClearNodeFilter={() => setNodeFilter(null)}
|
||||
connected={connected}
|
||||
generatedAt={state?.generated_at ?? null}
|
||||
/>
|
||||
<main className="center">
|
||||
{topology ? (
|
||||
<PipelineMap
|
||||
topology={topology}
|
||||
state={state}
|
||||
pathNodeIds={pathNodeIds}
|
||||
loopedEdges={loopedEdges}
|
||||
onNodeClick={toggleNodeFilter}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(v) => setView(v as View)}
|
||||
className="flex min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-3 py-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="board">
|
||||
<LayoutGrid /> Board
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pipeline">
|
||||
<WorkflowIcon /> Pipeline
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="board" className="m-0 min-h-0 flex-1">
|
||||
<Board
|
||||
state={state}
|
||||
topology={topology}
|
||||
selectedId={selectedId}
|
||||
nodeFilter={nodeFilter}
|
||||
onSelect={selectTask}
|
||||
onClearNodeFilter={() => setNodeFilter(null)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="pipeline" className="m-0 min-h-0 flex-1">
|
||||
{topology ? (
|
||||
<PipelineMap
|
||||
topology={topology}
|
||||
state={state}
|
||||
pathNodeIds={pathNodeIds}
|
||||
loopedEdges={loopedEdges}
|
||||
onNodeClick={toggleNodeFilter}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-8 text-sm text-muted-foreground">
|
||||
Loading pipeline…
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{drawerOpen && (
|
||||
<TaskDrawer
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
error={detailError}
|
||||
onClose={closeDrawer}
|
||||
/>
|
||||
) : (
|
||||
<div className="loading">Loading pipeline…</div>
|
||||
)}
|
||||
</main>
|
||||
<TaskDrawer
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
error={detailError}
|
||||
onClose={closeDrawer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,28 @@ export interface TaskView {
|
|||
waiting_since: string | null;
|
||||
}
|
||||
|
||||
// One pipeline stage as grouped + ordered by the backend (status_page.snapshot).
|
||||
// Each stage maps to a Kanban column; `tasks` are the live tasks sitting in it.
|
||||
export interface Stage {
|
||||
key: string;
|
||||
label: string;
|
||||
agent: string;
|
||||
phases: string[];
|
||||
gated: boolean;
|
||||
role_node: boolean;
|
||||
state: NodeState;
|
||||
count: number;
|
||||
tasks: TaskView[];
|
||||
}
|
||||
|
||||
export interface StateResponse {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
generated_at: string;
|
||||
db_path: string;
|
||||
// Backend already groups tasks into ordered pipeline stages; the Board renders
|
||||
// one column per stage. Optional for resilience against older daemons.
|
||||
stages?: Stage[];
|
||||
tasks: TaskView[];
|
||||
waiting: TaskView[];
|
||||
question_counts: Record<string, number>;
|
||||
|
|
|
|||
15
agent-team/web/src/components/Board.tsx
Normal file
15
agent-team/web/src/components/Board.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { StateResponse, Topology } from "../api";
|
||||
|
||||
// STUB — replaced in the component fan-out. Keeps the foundation compiling.
|
||||
interface Props {
|
||||
state: StateResponse | null;
|
||||
topology: Topology | null;
|
||||
selectedId: string | null;
|
||||
nodeFilter: string | null;
|
||||
onSelect: (threadId: string) => void;
|
||||
onClearNodeFilter: () => void;
|
||||
}
|
||||
|
||||
export function Board(_props: Props) {
|
||||
return <div className="p-4 text-sm text-muted-foreground">Board…</div>;
|
||||
}
|
||||
64
agent-team/web/src/components/ui/badge.tsx
Normal file
64
agent-team/web/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold leading-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-secondary text-secondary-foreground",
|
||||
outline: "border border-border text-muted-foreground",
|
||||
// Pipeline status variants — tinted surface + bright foreground.
|
||||
active: "bg-status-active-bg text-status-active",
|
||||
waiting: "bg-status-waiting-bg text-status-waiting",
|
||||
parked: "bg-status-parked-bg text-status-parked",
|
||||
done: "bg-status-done-bg text-status-done",
|
||||
failed: "bg-status-failed-bg text-status-failed",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a backend task status / verdict string to a Badge variant. Centralized so
|
||||
* TaskCard, TaskList, and TaskDrawer agree on color.
|
||||
*/
|
||||
export function statusVariant(
|
||||
status: string,
|
||||
): NonNullable<BadgeProps["variant"]> {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "active";
|
||||
case "waiting_human":
|
||||
case "waiting":
|
||||
return "waiting";
|
||||
case "parked":
|
||||
case "request_changes":
|
||||
return "parked";
|
||||
case "done":
|
||||
case "approve":
|
||||
case "approved":
|
||||
case "pass":
|
||||
return status === "done" ? "done" : "active";
|
||||
case "failed":
|
||||
return "failed";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
55
agent-team/web/src/components/ui/button.tsx
Normal file
55
agent-team/web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-6",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
51
agent-team/web/src/components/ui/card.tsx
Normal file
51
agent-team/web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-1 p-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-3 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent };
|
||||
7
agent-team/web/src/components/ui/collapsible.tsx
Normal file
7
agent-team/web/src/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
20
agent-team/web/src/components/ui/input.tsx
Normal file
20
agent-team/web/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-secondary px-2.5 py-1 text-sm text-foreground transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
45
agent-team/web/src/components/ui/scroll-area.tsx
Normal file
45
agent-team/web/src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2 border-l border-l-transparent p-px",
|
||||
orientation === "horizontal" &&
|
||||
"h-2 flex-col border-t border-t-transparent p-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
85
agent-team/web/src/components/ui/select.tsx
Normal file
85
agent-team/web/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between gap-1 rounded-md border border-input bg-secondary px-2.5 py-1 text-sm text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
position={position}
|
||||
className={cn(
|
||||
"relative z-50 max-h-72 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
position === "popper" && "translate-y-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-7 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
};
|
||||
28
agent-team/web/src/components/ui/separator.tsx
Normal file
28
agent-team/web/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
52
agent-team/web/src/components/ui/tabs.tsx
Normal file
52
agent-team/web/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
27
agent-team/web/src/components/ui/tooltip.tsx
Normal file
27
agent-team/web/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
94
agent-team/web/src/index.css
Normal file
94
agent-team/web/src/index.css
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* Design tokens for the agent-team dashboard.
|
||||
*
|
||||
* shadcn/ui HSL semantic tokens (background/foreground/card/primary/…) plus a
|
||||
* pipeline `status.*` palette (active/waiting/parked/done/failed), each with a
|
||||
* tinted surface and a bright foreground. Values are ported from the original
|
||||
* theme.css hex palette so the dark look stays recognizable. Density follows
|
||||
* vibe-kanban: tight spacing, 0.5rem radius.
|
||||
*/
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: 223 23% 6%; /* #0c0e13 */
|
||||
--foreground: 225 18% 92%; /* #e6e8ee */
|
||||
|
||||
--card: 220 19% 13%; /* #1a1e26 */
|
||||
--card-foreground: 225 18% 92%;
|
||||
|
||||
--popover: 222 22% 9%; /* #12151c */
|
||||
--popover-foreground: 225 18% 92%;
|
||||
|
||||
--primary: 251 100% 68%; /* #7a5cff accent purple */
|
||||
--primary-foreground: 223 23% 6%;
|
||||
|
||||
--secondary: 220 19% 13%;
|
||||
--secondary-foreground: 225 18% 92%;
|
||||
|
||||
--muted: 222 22% 9%;
|
||||
--muted-foreground: 221 14% 60%; /* #8a93a6 */
|
||||
|
||||
--accent: 220 19% 16%; /* subtle hover surface */
|
||||
--accent-foreground: 225 18% 92%;
|
||||
|
||||
--destructive: 345 86% 73%; /* #f57f9c */
|
||||
--destructive-foreground: 223 23% 6%;
|
||||
|
||||
--border: 218 21% 17%; /* #232a36 */
|
||||
--input: 221 19% 23%; /* #2f3645 */
|
||||
--ring: 251 100% 68%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Pipeline status palette (foreground + tinted surface). */
|
||||
--status-active: 149 72% 67%; /* #6ee7a8 */
|
||||
--status-active-bg: 155 45% 16%; /* #163a2b */
|
||||
--status-waiting: 47 87% 70%; /* #f5d76e */
|
||||
--status-waiting-bg: 47 45% 16%; /* #3a3216 */
|
||||
--status-parked: 24 85% 69%; /* #f3a36e */
|
||||
--status-parked-bg: 15 45% 16%; /* #3a1f16 */
|
||||
--status-done: 214 86% 73%; /* #7fb3f5 */
|
||||
--status-done-bg: 210 35% 17%; /* #1c2b3a */
|
||||
--status-failed: 345 86% 73%; /* #f57f9c */
|
||||
--status-failed-bg: 343 45% 16%; /* #3a1620 */
|
||||
|
||||
/* Brand gradient stops (logo dot, accents). */
|
||||
--brand-from: 251 100% 68%;
|
||||
--brand-to: 215 66% 55%; /* #3f7fd8 */
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: theme("fontFamily.sans");
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.mono {
|
||||
font-family: theme("fontFamily.mono");
|
||||
}
|
||||
}
|
||||
|
||||
/* React Flow: hide the attribution badge (LAN-only, offline). */
|
||||
.react-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
7
agent-team/web/src/lib/utils.ts
Normal file
7
agent-team/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** shadcn/ui class-name helper: merge conditional classes, dedupe Tailwind. */
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
// theme.css is being retired as components migrate to Tailwind; kept until the
|
||||
// last component is converted so intermediate builds still render (removed in
|
||||
// the integration phase).
|
||||
import "./theme.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
|
|
|
|||
100
agent-team/web/tailwind.config.js
Normal file
100
agent-team/web/tailwind.config.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import animate from "tailwindcss-animate";
|
||||
|
||||
/**
|
||||
* Tailwind config for the agent-team dashboard.
|
||||
*
|
||||
* Colors are driven by HSL CSS variables defined in src/index.css (shadcn/ui
|
||||
* convention) so the dark theme stays in one place. On top of the standard
|
||||
* shadcn tokens we add pipeline `status.*` colors (active/waiting/parked/done/
|
||||
* failed) that the Board, MapNode, and badges share.
|
||||
*/
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
// Pipeline status palette — each has a tinted surface (`bg`) and a
|
||||
// bright foreground (`fg`), mirroring the original theme.css mapping.
|
||||
status: {
|
||||
active: "hsl(var(--status-active))",
|
||||
"active-bg": "hsl(var(--status-active-bg))",
|
||||
waiting: "hsl(var(--status-waiting))",
|
||||
"waiting-bg": "hsl(var(--status-waiting-bg))",
|
||||
parked: "hsl(var(--status-parked))",
|
||||
"parked-bg": "hsl(var(--status-parked-bg))",
|
||||
done: "hsl(var(--status-done))",
|
||||
"done-bg": "hsl(var(--status-done-bg))",
|
||||
failed: "hsl(var(--status-failed))",
|
||||
"failed-bg": "hsl(var(--status-failed-bg))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [animate],
|
||||
};
|
||||
|
|
@ -15,6 +15,10 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["node", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/// <reference types="vitest/config" />
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
|
|
@ -8,6 +9,11 @@ import react from "@vitejs/plugin-react";
|
|||
// locally (the dashboard is offline/LAN-only).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
|
|
|
|||
Reference in a new issue