@renseiai/agentfactory-dashboard 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/components.json +16 -0
  4. package/package.json +78 -0
  5. package/src/components/fleet/agent-card.tsx +97 -0
  6. package/src/components/fleet/fleet-overview.tsx +139 -0
  7. package/src/components/fleet/provider-icon.tsx +61 -0
  8. package/src/components/fleet/stat-card.tsx +77 -0
  9. package/src/components/fleet/status-dot.tsx +35 -0
  10. package/src/components/layout/bottom-bar.tsx +58 -0
  11. package/src/components/layout/shell.tsx +65 -0
  12. package/src/components/layout/sidebar.tsx +97 -0
  13. package/src/components/layout/top-bar.tsx +63 -0
  14. package/src/components/pipeline/pipeline-card.tsx +65 -0
  15. package/src/components/pipeline/pipeline-column.tsx +44 -0
  16. package/src/components/pipeline/pipeline-view.tsx +85 -0
  17. package/src/components/sessions/session-detail.tsx +153 -0
  18. package/src/components/sessions/session-list.tsx +125 -0
  19. package/src/components/sessions/session-timeline.tsx +76 -0
  20. package/src/components/sessions/token-chart.tsx +51 -0
  21. package/src/components/settings/settings-view.tsx +175 -0
  22. package/src/components/shared/empty-state.tsx +34 -0
  23. package/src/components/shared/logo.tsx +37 -0
  24. package/src/components/ui/badge.tsx +33 -0
  25. package/src/components/ui/button.tsx +54 -0
  26. package/src/components/ui/card.tsx +57 -0
  27. package/src/components/ui/dropdown-menu.tsx +77 -0
  28. package/src/components/ui/scroll-area.tsx +45 -0
  29. package/src/components/ui/separator.tsx +25 -0
  30. package/src/components/ui/sheet.tsx +88 -0
  31. package/src/components/ui/skeleton.tsx +15 -0
  32. package/src/components/ui/tabs.tsx +54 -0
  33. package/src/components/ui/tooltip.tsx +29 -0
  34. package/src/hooks/use-sessions.ts +13 -0
  35. package/src/hooks/use-stats.ts +13 -0
  36. package/src/hooks/use-workers.ts +17 -0
  37. package/src/index.ts +82 -0
  38. package/src/lib/format.ts +36 -0
  39. package/src/lib/status-config.ts +72 -0
  40. package/src/lib/utils.ts +6 -0
  41. package/src/lib/work-type-config.ts +116 -0
  42. package/src/pages/dashboard-page.tsx +11 -0
  43. package/src/pages/pipeline-page.tsx +11 -0
  44. package/src/pages/session-page.tsx +29 -0
  45. package/src/pages/settings-page.tsx +7 -0
  46. package/src/styles/globals.css +218 -0
  47. package/src/types/api.ts +48 -0
  48. package/tailwind.config.ts +139 -0
