agentfit 0.1.0 → 0.1.2

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 (74) hide show
  1. package/.github/workflows/release.yml +111 -0
  2. package/README.md +41 -38
  3. package/app/(dashboard)/daily/page.tsx +1 -1
  4. package/app/(dashboard)/data-management/page.tsx +180 -0
  5. package/app/(dashboard)/flow/page.tsx +17 -0
  6. package/app/(dashboard)/layout.tsx +2 -0
  7. package/app/(dashboard)/page.tsx +24 -5
  8. package/app/(dashboard)/reports/[id]/page.tsx +72 -0
  9. package/app/(dashboard)/reports/page.tsx +132 -0
  10. package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
  11. package/app/api/backup/route.ts +215 -0
  12. package/app/api/check/route.ts +11 -1
  13. package/app/api/command-insights/route.ts +13 -0
  14. package/app/api/commands/route.ts +55 -1
  15. package/app/api/images-analysis/route.ts +3 -4
  16. package/app/api/reports/[id]/route.ts +23 -0
  17. package/app/api/reports/route.ts +50 -0
  18. package/app/api/reset/route.ts +21 -0
  19. package/app/api/session/route.ts +40 -0
  20. package/app/api/usage/route.ts +26 -1
  21. package/app/layout.tsx +1 -1
  22. package/bin/agentfit.mjs +2 -2
  23. package/components/agent-coach.tsx +256 -129
  24. package/components/app-sidebar.tsx +45 -10
  25. package/components/backup-section.tsx +236 -0
  26. package/components/daily-chart.tsx +447 -83
  27. package/components/dashboard-shell.tsx +29 -31
  28. package/components/data-provider.tsx +88 -8
  29. package/components/fitness-score.tsx +95 -54
  30. package/components/overview-cards.tsx +148 -41
  31. package/components/report-view.tsx +307 -0
  32. package/components/screenshots-analysis.tsx +51 -46
  33. package/components/session-chatlog.tsx +124 -0
  34. package/components/session-timeline.tsx +184 -0
  35. package/components/session-workflow.tsx +183 -0
  36. package/components/sessions-table.tsx +9 -1
  37. package/components/tool-flow-graph.tsx +144 -0
  38. package/components/ui/carousel.tsx +242 -0
  39. package/components/ui/sidebar.tsx +1 -1
  40. package/components/ui/sonner.tsx +51 -0
  41. package/electron/entitlements.mac.plist +16 -0
  42. package/electron/init-db.mjs +37 -0
  43. package/electron/main.mjs +203 -0
  44. package/generated/prisma/browser.ts +5 -0
  45. package/generated/prisma/client.ts +5 -0
  46. package/generated/prisma/internal/class.ts +14 -4
  47. package/generated/prisma/internal/prismaNamespace.ts +97 -2
  48. package/generated/prisma/internal/prismaNamespaceBrowser.ts +21 -1
  49. package/generated/prisma/models/Report.ts +1219 -0
  50. package/generated/prisma/models/Session.ts +221 -1
  51. package/generated/prisma/models.ts +1 -0
  52. package/lib/coach.ts +571 -211
  53. package/lib/command-insights.ts +231 -0
  54. package/lib/db.ts +2 -2
  55. package/lib/parse-codex.ts +6 -0
  56. package/lib/parse-logs.ts +80 -1
  57. package/lib/queries-codex.ts +24 -0
  58. package/lib/queries.ts +45 -0
  59. package/lib/report.ts +156 -0
  60. package/lib/session-detail.ts +382 -0
  61. package/lib/sync.ts +87 -0
  62. package/lib/tool-flow.ts +71 -0
  63. package/next.config.mjs +6 -1
  64. package/package.json +17 -2
  65. package/plugins/cost-heatmap/component.tsx +72 -50
  66. package/prisma/migrations/20260401144555_add_system_prompt_edits/migration.sql +80 -0
  67. package/prisma/schema.prisma +18 -0
  68. package/prisma/schema.sql +81 -0
  69. package/.claude/settings.local.json +0 -26
  70. package/CONTRIBUTING.md +0 -209
  71. package/prisma/migrations/20260328152517_init/migration.sql +0 -41
  72. package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
  73. package/prisma.config.ts +0 -14
  74. package/setup.sh +0 -73
@@ -12,6 +12,8 @@ import {
12
12
  } from '@/components/ui/chart'
13
13
  import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
14
14
  import { Badge } from '@/components/ui/badge'
15
+ import { PaginationControls } from '@/components/pagination-controls'
16
+ import { usePagination } from '@/hooks/use-pagination'
15
17
  import { formatCost, formatNumber } from '@/lib/format'
