agentfit 0.1.0 → 0.1.1

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 (68) hide show
  1. package/README.md +30 -34
  2. package/app/(dashboard)/daily/page.tsx +1 -1
  3. package/app/(dashboard)/flow/page.tsx +17 -0
  4. package/app/(dashboard)/layout.tsx +2 -0
  5. package/app/(dashboard)/page.tsx +24 -5
  6. package/app/(dashboard)/reports/[id]/page.tsx +72 -0
  7. package/app/(dashboard)/reports/page.tsx +132 -0
  8. package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
  9. package/app/(dashboard)/settings/page.tsx +180 -0
  10. package/app/api/backup/route.ts +215 -0
  11. package/app/api/check/route.ts +11 -1
  12. package/app/api/command-insights/route.ts +13 -0
  13. package/app/api/images-analysis/route.ts +3 -4
  14. package/app/api/reports/[id]/route.ts +23 -0
  15. package/app/api/reports/route.ts +50 -0
  16. package/app/api/reset/route.ts +21 -0
  17. package/app/api/session/route.ts +40 -0
  18. package/app/api/usage/route.ts +25 -1
  19. package/app/layout.tsx +1 -1
  20. package/bin/agentfit.mjs +2 -2
  21. package/components/agent-coach.tsx +256 -129
  22. package/components/app-sidebar.tsx +258 -8
  23. package/components/backup-section.tsx +236 -0
  24. package/components/daily-chart.tsx +404 -83
  25. package/components/dashboard-shell.tsx +9 -24
  26. package/components/data-provider.tsx +66 -2
  27. package/components/fitness-score.tsx +95 -54
  28. package/components/overview-cards.tsx +148 -41
  29. package/components/report-view.tsx +307 -0
  30. package/components/screenshots-analysis.tsx +51 -46
  31. package/components/session-chatlog.tsx +124 -0
  32. package/components/session-timeline.tsx +184 -0
  33. package/components/session-workflow.tsx +183 -0
  34. package/components/sessions-table.tsx +9 -1
  35. package/components/tool-flow-graph.tsx +144 -0
  36. package/components/ui/carousel.tsx +242 -0
  37. package/components/ui/sidebar.tsx +1 -1
  38. package/components/ui/sonner.tsx +51 -0
  39. package/generated/prisma/browser.ts +5 -0
  40. package/generated/prisma/client.ts +5 -0
  41. package/generated/prisma/internal/class.ts +14 -4
  42. package/generated/prisma/internal/prismaNamespace.ts +96 -2
  43. package/generated/prisma/internal/prismaNamespaceBrowser.ts +20 -1
  44. package/generated/prisma/models/Report.ts +1219 -0
  45. package/generated/prisma/models/Session.ts +187 -1
  46. package/generated/prisma/models.ts +1 -0
  47. package/lib/coach.ts +530 -211
  48. package/lib/command-insights.ts +231 -0
  49. package/lib/db.ts +1 -1
  50. package/lib/parse-codex.ts +5 -0
  51. package/lib/parse-logs.ts +65 -0
  52. package/lib/queries-codex.ts +22 -0
  53. package/lib/queries.ts +42 -0
  54. package/lib/report.ts +156 -0
  55. package/lib/session-detail.ts +382 -0
  56. package/lib/sync.ts +77 -0
  57. package/lib/tool-flow.ts +71 -0
  58. package/next.config.mjs +6 -1
  59. package/package.json +16 -2
  60. package/plugins/cost-heatmap/component.tsx +72 -50
  61. package/prisma/schema.prisma +17 -0
  62. package/.claude/settings.local.json +0 -26
  63. package/CONTRIBUTING.md +0 -209
  64. package/prisma/migrations/20260328152517_init/migration.sql +0 -41
  65. package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
  66. package/prisma/migrations/migration_lock.toml +0 -3
  67. package/prisma.config.ts +0 -14
  68. package/setup.sh +0 -73
