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.
- package/.github/workflows/release.yml +111 -0
- package/README.md +41 -38
- package/app/(dashboard)/daily/page.tsx +1 -1
- package/app/(dashboard)/data-management/page.tsx +180 -0
- package/app/(dashboard)/flow/page.tsx +17 -0
- package/app/(dashboard)/layout.tsx +2 -0
- package/app/(dashboard)/page.tsx +24 -5
- package/app/(dashboard)/reports/[id]/page.tsx +72 -0
- package/app/(dashboard)/reports/page.tsx +132 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
- package/app/api/backup/route.ts +215 -0
- package/app/api/check/route.ts +11 -1
- package/app/api/command-insights/route.ts +13 -0
- package/app/api/commands/route.ts +55 -1
- package/app/api/images-analysis/route.ts +3 -4
- package/app/api/reports/[id]/route.ts +23 -0
- package/app/api/reports/route.ts +50 -0
- package/app/api/reset/route.ts +21 -0
- package/app/api/session/route.ts +40 -0
- package/app/api/usage/route.ts +26 -1
- package/app/layout.tsx +1 -1
- package/bin/agentfit.mjs +2 -2
- package/components/agent-coach.tsx +256 -129
- package/components/app-sidebar.tsx +45 -10
- package/components/backup-section.tsx +236 -0
- package/components/daily-chart.tsx +447 -83
- package/components/dashboard-shell.tsx +29 -31
- package/components/data-provider.tsx +88 -8
- package/components/fitness-score.tsx +95 -54
- package/components/overview-cards.tsx +148 -41
- package/components/report-view.tsx +307 -0
- package/components/screenshots-analysis.tsx +51 -46
- package/components/session-chatlog.tsx +124 -0
- package/components/session-timeline.tsx +184 -0
- package/components/session-workflow.tsx +183 -0
- package/components/sessions-table.tsx +9 -1
- package/components/tool-flow-graph.tsx +144 -0
- package/components/ui/carousel.tsx +242 -0
- package/components/ui/sidebar.tsx +1 -1
- package/components/ui/sonner.tsx +51 -0
- package/electron/entitlements.mac.plist +16 -0
- package/electron/init-db.mjs +37 -0
- package/electron/main.mjs +203 -0
- package/generated/prisma/browser.ts +5 -0
- package/generated/prisma/client.ts +5 -0
- package/generated/prisma/internal/class.ts +14 -4
- package/generated/prisma/internal/prismaNamespace.ts +97 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +21 -1
- package/generated/prisma/models/Report.ts +1219 -0
- package/generated/prisma/models/Session.ts +221 -1
- package/generated/prisma/models.ts +1 -0
- package/lib/coach.ts +571 -211
- package/lib/command-insights.ts +231 -0
- package/lib/db.ts +2 -2
- package/lib/parse-codex.ts +6 -0
- package/lib/parse-logs.ts +80 -1
- package/lib/queries-codex.ts +24 -0
- package/lib/queries.ts +45 -0
- package/lib/report.ts +156 -0
- package/lib/session-detail.ts +382 -0
- package/lib/sync.ts +87 -0
- package/lib/tool-flow.ts +71 -0
- package/next.config.mjs +6 -1
- package/package.json +17 -2
- package/plugins/cost-heatmap/component.tsx +72 -50
- package/prisma/migrations/20260401144555_add_system_prompt_edits/migration.sql +80 -0
- package/prisma/schema.prisma +18 -0
- package/prisma/schema.sql +81 -0
- package/.claude/settings.local.json +0 -26
- package/CONTRIBUTING.md +0 -209
- package/prisma/migrations/20260328152517_init/migration.sql +0 -41
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
- package/prisma.config.ts +0 -14
- 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
|
-
|
|
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
|
-
{/*
|
|
319
|
-
<
|
|
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>
|