@@ -0,0 +1,77 @@
1
+ import { cn } from '../../lib/utils'
2
+ import { Skeleton } from '../../components/ui/skeleton'
3
+ import { Tooltip, TooltipTrigger, TooltipContent } from '../../components/ui/tooltip'
4
+
5
+ interface StatCardProps {
6
+ label: string
7
+ value: string | number
8
+ detail?: string
9
+ icon?: React.ReactNode
10
+ trend?: 'up' | 'down' | 'neutral'
11
+ accent?: boolean
12
+ loading?: boolean
13
+ className?: string
14
+ tooltip?: React.ReactNode
15
+ }
16
+
17
+ export function StatCard({ label, value, detail, icon, accent, loading, className, tooltip }: StatCardProps) {
18
+ if (loading) {
19
+ return (
20
+ <div className={cn(
21
+ 'rounded-xl border border-af-surface-border/50 bg-af-surface/50 p-4',
22
+ className
23
+ )}>
24
+ <div className="flex items-center justify-between">
25
+ <Skeleton className="h-3 w-16" />
26
+ <Skeleton className="h-4 w-4 rounded" />
27
+ </div>
28
+ <Skeleton className="mt-3 h-8 w-16" />
29
+ <Skeleton className="mt-1.5 h-3 w-20" />
30
+ </div>
31
+ )
32
+ }
33
+
34
+ const card = (
35
+ <div className={cn(
36
+ 'group rounded-xl border border-af-surface-border/50 bg-af-surface/40 p-4 transition-all duration-300 hover-glow',
37
+ accent && 'border-af-accent/15 bg-af-accent/[0.03]',
38
+ className
39
+ )}>
40
+ <div className="flex items-center justify-between">
41
+ <span className="text-2xs font-body font-medium uppercase tracking-wider text-af-text-tertiary">
42
+ {label}
43
+ </span>
44
+ {icon && (
45
+ <span className={cn(
46
+ 'text-af-text-tertiary transition-colors duration-300 group-hover:text-af-text-secondary',
47
+ accent && 'text-af-accent/40 group-hover:text-af-accent/70'
48
+ )}>
49
+ {icon}
50
+ </span>
51
+ )}
52
+ </div>
53
+ <div className={cn(
54
+ 'mt-2 font-display text-2xl font-bold tabular-nums tracking-tight',
55
+ accent ? 'text-af-accent' : 'text-af-text-primary'
56
+ )}>
57
+ {value}
58
+ </div>
59
+ {detail && (
60
+ <p className="mt-1 text-2xs font-body text-af-text-tertiary">{detail}</p>
61
+ )}
62
+ </div>
63
+ )
64
+
65
+ if (tooltip) {
66
+ return (
67
+ <Tooltip>
68
+ <TooltipTrigger asChild>{card}</TooltipTrigger>
69
+ <TooltipContent side="bottom" className="max-w-xs">
70
+ {tooltip}
71
+ </TooltipContent>
72
+ </Tooltip>
73
+ )
74
+ }
75
+
76
+ return card
77
+ }
@@ -0,0 +1,35 @@
1
+ import { cn } from '../../lib/utils'
2
+ import { getStatusConfig, type SessionStatus } from '../../lib/status-config'
3
+
4
+ interface StatusDotProps {
5
+ status: SessionStatus
6
+ className?: string
7
+ showHeartbeat?: boolean
8
+ }
9
+
10
+ export function StatusDot({ status, className, showHeartbeat = false }: StatusDotProps) {
11
+ const config = getStatusConfig(status)
12
+
13
+ return (
14
+ <span className={cn('relative inline-flex h-2 w-2', className)}>
15
+ {/* Outer glow ring for active states */}
16
+ {config.animate && showHeartbeat && (
17
+ <span
18
+ className={cn(
19
+ 'absolute inset-0 rounded-full animate-heartbeat',
20
+ config.dotColor
21
+ )}
22
+ />
23
+ )}
24
+ {/* Core dot */}
25
+ <span
26
+ className={cn(
27
+ 'relative inline-flex h-2 w-2 rounded-full',
28
+ config.dotColor,
29
+ config.animate && 'animate-pulse-dot',
30
+ config.glowClass
31
+ )}
32
+ />
33
+ </span>
34
+ )
35
+ }
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { useStats } from '../../hooks/use-stats'
5
+ import { formatCost } from '../../lib/format'
6
+ import { Tooltip, TooltipTrigger, TooltipContent } from '../../components/ui/tooltip'
7
+ import { Zap } from 'lucide-react'
8
+
9
+ interface BottomBarProps {
10
+ className?: string
11
+ }
12
+
13
+ export function BottomBar({ className }: BottomBarProps) {
14
+ const { data } = useStats()
15
+
16
+ return (
17
+ <footer
18
+ className={cn(
19
+ 'flex h-8 items-center justify-between border-t border-af-surface-border/40 bg-af-bg-secondary/30 backdrop-blur-sm px-5',
20
+ className
21
+ )}
22
+ >
23
+ <div className="flex items-center gap-5 text-2xs font-body text-af-text-tertiary">
24
+ <span>
25
+ <span className="text-af-text-secondary tabular-nums">{data?.completedToday ?? 0}</span> completed
26
+ </span>
27
+ <span>
28
+ <span className="text-af-text-secondary tabular-nums">{data?.availableCapacity ?? 0}</span> capacity
29
+ </span>
30
+ {data?.totalCostToday != null && (
31
+ <Tooltip>
32
+ <TooltipTrigger asChild>
33
+ <span className="cursor-default">
34
+ <span className="text-af-accent font-mono tabular-nums">{formatCost(data.totalCostToday)}</span> today
35
+ {data.totalCostAllTime != null && data.totalCostAllTime !== data.totalCostToday && (
36
+ <span className="text-af-text-tertiary"> · <span className="font-mono tabular-nums">{formatCost(data.totalCostAllTime)}</span> total</span>
37
+ )}
38
+ </span>
39
+ </TooltipTrigger>
40
+ <TooltipContent side="top">
41
+ Cost from {data.sessionCountToday ?? 0} session{(data.sessionCountToday ?? 0) !== 1 ? 's' : ''} updated today (UTC)
42
+ </TooltipContent>
43
+ </Tooltip>
44
+ )}
45
+ </div>
46
+
47
+ <a
48
+ href="https://github.com/renseiai/agentfactory"
49
+ target="_blank"
50
+ rel="noopener noreferrer"
51
+ className="flex items-center gap-1 text-2xs font-body text-af-text-tertiary hover:text-af-text-secondary transition-colors"
52
+ >
53
+ <Zap className="h-2.5 w-2.5" />
54
+ AgentFactory
55
+ </a>
56
+ </footer>
57
+ )
58
+ }
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+ import { Sidebar } from './sidebar'
6
+ import { TopBar } from './top-bar'
7
+ import { BottomBar } from './bottom-bar'
8
+ import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '../../components/ui/sheet'
9
+ import { Button } from '../../components/ui/button'
10
+ import { TooltipProvider } from '../../components/ui/tooltip'
11
+ import { Menu } from 'lucide-react'
12
+ import { Logo } from '../../components/shared/logo'
13
+
14
+ interface DashboardShellProps {
15
+ children: React.ReactNode
16
+ currentPath?: string
17
+ className?: string
18
+ }
19
+
20
+ export function DashboardShell({ children, currentPath = '/', className }: DashboardShellProps) {
21
+ return (
22
+ <TooltipProvider>
23
+ <div className={cn('flex h-screen bg-af-bg-primary overflow-hidden', className)}>
24
+ {/* Background effects layer */}
25
+ <div className="fixed inset-0 mesh-gradient pointer-events-none" />
26
+ <div className="fixed inset-0 grid-bg pointer-events-none opacity-40" />
27
+
28
+ {/* Desktop sidebar */}
29
+ <div className="hidden md:block relative z-10">
30
+ <Sidebar currentPath={currentPath} />
31
+ </div>
32
+
33
+ {/* Main content area */}
34
+ <div className="flex flex-1 flex-col overflow-hidden relative z-10">
35
+ {/* Mobile header with hamburger */}
36
+ <div className="flex items-center gap-3 border-b border-af-surface-border bg-af-bg-secondary/80 backdrop-blur-md px-4 py-2.5 md:hidden">
37
+ <Sheet>
38
+ <SheetTrigger asChild>
39
+ <Button variant="ghost" size="icon" className="h-8 w-8 text-af-text-secondary hover:text-af-text-primary">
40
+ <Menu className="h-4 w-4" />
41
+ </Button>
42
+ </SheetTrigger>
43
+ <SheetContent side="left" className="w-60 p-0 border-af-surface-border bg-af-bg-secondary">
44
+ <SheetTitle className="sr-only">Navigation</SheetTitle>
45
+ <Sidebar currentPath={currentPath} />
46
+ </SheetContent>
47
+ </Sheet>
48
+ <Logo size={20} />
49
+ <span className="text-sm font-display font-semibold text-af-text-primary tracking-tight">
50
+ AgentFactory
51
+ </span>
52
+ </div>
53
+
54
+ <TopBar className="hidden md:flex" />
55
+
56
+ <main className="flex-1 overflow-auto">
57
+ {children}
58
+ </main>
59
+
60
+ <BottomBar />
61
+ </div>
62
+ </div>
63
+ </TooltipProvider>
64
+ )
65
+ }
@@ -0,0 +1,97 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { Logo } from '../../components/shared/logo'
5
+ import { Separator } from '../../components/ui/separator'
6
+ import { LayoutDashboard, Columns3, Activity, Settings, ExternalLink } from 'lucide-react'
7
+
8
+ export interface NavItem {
9
+ label: string
10
+ href: string
11
+ icon: React.ReactNode
12
+ }
13
+
14
+ const defaultNavItems: NavItem[] = [
15
+ { label: 'Fleet', href: '/', icon: <LayoutDashboard className="h-4 w-4" /> },
16
+ { label: 'Pipeline', href: '/pipeline', icon: <Columns3 className="h-4 w-4" /> },
17
+ { label: 'Sessions', href: '/sessions', icon: <Activity className="h-4 w-4" /> },
18
+ { label: 'Settings', href: '/settings', icon: <Settings className="h-4 w-4" /> },
19
+ ]
20
+
21
+ interface SidebarProps {
22
+ currentPath?: string
23
+ navItems?: NavItem[]
24
+ className?: string
25
+ }
26
+
27
+ export function Sidebar({ currentPath = '/', navItems = defaultNavItems, className }: SidebarProps) {
28
+ const isActive = (href: string) => {
29
+ if (href === '/') return currentPath === '/'
30
+ return currentPath.startsWith(href)
31
+ }
32
+
33
+ return (
34
+ <aside
35
+ className={cn(
36
+ 'flex h-full w-[220px] flex-col border-r border-af-surface-border/60 bg-af-bg-secondary/60 backdrop-blur-xl',
37
+ className
38
+ )}
39
+ >
40
+ {/* Logo area */}
41
+ <div className="flex items-center gap-2.5 px-5 py-4">
42
+ <Logo size={22} />
43
+ <span className="font-display text-sm font-bold text-af-text-primary tracking-tight">
44
+ AgentFactory
45
+ </span>
46
+ </div>
47
+
48
+ <div className="px-5">
49
+ <Separator className="bg-af-surface-border/60" />
50
+ </div>
51
+
52
+ {/* Navigation */}
53
+ <nav className="flex-1 space-y-0.5 px-3 py-4">
54
+ {navItems.map((item) => {
55
+ const active = isActive(item.href)
56
+ return (
57
+ <a
58
+ key={item.href}
59
+ href={item.href}
60
+ className={cn(
61
+ 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-body transition-all duration-200',
62
+ active
63
+ ? 'nav-active-indicator bg-af-surface/80 text-af-text-primary font-medium'
64
+ : 'text-af-text-secondary hover:bg-af-surface/40 hover:text-af-text-primary'
65
+ )}
66
+ >
67
+ <span className={cn(
68
+ 'transition-colors duration-200',
69
+ active ? 'text-af-accent' : ''
70
+ )}>
71
+ {item.icon}
72
+ </span>
73
+ {item.label}
74
+ </a>
75
+ )
76
+ })}
77
+ </nav>
78
+
79
+ <div className="px-5">
80
+ <Separator className="bg-af-surface-border/60" />
81
+ </div>
82
+
83
+ {/* Footer */}
84
+ <div className="px-5 py-3">
85
+ <a
86
+ href="https://github.com/renseiai/agentfactory"
87
+ target="_blank"
88
+ rel="noopener noreferrer"
89
+ className="group flex items-center gap-1.5 text-2xs font-body text-af-text-tertiary hover:text-af-text-secondary transition-colors"
90
+ >
91
+ AgentFactory
92
+ <ExternalLink className="h-2.5 w-2.5 opacity-0 group-hover:opacity-100 transition-opacity" />
93
+ </a>
94
+ </div>
95
+ </aside>
96
+ )
97
+ }
@@ -0,0 +1,63 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { StatusDot } from '../../components/fleet/status-dot'
5
+ import { useStats } from '../../hooks/use-stats'
6
+ import { Skeleton } from '../../components/ui/skeleton'
7
+ import { Radio } from 'lucide-react'
8
+
9
+ interface TopBarProps {
10
+ className?: string
11
+ }
12
+
13
+ export function TopBar({ className }: TopBarProps) {
14
+ const { data, isLoading } = useStats()
15
+
16
+ return (
17
+ <header
18
+ className={cn(
19
+ 'flex h-11 items-center justify-between border-b border-af-surface-border/50 bg-af-bg-secondary/40 backdrop-blur-md px-5',
20
+ className
21
+ )}
22
+ >
23
+ <div className="flex items-center gap-5">
24
+ {isLoading ? (
25
+ <>
26
+ <Skeleton className="h-4 w-28" />
27
+ <Skeleton className="h-4 w-20" />
28
+ <Skeleton className="h-4 w-20" />
29
+ </>
30
+ ) : (
31
+ <>
32
+ <div className="flex items-center gap-2">
33
+ <StatusDot status={data?.workersOnline ? 'working' : 'stopped'} showHeartbeat />
34
+ <span className="text-xs font-body text-af-text-secondary">
35
+ <span className="font-semibold text-af-text-primary tabular-nums">{data?.workersOnline ?? 0}</span>{' '}
36
+ {data?.workersOnline === 1 ? 'worker' : 'workers'}
37
+ </span>
38
+ </div>
39
+
40
+ <div className="h-3 w-px bg-af-surface-border" />
41
+
42
+ <span className="text-xs font-body text-af-text-secondary">
43
+ <span className="font-semibold text-af-teal tabular-nums">{data?.agentsWorking ?? 0}</span> active
44
+ </span>
45
+
46
+ <span className="text-xs font-body text-af-text-secondary">
47
+ <span className="font-semibold text-af-text-primary tabular-nums">{data?.queueDepth ?? 0}</span> queued
48
+ </span>
49
+ </>
50
+ )}
51
+ </div>
52
+
53
+ <div className="flex items-center gap-2 text-xs font-body text-af-text-tertiary">
54
+ {data?.timestamp && (
55
+ <>
56
+ <Radio className="h-3 w-3 text-af-teal animate-pulse-dot" />
57
+ <span>{new Date(data.timestamp).toLocaleTimeString()}</span>
58
+ </>
59
+ )}
60
+ </div>
61
+ </header>
62
+ )
63
+ }
@@ -0,0 +1,65 @@
1
+ import { cn } from '../../lib/utils'
2
+ import { StatusDot } from '../../components/fleet/status-dot'
3
+ import { Badge } from '../../components/ui/badge'
4
+ import { formatDuration } from '../../lib/format'
5
+ import { getWorkTypeConfig } from '../../lib/work-type-config'
6
+ import type { PublicSessionResponse } from '../../types/api'
7
+ import { Clock } from 'lucide-react'
8
+
9
+ interface PipelineCardProps {
10
+ session: PublicSessionResponse
11
+ className?: string
12
+ onSelect?: (sessionId: string) => void
13
+ }
14
+
15
+ export function PipelineCard({ session, className, onSelect }: PipelineCardProps) {
16
+ const workTypeConfig = getWorkTypeConfig(session.workType)
17
+
18
+ return (
19
+ <div
20
+ className={cn(
21
+ 'rounded-lg border border-af-surface-border/40 bg-af-surface/50 p-3 transition-all duration-200 hover-glow',
22
+ onSelect && 'cursor-pointer',
23
+ className
24
+ )}
25
+ {...(onSelect && {
26
+ role: 'button',
27
+ tabIndex: 0,
28
+ onClick: () => onSelect(session.id),
29
+ onKeyDown: (e: React.KeyboardEvent) => {
30
+ if (e.key === 'Enter' || e.key === ' ') {
31
+ e.preventDefault()
32
+ onSelect(session.id)
33
+ }
34
+ },
35
+ })}
36
+ >
37
+ <div className="flex items-center gap-2">
38
+ <StatusDot status={session.status} />
39
+ <span className={cn(
40
+ 'text-xs font-mono font-medium truncate',
41
+ onSelect
42
+ ? 'text-af-teal hover:underline underline-offset-2'
43
+ : 'text-af-text-primary'
44
+ )}>
45
+ {session.identifier}
46
+ </span>
47
+ </div>
48
+ <div className="mt-2.5 flex items-center justify-between">
49
+ <Badge
50
+ variant="outline"
51
+ className={cn(
52
+ 'text-2xs border',
53
+ workTypeConfig.bgColor, workTypeConfig.color, workTypeConfig.borderColor
54
+ )}
55
+ >
56
+ {workTypeConfig.label}
57
+ </Badge>
58
+ <span className="flex items-center gap-1 text-2xs font-body text-af-text-tertiary tabular-nums">
59
+ <Clock className="h-2.5 w-2.5" />
60
+ {formatDuration(session.duration)}
61
+ </span>
62
+ </div>
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,44 @@
1
+ import { cn } from '../../lib/utils'
2
+ import { ScrollArea } from '../../components/ui/scroll-area'
3
+ import { PipelineCard } from './pipeline-card'
4
+ import type { PublicSessionResponse } from '../../types/api'
5
+
6
+ interface PipelineColumnProps {
7
+ title: string
8
+ sessions: PublicSessionResponse[]
9
+ count: number
10
+ accentClass?: string
11
+ className?: string
12
+ onSessionSelect?: (sessionId: string) => void
13
+ }
14
+
15
+ export function PipelineColumn({ title, sessions, count, accentClass, className, onSessionSelect }: PipelineColumnProps) {
16
+ return (
17
+ <div
18
+ className={cn(
19
+ 'flex w-72 shrink-0 flex-col rounded-xl border border-af-surface-border/40 bg-af-bg-secondary/50 backdrop-blur-sm',
20
+ accentClass,
21
+ className
22
+ )}
23
+ >
24
+ {/* Column header */}
25
+ <div className="flex items-center justify-between px-3.5 py-3">
26
+ <span className="text-xs font-display font-semibold text-af-text-primary tracking-tight">
27
+ {title}
28
+ </span>
29
+ <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-af-surface/60 px-1.5 text-2xs font-body font-medium tabular-nums text-af-text-secondary">
30
+ {count}
31
+ </span>
32
+ </div>
33
+
34
+ {/* Card list */}
35
+ <ScrollArea className="flex-1 px-2 pb-2">
36
+ <div className="space-y-2">
37
+ {sessions.map((session) => (
38
+ <PipelineCard key={session.id} session={session} onSelect={onSessionSelect} />
39
+ ))}
40
+ </div>
41
+ </ScrollArea>
42
+ </div>
43
+ )
44
+ }
@@ -0,0 +1,85 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { useSessions } from '../../hooks/use-sessions'
5
+ import { PipelineColumn } from './pipeline-column'
6
+ import { EmptyState } from '../../components/shared/empty-state'
7
+ import { Skeleton } from '../../components/ui/skeleton'
8
+ import type { PublicSessionResponse, SessionStatus } from '../../types/api'
9
+
10
+ interface ColumnDef {
11
+ title: string
12
+ statuses: SessionStatus[]
13
+ accentClass: string
14
+ }
15
+
16
+ const columns: ColumnDef[] = [
17
+ { title: 'Backlog', statuses: ['queued', 'parked'], accentClass: 'column-accent column-accent-gray' },
18
+ { title: 'Started', statuses: ['working'], accentClass: 'column-accent column-accent-green' },
19
+ { title: 'Finished', statuses: ['completed'], accentClass: 'column-accent column-accent-blue' },
20
+ { title: 'Failed', statuses: ['failed'], accentClass: 'column-accent column-accent-red' },
21
+ { title: 'Stopped', statuses: ['stopped'], accentClass: 'column-accent column-accent-yellow' },
22
+ ]
23
+
24
+ function groupByColumn(sessions: PublicSessionResponse[]) {
25
+ return columns.map((col) => ({
26
+ ...col,
27
+ sessions: sessions.filter((s) => col.statuses.includes(s.status)),
28
+ }))
29
+ }
30
+
31
+ interface PipelineViewProps {
32
+ className?: string
33
+ onSessionSelect?: (sessionId: string) => void
34
+ }
35
+
36
+ export function PipelineView({ className, onSessionSelect }: PipelineViewProps) {
37
+ const { data, isLoading } = useSessions()
38
+ const sessions = data?.sessions ?? []
39
+
40
+ return (
41
+ <div className={cn('p-6', className)}>
42
+ <div className="mb-5 flex items-center gap-3">
43
+ <h2 className="font-display text-lg font-bold text-af-text-primary tracking-tight">
44
+ Pipeline
45
+ </h2>
46
+ {!isLoading && (
47
+ <span className="rounded-full bg-af-surface/60 px-2 py-0.5 text-2xs font-body font-medium tabular-nums text-af-text-secondary">
48
+ {sessions.length}
49
+ </span>
50
+ )}
51
+ </div>
52
+
53
+ {isLoading ? (
54
+ <div className="flex gap-3 overflow-x-auto pb-2">
55
+ {columns.map((col) => (
56
+ <Skeleton key={col.title} className="h-96 w-72 shrink-0 rounded-xl" />
57
+ ))}
58
+ </div>
59
+ ) : sessions.length === 0 ? (
60
+ <EmptyState
61
+ title="No pipeline data"
62
+ description="Sessions will populate the pipeline as agents work on issues."
63
+ />
64
+ ) : (
65
+ <div className="flex gap-3 overflow-x-auto pb-2">
66
+ {groupByColumn(sessions).map((col, i) => (
67
+ <div
68
+ key={col.title}
69
+ className="animate-fade-in"
70
+ style={{ animationDelay: `${i * 60}ms` }}
71
+ >
72
+ <PipelineColumn
73
+ title={col.title}
74
+ sessions={col.sessions}
75
+ count={col.sessions.length}
76
+ accentClass={col.accentClass}
77
+ onSessionSelect={onSessionSelect}
78
+ />
79
+ </div>
80
+ ))}
81
+ </div>
82
+ )}
83
+ </div>
84
+ )
85
+ }