@@ -0,0 +1,184 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5
+ import { Badge } from '@/components/ui/badge'
6
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
7
+ import type { SessionSummary } from '@/lib/parse-logs'
8
+ import { formatCost, formatDuration, formatNumber } from '@/lib/format'
9
+
10
+ const MODEL_COLORS: Record<string, string> = {
11
+ 'claude-opus-4-6': 'oklch(0.65 0.12 250)',
12
+ 'claude-sonnet-4-6': 'oklch(0.70 0.12 200)',
13
+ 'claude-haiku-4-5': 'oklch(0.75 0.10 155)',
14
+ 'default': 'oklch(0.70 0.08 200)',
15
+ }
16
+
17
+ function getModelColor(model: string): string {
18
+ for (const [key, color] of Object.entries(MODEL_COLORS)) {
19
+ if (key !== 'default' && model.includes(key.replace('claude-', ''))) return color
20
+ }
21
+ return MODEL_COLORS.default
22
+ }
23
+
24
+ interface DayRow {
25
+ date: string
26
+ sessions: SessionSummary[]
27
+ earliest: number // ms timestamp
28
+ latest: number
29
+ }
30
+
31
+ export function SessionTimeline({ sessions }: { sessions: SessionSummary[] }) {
32
+ const [hoveredSession, setHoveredSession] = useState<string | null>(null)
33
+
34
+ const { days, dayStart, dayEnd } = useMemo(() => {
35
+ const dayMap = new Map<string, SessionSummary[]>()
36
+ for (const s of sessions) {
37
+ if (!s.startTime) continue
38
+ const date = s.startTime.slice(0, 10)
39
+ if (!dayMap.has(date)) dayMap.set(date, [])
40
+ dayMap.get(date)!.push(s)
41
+ }
42
+
43
+ const rows: DayRow[] = []
44
+ for (const [date, daySessions] of dayMap) {
45
+ const times = daySessions.flatMap(s => {
46
+ const start = new Date(s.startTime).getTime()
47
+ const end = s.endTime ? new Date(s.endTime).getTime() : start + s.durationMinutes * 60000
48
+ return [start, end]
49
+ })
50
+ rows.push({
51
+ date,
52
+ sessions: daySessions.sort((a, b) => a.startTime.localeCompare(b.startTime)),
53
+ earliest: Math.min(...times),
54
+ latest: Math.max(...times),
55
+ })
56
+ }
57
+
58
+ rows.sort((a, b) => b.date.localeCompare(a.date))
59
+
60
+ // Use 0:00 - 24:00 as the timeline range
61
+ return {
62
+ days: rows.slice(0, 30), // last 30 days
63
+ dayStart: 0,
64
+ dayEnd: 24 * 60, // in minutes
65
+ }
66
+ }, [sessions])
67
+
68
+ if (days.length === 0) {
69
+ return (
70
+ <Card>
71
+ <CardHeader>
72
+ <CardTitle>Session Timeline</CardTitle>
73
+ <CardDescription>No sessions with timestamp data</CardDescription>
74
+ </CardHeader>
75
+ </Card>
76
+ )
77
+ }
78
+
79
+ const totalMinutes = dayEnd - dayStart
80
+ const hourMarkers = Array.from({ length: 25 }, (_, i) => i)
81
+
82
+ return (
83
+ <Card>
84
+ <CardHeader>
85
+ <CardTitle>Session Timeline</CardTitle>
86
+ <CardDescription>
87
+ Daily session activity — each bar is a session, width = duration, color = model.
88
+ Showing last {days.length} active days.
89
+ </CardDescription>
90
+ </CardHeader>
91
+ <CardContent>
92
+ <div className="overflow-x-auto">
93
+ {/* Hour labels */}
94
+ <div className="flex items-end mb-1" style={{ paddingLeft: 90 }}>
95
+ <div className="relative w-full" style={{ height: 16 }}>
96
+ {hourMarkers.filter(h => h % 3 === 0).map(h => (
97
+ <span
98
+ key={h}
99
+ className="absolute text-[10px] text-muted-foreground -translate-x-1/2"
100
+ style={{ left: `${(h * 60 / totalMinutes) * 100}%` }}
101
+ >
102
+ {h === 0 ? '12a' : h < 12 ? `${h}a` : h === 12 ? '12p' : `${h - 12}p`}
103
+ </span>
104
+ ))}
105
+ </div>
106
+ </div>
107
+
108
+ {/* Rows */}
109
+ <div className="space-y-0.5">
110
+ {days.map(day => (
111
+ <div key={day.date} className="flex items-center gap-2" style={{ height: 28 }}>
112
+ {/* Date label */}
113
+ <div className="w-[80px] shrink-0 text-right text-xs text-muted-foreground font-mono">
114
+ {day.date.slice(5)}
115
+ </div>
116
+
117
+ {/* Timeline bar */}
118
+ <div className="relative flex-1 h-full rounded-sm bg-muted/30">
119
+ {/* Hour gridlines */}
120
+ {hourMarkers.filter(h => h % 6 === 0).map(h => (
121
+ <div
122
+ key={h}
123
+ className="absolute top-0 h-full w-px bg-border/50"
124
+ style={{ left: `${(h * 60 / totalMinutes) * 100}%` }}
125
+ />
126
+ ))}
127
+
128
+ {/* Session blocks */}
129
+ {day.sessions.map(s => {
130
+ const startDate = new Date(s.startTime)
131
+ const startMin = startDate.getHours() * 60 + startDate.getMinutes()
132
+ const duration = Math.max(s.durationMinutes, 3) // min 3min width for visibility
133
+ const leftPct = (startMin / totalMinutes) * 100
134
+ const widthPct = Math.min((duration / totalMinutes) * 100, 100 - leftPct)
135
+ const isHovered = hoveredSession === s.sessionId
136
+
137
+ return (
138
+ <Tooltip key={s.sessionId}>
139
+ <TooltipTrigger
140
+ render={
141
+ <div
142
+ className="absolute top-0.5 bottom-0.5 rounded-sm cursor-default transition-opacity"
143
+ style={{
144
+ left: `${leftPct}%`,
145
+ width: `${Math.max(widthPct, 0.3)}%`,
146
+ backgroundColor: getModelColor(s.model),
147
+ opacity: isHovered ? 1 : 0.75,
148
+ zIndex: isHovered ? 10 : 1,
149
+ }}
150
+ onMouseEnter={() => setHoveredSession(s.sessionId)}
151
+ onMouseLeave={() => setHoveredSession(null)}
152
+ />
153
+ }
154
+ />
155
+ <TooltipContent side="top" className="text-xs">
156
+ <div className="space-y-1">
157
+ <div className="font-semibold">{s.project}</div>
158
+ <div>{s.model}</div>
159
+ <div>{formatDuration(s.durationMinutes)} | {formatNumber(s.totalMessages)} msgs | {formatCost(s.costUSD)}</div>
160
+ <div>{new Date(s.startTime).toLocaleTimeString()} – {s.endTime ? new Date(s.endTime).toLocaleTimeString() : '?'}</div>
161
+ </div>
162
+ </TooltipContent>
163
+ </Tooltip>
164
+ )
165
+ })}
166
+ </div>
167
+ </div>
168
+ ))}
169
+ </div>
170
+
171
+ {/* Legend */}
172
+ <div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
173
+ {Object.entries(MODEL_COLORS).filter(([k]) => k !== 'default').map(([model, color]) => (
174
+ <span key={model} className="flex items-center gap-1.5">
175
+ <span className="inline-block h-3 w-3 rounded-sm" style={{ backgroundColor: color }} />
176
+ {model.replace('claude-', '')}
177
+ </span>
178
+ ))}
179
+ </div>
180
+ </div>
181
+ </CardContent>
182
+ </Card>
183
+ )
184
+ }
@@ -0,0 +1,183 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useCallback } from 'react'
4
+ import {
5
+ ReactFlow,
6
+ Background,
7
+ Controls,
8
+ MiniMap,
9
+ useReactFlow,
10
+ ReactFlowProvider,
11
+ type Node,
12
+ type Edge,
13
+ Position,
14
+ MarkerType,
15
+ } from '@xyflow/react'
16
+ import '@xyflow/react/dist/style.css'
17
+ import dagre from 'dagre'
18
+ import type { WorkflowNode, WorkflowEdge } from '@/lib/session-detail'
19
+
20
+ // ─── Node Colors ─────────────────────────────────────────────────────
21
+
22
+ const NODE_STYLES: Record<string, { bg: string; border: string; text: string }> = {
23
+ user_input: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
24
+ thinking: { bg: '#faf5ff', border: '#8b5cf6', text: '#6d28d9' },
25
+ text_response: { bg: '#f0fdf4', border: '#22c55e', text: '#166534' },
26
+ tool_call: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' },
27
+ tool_result: { bg: '#f0fdf4', border: '#22c55e', text: '#166534' },
28
+ system: { bg: '#f8fafc', border: '#94a3b8', text: '#475569' },
29
+ }
30
+
31
+ const ERROR_STYLE = { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' }
32
+
33
+ // ─── Dagre Layout ────────────────────────────────────────────────────
34
+
35
+ function layoutNodes(
36
+ wfNodes: WorkflowNode[],
37
+ wfEdges: WorkflowEdge[]
38
+ ): { nodes: Node[]; edges: Edge[] } {
39
+ const g = new dagre.graphlib.Graph()
40
+ g.setDefaultEdgeLabel(() => ({}))
41
+ g.setGraph({ rankdir: 'TB', ranksep: 50, nodesep: 40, marginx: 20, marginy: 20 })
42
+
43
+ const limitedNodes = wfNodes.slice(0, 200)
44
+ const nodeIds = new Set(limitedNodes.map(n => n.id))
45
+ const nodeWidth = 260
46
+
47
+ for (const node of limitedNodes) {
48
+ g.setNode(node.id, { width: nodeWidth, height: 80 })
49
+ }
50
+
51
+ for (const edge of wfEdges) {
52
+ if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
53
+ g.setEdge(edge.source, edge.target)
54
+ }
55
+ }
56
+
57
+ dagre.layout(g)
58
+
59
+ const nodes: Node[] = limitedNodes.map(node => {
60
+ const pos = g.node(node.id)
61
+ const style = node.isError ? ERROR_STYLE : (NODE_STYLES[node.nodeType] || NODE_STYLES.system)
62
+
63
+ return {
64
+ id: node.id,
65
+ position: { x: (pos?.x || 0) - nodeWidth / 2, y: (pos?.y || 0) - 40 },
66
+ width: nodeWidth,
67
+ height: 80,
68
+ measured: { width: nodeWidth, height: 80 },
69
+ data: {
70
+ color: style.border,
71
+ label: (
72
+ <div className="text-left px-2 py-1 overflow-hidden">
73
+ <div className="flex items-center gap-1.5 mb-0.5">
74
+ <span
75
+ className="inline-block h-2 w-2 rounded-full shrink-0"
76
+ style={{ backgroundColor: style.border }}
77
+ />
78
+ <span className="text-[11px] font-semibold truncate" style={{ color: style.text }}>
79
+ {node.label}
80
+ </span>
81
+ {node.toolName && (
82
+ <span className="text-[10px] text-muted-foreground">
83
+ #{node.stepIndex}
84
+ </span>
85
+ )}
86
+ </div>
87
+ <div className="text-[10px] text-muted-foreground leading-tight line-clamp-2">
88
+ {node.contentPreview || '...'}
89
+ </div>
90
+ </div>
91
+ ),
92
+ },
93
+ style: {
94
+ width: nodeWidth,
95
+ minHeight: 60,
96
+ backgroundColor: style.bg,
97
+ border: `1.5px solid ${style.border}`,
98
+ borderRadius: 8,
99
+ fontSize: 11,
100
+ padding: 0,
101
+ },
102
+ sourcePosition: Position.Bottom,
103
+ targetPosition: Position.Top,
104
+ }
105
+ })
106
+
107
+ const edges: Edge[] = wfEdges
108
+ .filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
109
+ .map(e => ({
110
+ id: e.id,
111
+ source: e.source,
112
+ target: e.target,
113
+ style: { stroke: '#94a3b8', strokeWidth: 1.5 },
114
+ markerEnd: { type: MarkerType.ArrowClosed, width: 10, height: 10, color: '#94a3b8' },
115
+ }))
116
+
117
+ return { nodes, edges }
118
+ }
119
+
120
+ // ─── Inner component (needs ReactFlowProvider) ──────────────────────
121
+
122
+ function WorkflowInner({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) {
123
+ const { fitView } = useReactFlow()
124
+
125
+ // On initial render, zoom to the first 5 nodes
126
+ const onInit = useCallback(() => {
127
+ const firstNodeIds = nodes.slice(0, 5).map(n => n.id)
128
+ setTimeout(() => {
129
+ fitView({ nodes: firstNodeIds.map(id => ({ id })), padding: 0.3, duration: 300 })
130
+ }, 100)
131
+ }, [nodes, fitView])
132
+
133
+ return (
134
+ <ReactFlow
135
+ nodes={nodes}
136
+ edges={edges}
137
+ onInit={onInit}
138
+ nodesDraggable
139
+ nodesConnectable={false}
140
+ minZoom={0.05}
141
+ maxZoom={2}
142
+ defaultEdgeOptions={{ animated: false }}
143
+ >
144
+ <Background gap={16} size={1} />
145
+ <Controls showInteractive={false} />
146
+ <MiniMap
147
+ nodeColor={(node) => (node.data as Record<string, string>)?.color || '#94a3b8'}
148
+ nodeStrokeWidth={0}
149
+ maskColor="rgba(0,0,0,0.08)"
150
+ style={{ width: 200, height: 160 }}
151
+ pannable
152
+ zoomable
153
+ />
154
+ </ReactFlow>
155
+ )
156
+ }
157
+
158
+ // ─── Exported Component ──────────────────────────────────────────────
159
+
160
+ export function SessionWorkflow({
161
+ nodes: wfNodes,
162
+ edges: wfEdges,
163
+ }: {
164
+ nodes: WorkflowNode[]
165
+ edges: WorkflowEdge[]
166
+ }) {
167
+ const { nodes, edges } = useMemo(
168
+ () => layoutNodes(wfNodes, wfEdges),
169
+ [wfNodes, wfEdges]
170
+ )
171
+
172
+ if (nodes.length === 0) {
173
+ return <div className="text-muted-foreground text-sm p-4">No workflow data</div>
174
+ }
175
+
176
+ return (
177
+ <div style={{ height: 700 }} className="w-full rounded-lg border bg-background">
178
+ <ReactFlowProvider>
179
+ <WorkflowInner nodes={nodes} edges={edges} />
180
+ </ReactFlowProvider>
181
+ </div>
182
+ )
183
+ }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useState } from 'react'
4
+ import Link from 'next/link'
4
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5
6
  import {
6
7
  Table,
@@ -20,6 +21,7 @@ import {
20
21
  import { Badge } from '@/components/ui/badge'
21
22
  import { PaginationControls } from '@/components/pagination-controls'
22
23
  import { usePagination } from '@/hooks/use-pagination'
24
+ import { Eye } from 'lucide-react'
23
25
  import type { SessionSummary } from '@/lib/parse-logs'
24
26
  import { formatCost, formatTokens, formatDuration, formatNumber } from '@/lib/format'
25
27
 
@@ -67,11 +69,12 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
67
69
  <TableHead className="text-right">Cost</TableHead>
68
70
  <TableHead className="text-right">Duration</TableHead>
69
71
  <TableHead className="text-right">Tools</TableHead>
72
+ <TableHead className="w-[60px]"></TableHead>
70
73
  </TableRow>
71
74
  </TableHeader>
72
75
  <TableBody>
73
76
  {pagination.pageItems.map((s) => (
74
- <TableRow key={s.sessionId}>
77
+ <TableRow key={s.sessionId} className="hover:bg-muted/50">
75
78
  <TableCell className="whitespace-nowrap text-sm">
76
79
  {s.startTime ? new Date(s.startTime).toLocaleDateString() : 'N/A'}
77
80
  </TableCell>
@@ -86,6 +89,11 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
86
89
  <TableCell className="text-right">{formatCost(s.costUSD)}</TableCell>
87
90
  <TableCell className="text-right">{formatDuration(s.durationMinutes)}</TableCell>
88
91
  <TableCell className="text-right">{formatNumber(s.toolCallsTotal)}</TableCell>
92
+ <TableCell>
93
+ <Link href={`/sessions/${s.sessionId}`} className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
94
+ <Eye className="h-4 w-4" />
95
+ </Link>
96
+ </TableCell>
89
97
  </TableRow>
90
98
  ))}
91
99
  </TableBody>
@@ -0,0 +1,144 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import {
5
+ ReactFlow,
6
+ Background,
7
+ Controls,
8
+ type Node,
9
+ type Edge,
10
+ Position,
11
+ MarkerType,
12
+ } from '@xyflow/react'
13
+ import '@xyflow/react/dist/style.css'
14
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
15
+ import type { UsageData } from '@/lib/parse-logs'
16
+ import { computeToolFlow } from '@/lib/tool-flow'
17
+ import { formatNumber } from '@/lib/format'
18
+
19
+ const TOOL_COLORS: Record<string, string> = {
20
+ Read: '#6b9bd2',
21
+ Edit: '#7bc8a4',
22
+ Write: '#7bc8a4',
23
+ Bash: '#d4a574',
24
+ Grep: '#6b9bd2',
25
+ Glob: '#6b9bd2',
26
+ Agent: '#b8a9d4',
27
+ Skill: '#b8a9d4',
28
+ WebSearch: '#d4a5a5',
29
+ WebFetch: '#d4a5a5',
30
+ }
31
+
32
+ function getToolColor(tool: string): string {
33
+ return TOOL_COLORS[tool] || '#94a3b8'
34
+ }
35
+
36
+ export function ToolFlowGraph({ data }: { data: UsageData }) {
37
+ const flow = useMemo(() => computeToolFlow(data), [data])
38
+
39
+ const { nodes, edges } = useMemo(() => {
40
+ if (flow.nodes.length === 0) return { nodes: [], edges: [] }
41
+
42
+ const maxCount = Math.max(...flow.nodes.map(n => n.count))
43
+ const centerX = 400
44
+ const centerY = 300
45
+ const radius = 220
46
+
47
+ const rfNodes: Node[] = flow.nodes.map((n, i) => {
48
+ const angle = (i / flow.nodes.length) * 2 * Math.PI - Math.PI / 2
49
+ const size = 40 + (n.count / maxCount) * 60
50
+
51
+ return {
52
+ id: n.id,
53
+ position: {
54
+ x: centerX + radius * Math.cos(angle) - size / 2,
55
+ y: centerY + radius * Math.sin(angle) - size / 2,
56
+ },
57
+ data: {
58
+ label: (
59
+ <div className="flex flex-col items-center gap-0.5">
60
+ <span className="text-xs font-semibold">{n.id}</span>
61
+ <span className="text-[10px] text-muted-foreground">{formatNumber(n.count)}</span>
62
+ </div>
63
+ ),
64
+ },
65
+ style: {
66
+ width: size,
67
+ height: size,
68
+ borderRadius: '50%',
69
+ display: 'flex',
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ backgroundColor: getToolColor(n.id),
73
+ border: '2px solid rgba(255,255,255,0.2)',
74
+ color: '#fff',
75
+ fontSize: 11,
76
+ },
77
+ sourcePosition: Position.Right,
78
+ targetPosition: Position.Left,
79
+ }
80
+ })
81
+
82
+ const maxEdgeCount = Math.max(...flow.edges.map(e => e.count), 1)
83
+
84
+ const rfEdges: Edge[] = flow.edges.map((e, i) => ({
85
+ id: `e-${i}`,
86
+ source: e.source,
87
+ target: e.target,
88
+ animated: e.count > maxEdgeCount * 0.5,
89
+ style: {
90
+ strokeWidth: 1 + (e.count / maxEdgeCount) * 4,
91
+ opacity: 0.3 + (e.count / maxEdgeCount) * 0.5,
92
+ stroke: getToolColor(e.source),
93
+ },
94
+ markerEnd: {
95
+ type: MarkerType.ArrowClosed,
96
+ width: 12,
97
+ height: 12,
98
+ },
99
+ label: e.count > maxEdgeCount * 0.2 ? String(e.count) : undefined,
100
+ labelStyle: { fontSize: 10, fill: '#94a3b8' },
101
+ }))
102
+
103
+ return { nodes: rfNodes, edges: rfEdges }
104
+ }, [flow])
105
+
106
+ if (flow.nodes.length === 0) {
107
+ return (
108
+ <Card>
109
+ <CardHeader>
110
+ <CardTitle>Tool Flow</CardTitle>
111
+ <CardDescription>Not enough tool data for visualization</CardDescription>
112
+ </CardHeader>
113
+ </Card>
114
+ )
115
+ }
116
+
117
+ return (
118
+ <Card>
119
+ <CardHeader>
120
+ <CardTitle>Tool Flow Graph</CardTitle>
121
+ <CardDescription>
122
+ How tools connect — node size = usage frequency, edge thickness = co-occurrence strength.
123
+ Top {flow.nodes.length} tools with {flow.edges.length} connections.
124
+ </CardDescription>
125
+ </CardHeader>
126
+ <CardContent>
127
+ <div style={{ height: 600 }} className="rounded-lg border bg-background">
128
+ <ReactFlow
129
+ nodes={nodes}
130
+ edges={edges}
131
+ fitView
132
+ nodesDraggable
133
+ nodesConnectable={false}
134
+ minZoom={0.3}
135
+ maxZoom={2}
136
+ >
137
+ <Background gap={20} size={1} />
138
+ <Controls showInteractive={false} />
139
+ </ReactFlow>
140
+ </div>
141
+ </CardContent>
142
+ </Card>
143
+ )
144
+ }