16
18
  import {
17
19
  Camera,
@@ -21,6 +23,14 @@ import {
21
23
  Image as ImageIcon,
22
24
  } from 'lucide-react'
23
25
 
26
+ interface ImageInfo {
27
+ filename: string
28
+ sessionId: string
29
+ timestamp: string
30
+ sizeBytes: number
31
+ project: string
32
+ }
33
+
24
34
  interface ImageAnalysis {
25
35
  overview: {
26
36
  totalImages: number
@@ -55,13 +65,7 @@ interface ImageAnalysis {
55
65
  cost: number
56
66
  date: string
57
67
  }[]
58
- recentImages: {
59
- filename: string
60
- sessionId: string
61
- timestamp: string
62
- sizeBytes: number
63
- project: string
64
- }[]
68
+ allImages: ImageInfo[]
65
69
  }
66
70
 
67
71
  const projectConfig = {
@@ -315,45 +319,46 @@ export function ScreenshotsAnalysis() {
315
319
  </CardContent>
316
320
  </Card>
317
321
 
318
- {/* Recent images gallery */}
319
- <Card>
320
- <CardHeader>
321
- <CardTitle>Recent Images</CardTitle>
322
- <CardDescription>Latest images from your sessions</CardDescription>
323
- </CardHeader>
324
- <CardContent>
325
- <div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
326
- {data.recentImages.map((img) => (
327
- <div
328
- key={img.filename}
329
- className="group relative mb-4 break-inside-avoid overflow-hidden rounded-xl border bg-card shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5"
330
- >
331
- <div className="relative">
332
- <img
333
- src={`/api/images/${img.filename}`}
334
- alt={`Image from ${img.project}`}
335
- className="w-full object-cover object-top"
336
- loading="lazy"
337
- />
338
- {/* Hover overlay */}
339
- <div className="absolute inset-0 bg-black/0 transition-colors duration-300 group-hover:bg-black/5" />
340
- {/* Project badge — always visible */}
341
- <div className="absolute top-2.5 left-2.5">
342
- <span className="inline-flex items-center rounded-md bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
343
- {img.project}
344
- </span>
345
- </div>
346
- </div>
347
- {/* Metadata footer */}
348
- <div className="flex items-center justify-between px-3 py-2.5 text-[11px] text-muted-foreground">
349
- <span>{new Date(img.timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
350
- <span>{Math.round(img.sizeBytes / 1024)} KB</span>
351
- </div>
352
- </div>
353
- ))}
354
- </div>
355
- </CardContent>
356
- </Card>
322
+ {/* All images grid with pagination */}
323
+ <ImageGallery images={data.allImages} />
357
324
  </div>
358
325
  )
359
326
  }
327
+
328
+ function ImageGallery({ images }: { images: ImageInfo[] }) {
329
+ const pagination = usePagination(images, 16)
330
+
331
+ if (images.length === 0) return null
332
+
333
+ return (
334
+ <Card>
335
+ <CardHeader>
336
+ <CardTitle>All Images</CardTitle>
337
+ <CardDescription>{images.length} images across all sessions</CardDescription>
338
+ </CardHeader>
339
+ <CardContent>
340
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
341
+ {pagination.pageItems.map((img) => (
342
+ <div
343
+ key={img.filename}
344
+ className="group relative overflow-hidden rounded-xl border bg-card shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5"
345
+ >
346
+ <img
347
+ src={`/api/images/${img.filename}`}
348
+ alt={`Image from ${img.project}`}
349
+ className="aspect-video w-full object-cover object-top"
350
+ loading="lazy"
351
+ />
352
+ <div className="flex items-center justify-between px-3 py-2 text-[11px] text-muted-foreground">
353
+ <span className="font-medium text-foreground">{img.project}</span>
354
+ <span>{new Date(img.timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
355
+ <span>{Math.round(img.sizeBytes / 1024)} KB</span>
356
+ </div>
357
+ </div>
358
+ ))}
359
+ </div>
360
+ <PaginationControls pagination={pagination} noun="images" />
361
+ </CardContent>
362
+ </Card>
363
+ )
364
+ }
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { User, Brain, Wrench, CheckCircle, XCircle, Settings, ChevronDown, ChevronRight, Image } from 'lucide-react'
5
+ import type { ChatMessage } from '@/lib/session-detail'
6
+
7
+ const ROLE_CONFIG: Record<string, { color: string; bg: string; icon: typeof User; label: string }> = {
8
+ user: { color: '#3b82f6', bg: '#eff6ff', icon: User, label: 'User Input' },
9
+ assistant: { color: '#8b5cf6', bg: '#faf5ff', icon: Brain, label: 'Assistant' },
10
+ tool_result: { color: '#22c55e', bg: '#f0fdf4', icon: CheckCircle, label: 'Tool Result' },
11
+ system: { color: '#94a3b8', bg: '#f8fafc', icon: Settings, label: 'System' },
12
+ }
13
+
14
+ function ChatEntry({ message, index }: { message: ChatMessage; index: number }) {
15
+ const [expanded, setExpanded] = useState(false)
16
+ const config = ROLE_CONFIG[message.role] || ROLE_CONFIG.system
17
+ const Icon = message.toolName ? Wrench : (message.isThinking ? Brain : config.icon)
18
+ const isLong = message.content.length > 150
19
+
20
+ let label = config.label
21
+ if (message.toolName) label = message.toolName
22
+ else if (message.isThinking) label = 'Thinking'
23
+ else if (message.role === 'tool_result' && message.content.includes('error')) {
24
+ label = 'Error'
25
+ }
26
+
27
+ return (
28
+ <div
29
+ className="flex gap-3 py-2"
30
+ style={{ borderLeft: `3px solid ${config.color}`, paddingLeft: 12 }}
31
+ >
32
+ <div className="shrink-0 mt-0.5">
33
+ <div
34
+ className="flex h-6 w-6 items-center justify-center rounded-full"
35
+ style={{ backgroundColor: config.bg }}
36
+ >
37
+ <Icon className="h-3.5 w-3.5" style={{ color: config.color }} />
38
+ </div>
39
+ </div>
40
+ <div className="flex-1 min-w-0">
41
+ <div className="flex items-center gap-2 mb-0.5">
42
+ <span className="text-xs font-semibold" style={{ color: config.color }}>
43
+ #{message.stepIndex} {label}
44
+ </span>
45
+ {message.isSidechain && (
46
+ <span className="text-[10px] bg-muted px-1.5 py-0.5 rounded">sub-agent</span>
47
+ )}
48
+ {message.images && message.images > 0 && (
49
+ <span className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
50
+ <Image className="h-3 w-3" /> {message.images}
51
+ </span>
52
+ )}
53
+ <span className="text-[10px] text-muted-foreground">
54
+ {message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : ''}
55
+ </span>
56
+ </div>
57
+ <div
58
+ className={`text-xs leading-relaxed text-foreground/80 ${
59
+ !expanded && isLong ? 'line-clamp-3' : ''
60
+ }`}
61
+ style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
62
+ >
63
+ {message.content}
64
+ </div>
65
+ {isLong && (
66
+ <button
67
+ onClick={() => setExpanded(!expanded)}
68
+ className="flex items-center gap-0.5 mt-1 text-[11px] text-primary hover:underline"
69
+ >
70
+ {expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
71
+ {expanded ? 'Show less' : 'Show more'}
72
+ </button>
73
+ )}
74
+ </div>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ export function SessionChatLog({ messages }: { messages: ChatMessage[] }) {
80
+ const [filter, setFilter] = useState<string>('all')
81
+
82
+ const filtered = filter === 'all'
83
+ ? messages
84
+ : messages.filter(m => m.role === filter || (filter === 'tool' && (m.toolName || m.role === 'tool_result')))
85
+
86
+ return (
87
+ <div className="space-y-2">
88
+ {/* Filter buttons */}
89
+ <div className="flex gap-1.5 pb-2 border-b">
90
+ {[
91
+ { key: 'all', label: 'All', count: messages.length },
92
+ { key: 'user', label: 'User', count: messages.filter(m => m.role === 'user').length },
93
+ { key: 'assistant', label: 'Assistant', count: messages.filter(m => m.role === 'assistant' && !m.toolName).length },
94
+ { key: 'tool', label: 'Tools', count: messages.filter(m => m.toolName || m.role === 'tool_result').length },
95
+ ].map(f => (
96
+ <button
97
+ key={f.key}
98
+ onClick={() => setFilter(f.key)}
99
+ className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
100
+ filter === f.key
101
+ ? 'bg-primary text-primary-foreground'
102
+ : 'bg-muted text-muted-foreground hover:text-foreground'
103
+ }`}
104
+ >
105
+ {f.label} ({f.count})
106
+ </button>
107
+ ))}
108
+ </div>
109
+
110
+ {/* Messages */}
111
+ <div className="space-y-0.5 max-h-[600px] overflow-y-auto pr-2">
112
+ {filtered.map((msg, i) => (
113
+ <ChatEntry key={msg.id} message={msg} index={i} />
114
+ ))}
115
+ </div>
116
+
117
+ {messages.length > 200 && (
118
+ <div className="text-xs text-muted-foreground text-center pt-2">
119
+ Showing {filtered.length} of {messages.length} messages
120
+ </div>
121
+ )}
122
+ </div>
123
+ )
124
+ }
@@ -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>