tanuki-telemetry 1.1.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 (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,317 @@
1
+ import { useState, useEffect, useRef, useCallback, type ReactNode } from "react"
2
+ import gsap from "gsap"
3
+ import { Circle, Play, CheckCircle2, AlertTriangle, PauseCircle, Search } from "lucide-react"
4
+ import { cn, timeAgo } from "@/lib/utils"
5
+ import { apiFetch } from "@/lib/api"
6
+ import { TanukiLogo } from "./TanukiLogo"
7
+ import type { Event } from "@/types"
8
+ import type { WsMessage } from "@/hooks/useWebSocket"
9
+
10
+ interface WorkspaceState {
11
+ name: string
12
+ status: "idle" | "working" | "done" | "failed" | "paused"
13
+ last_task?: string
14
+ pending?: string[]
15
+ session_id?: string
16
+ last_updated?: string
17
+ }
18
+
19
+ interface CoordinatorState {
20
+ session_id: string
21
+ started_at: string
22
+ last_updated: string
23
+ workspaces: Record<string, WorkspaceState>
24
+ pending_tasks: string[]
25
+ decisions: string[]
26
+ notes: string
27
+ }
28
+
29
+ interface CoordinatorDetail {
30
+ state: CoordinatorState
31
+ history: Array<{ timestamp: string; summary: string; key_decisions: string[]; pending_work: string[] }>
32
+ }
33
+
34
+ interface LiveEvent extends Event {
35
+ worktree_name: string
36
+ }
37
+
38
+ interface LiveData {
39
+ events: LiveEvent[]
40
+ workspace_events: Record<string, LiveEvent>
41
+ }
42
+
43
+ const statusColors: Record<string, string> = {
44
+ idle: "text-text-dim border-border",
45
+ working: "text-warning border-warning/40",
46
+ done: "text-accent border-accent/40",
47
+ failed: "text-error border-error/40",
48
+ paused: "text-info border-info/40",
49
+ }
50
+
51
+ const statusBorderL: Record<string, string> = {
52
+ idle: "border-l-border",
53
+ working: "border-l-warning",
54
+ done: "border-l-accent/40",
55
+ failed: "border-l-error/40",
56
+ paused: "border-l-info/40",
57
+ }
58
+
59
+ const statusIcons: Record<string, ReactNode> = {
60
+ idle: <Circle size={10} />,
61
+ working: <Play size={10} fill="currentColor" />,
62
+ done: <CheckCircle2 size={10} />,
63
+ failed: <AlertTriangle size={10} />,
64
+ paused: <PauseCircle size={10} />,
65
+ }
66
+
67
+ const eventTypeColors: Record<string, string> = {
68
+ action: "text-accent",
69
+ decision: "text-info",
70
+ error: "text-error",
71
+ fix: "text-warning",
72
+ info: "text-text-dim",
73
+ phase_change: "text-accent",
74
+ review_pass: "text-accent",
75
+ review_flag: "text-error",
76
+ }
77
+
78
+ export function CoordinatorPage({ wsMessages = [] }: { wsMessages?: WsMessage[] }) {
79
+ const [detail, setDetail] = useState<CoordinatorDetail | null>(null)
80
+ const [liveData, setLiveData] = useState<LiveData | null>(null)
81
+ const [loading, setLoading] = useState(true)
82
+ const [search, setSearch] = useState("")
83
+ const [showSearch, setShowSearch] = useState(false)
84
+ const feedRef = useRef<HTMLDivElement>(null)
85
+ const workspacesRef = useRef<HTMLDivElement>(null)
86
+ const autoScroll = useRef(true)
87
+
88
+ // Load the latest coordinator session
89
+ const loadLatest = useCallback(() => {
90
+ apiFetch("/api/coordinator/sessions?limit=1")
91
+ .then((r) => r.json())
92
+ .then((data) => {
93
+ setLoading(false)
94
+ if (data.length > 0) {
95
+ const id = data[0].session_id
96
+ apiFetch(`/api/coordinator/sessions/${id}`)
97
+ .then((r) => r.json())
98
+ .then(setDetail)
99
+ .catch(() => {})
100
+ apiFetch(`/api/coordinator/sessions/${id}/live`)
101
+ .then((r) => r.json())
102
+ .then(setLiveData)
103
+ .catch(() => {})
104
+ }
105
+ })
106
+ .catch(() => setLoading(false))
107
+ }, [])
108
+
109
+ useEffect(() => { loadLatest() }, [loadLatest])
110
+
111
+ const reloadLive = useCallback(() => {
112
+ if (!detail) return
113
+ apiFetch(`/api/coordinator/sessions/${detail.state.session_id}/live`)
114
+ .then((r) => r.json())
115
+ .then(setLiveData)
116
+ .catch(() => {})
117
+ }, [detail])
118
+
119
+ // Poll live data
120
+ useEffect(() => {
121
+ if (!detail) return
122
+ const interval = setInterval(reloadLive, 2000)
123
+ return () => clearInterval(interval)
124
+ }, [detail, reloadLive])
125
+
126
+ // React to WS
127
+ useEffect(() => {
128
+ if (wsMessages.length === 0) return
129
+ const latest = wsMessages[wsMessages.length - 1]
130
+ if (latest.type === "coordinator_update") {
131
+ loadLatest()
132
+ }
133
+ if (latest.type === "event" || latest.type === "session_update") {
134
+ reloadLive()
135
+ }
136
+ }, [wsMessages, loadLatest, reloadLive])
137
+
138
+ // Auto-scroll feed to bottom
139
+ useEffect(() => {
140
+ if (feedRef.current && autoScroll.current) {
141
+ feedRef.current.scrollTop = feedRef.current.scrollHeight
142
+ }
143
+ }, [liveData])
144
+
145
+ // Detect manual scroll to disable auto-scroll
146
+ const handleFeedScroll = () => {
147
+ if (!feedRef.current) return
148
+ const { scrollTop, scrollHeight, clientHeight } = feedRef.current
149
+ autoScroll.current = scrollHeight - scrollTop - clientHeight < 40
150
+ }
151
+
152
+ // Animate workspaces on load
153
+ useEffect(() => {
154
+ if (!workspacesRef.current || !detail) return
155
+ const items = workspacesRef.current.querySelectorAll("[data-ws]")
156
+ gsap.fromTo(items, { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.2, stagger: 0.04, ease: "power2.out" })
157
+ }, [detail?.state.session_id])
158
+
159
+ if (loading) {
160
+ return (
161
+ <div className="flex-1 flex items-center justify-center text-text-dim text-xs">
162
+ loading coordinator...
163
+ </div>
164
+ )
165
+ }
166
+
167
+ if (!detail) {
168
+ return (
169
+ <div className="flex-1 flex items-center justify-center text-text-dim text-xs">
170
+ <div className="text-center space-y-2">
171
+ <div className="text-text-muted">no coordinator session</div>
172
+ <div className="text-[10px]">use save_coordinator_state to start one</div>
173
+ </div>
174
+ </div>
175
+ )
176
+ }
177
+
178
+ const state = detail.state
179
+ const workspaceEntries = Object.entries(state.workspaces)
180
+ const events = liveData?.events || []
181
+ const workspaceEvents = liveData?.workspace_events || {}
182
+
183
+ const filteredEvents = search
184
+ ? events.filter((ev) =>
185
+ ev.message.toLowerCase().includes(search.toLowerCase()) ||
186
+ ev.worktree_name.toLowerCase().includes(search.toLowerCase()) ||
187
+ ev.event_type.toLowerCase().includes(search.toLowerCase())
188
+ )
189
+ : events
190
+
191
+ return (
192
+ <div className="flex-1 flex flex-col overflow-hidden">
193
+ {/* Live feed — terminal tail */}
194
+ <div className="flex-shrink-0 border-b border-border flex flex-col" style={{ height: "40%" }}>
195
+ {/* Feed header */}
196
+ <div className="flex items-center justify-between px-4 h-9 border-b border-border bg-bg flex-shrink-0">
197
+ <div className="flex items-center gap-3">
198
+ <span className="text-text-dim text-[10px]">
199
+ <span className="text-accent">$</span> tail -f coordinator/
200
+ </span>
201
+ <span className="text-[10px] text-text-dim">{events.length}</span>
202
+ {autoScroll.current && events.length > 0 && (
203
+ <span className="inline-block w-1.5 h-1.5 bg-accent animate-pulse" />
204
+ )}
205
+ </div>
206
+ <div className="flex items-center gap-2">
207
+ {showSearch && (
208
+ <input
209
+ type="text"
210
+ value={search}
211
+ onChange={(e) => setSearch(e.target.value)}
212
+ placeholder="filter logs..."
213
+ autoFocus
214
+ className="bg-bg border border-border px-2 py-0.5 text-[10px] text-text font-mono w-48 focus:outline-none focus:border-accent/50"
215
+ />
216
+ )}
217
+ <button
218
+ onClick={() => { setShowSearch(!showSearch); if (showSearch) setSearch("") }}
219
+ className={cn("text-text-dim hover:text-accent transition-colors cursor-pointer", showSearch && "text-accent")}
220
+ >
221
+ <Search size={12} />
222
+ </button>
223
+ {/* Mascot */}
224
+ <div className="ml-2">
225
+ <TanukiLogo className="w-5 h-5 text-accent/30 animate-pulse" />
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ {/* Feed body */}
231
+ <div
232
+ ref={feedRef}
233
+ onScroll={handleFeedScroll}
234
+ className="flex-1 overflow-y-auto font-mono text-[10px] px-3 py-1 bg-bg"
235
+ >
236
+ {filteredEvents.length === 0 ? (
237
+ <div className="flex items-center justify-center h-full text-text-dim">
238
+ {search ? "no matching events" : "waiting for events..."}
239
+ </div>
240
+ ) : (
241
+ filteredEvents.map((ev) => (
242
+ <div key={ev.id} className="flex items-start gap-2 py-px hover:bg-bg-hover transition-colors leading-tight">
243
+ <span className="text-text-dim w-[52px] flex-shrink-0 tabular-nums">
244
+ {ev.timestamp.slice(11, 19)}
245
+ </span>
246
+ <span className={cn(
247
+ "font-bold w-[65px] flex-shrink-0 uppercase text-[9px]",
248
+ eventTypeColors[ev.event_type] || "text-text-dim"
249
+ )}>
250
+ {ev.event_type}
251
+ </span>
252
+ <span className="text-info w-[90px] flex-shrink-0 truncate">
253
+ {ev.worktree_name}
254
+ </span>
255
+ <span className="text-text-muted flex-1 truncate">
256
+ {ev.message}
257
+ </span>
258
+ </div>
259
+ ))
260
+ )}
261
+ </div>
262
+ </div>
263
+
264
+ {/* Workspaces + state — bottom section */}
265
+ <div ref={workspacesRef} className="flex-1 overflow-y-auto p-4">
266
+ {/* Workspace grid */}
267
+ <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
268
+ {workspaceEntries.map(([id, ws]) => {
269
+ const lastEvent = ws.session_id ? workspaceEvents[ws.session_id] : undefined
270
+ return (
271
+ <div
272
+ key={id}
273
+ data-ws
274
+ className={cn(
275
+ "border border-border border-l-2 p-2.5 transition-colors bg-bg",
276
+ statusBorderL[ws.status] || "border-l-border"
277
+ )}
278
+ >
279
+ <div className="flex items-center gap-2 mb-1">
280
+ <span className={cn("flex-shrink-0", statusColors[ws.status])}>
281
+ {statusIcons[ws.status]}
282
+ </span>
283
+ <span className="text-[11px] font-bold text-text truncate flex-1">{ws.name}</span>
284
+ <span className={cn(
285
+ "text-[9px] font-mono uppercase tracking-wider font-bold flex-shrink-0",
286
+ statusColors[ws.status]
287
+ )}>{ws.status}</span>
288
+ </div>
289
+
290
+ {ws.last_task && (
291
+ <div className="text-[10px] text-text-muted pl-5 truncate">{ws.last_task}</div>
292
+ )}
293
+
294
+ {lastEvent && (
295
+ <div className="pl-5 mt-1 text-[10px] text-text-dim truncate">
296
+ <span className={cn("font-bold mr-1", eventTypeColors[lastEvent.event_type])}>
297
+ {lastEvent.event_type}
298
+ </span>
299
+ {lastEvent.message}
300
+ <span className="ml-1 text-text-dim">{timeAgo(lastEvent.timestamp)}</span>
301
+ </div>
302
+ )}
303
+
304
+ {ws.pending && ws.pending.length > 0 && (
305
+ <div className="pl-5 mt-1 text-[10px] text-text-dim">
306
+ {ws.pending.length} pending
307
+ </div>
308
+ )}
309
+ </div>
310
+ )
311
+ })}
312
+ </div>
313
+
314
+ </div>
315
+ </div>
316
+ )
317
+ }
@@ -0,0 +1,108 @@
1
+ import { useRef, useEffect, useState } from "react"
2
+ import gsap from "gsap"
3
+ import { ArrowUp } from "lucide-react"
4
+ import type { Stats } from "@/types"
5
+ import { formatTokens, cn } from "@/lib/utils"
6
+ import { TanukiLogo } from "./TanukiLogo"
7
+
8
+ // Baked at build time — compare against /api/version to detect updates
9
+ const CLIENT_VERSION = "1.1.0"
10
+
11
+ export function Header({ stats }: { stats: Stats | null }) {
12
+ const ref = useRef<HTMLDivElement>(null)
13
+ const statsRef = useRef<HTMLDivElement>(null)
14
+ const [serverVersion, setServerVersion] = useState<string | null>(null)
15
+
16
+ useEffect(() => {
17
+ if (!ref.current) return
18
+ gsap.fromTo(
19
+ ref.current.children,
20
+ { opacity: 0 },
21
+ { opacity: 1, duration: 0.3, stagger: 0.06, ease: "power2.out" }
22
+ )
23
+ }, [])
24
+
25
+ useEffect(() => {
26
+ if (!statsRef.current || !stats) return
27
+ gsap.fromTo(
28
+ statsRef.current.children,
29
+ { opacity: 0 },
30
+ { opacity: 1, duration: 0.25, stagger: 0.05, ease: "power2.out" }
31
+ )
32
+ }, [stats])
33
+
34
+ // Check for version mismatch periodically
35
+ useEffect(() => {
36
+ const check = () => {
37
+ fetch("/api/version")
38
+ .then((r) => r.json())
39
+ .then((data) => setServerVersion(data.version))
40
+ .catch(() => {})
41
+ }
42
+ check()
43
+ const interval = setInterval(check, 60000) // check every minute
44
+ return () => clearInterval(interval)
45
+ }, [])
46
+
47
+ const updateAvailable = serverVersion && serverVersion !== CLIENT_VERSION
48
+
49
+ return (
50
+ <div
51
+ ref={ref}
52
+ className="border-b border-border px-4 py-2 flex items-center gap-4 bg-bg-elevated flex-shrink-0"
53
+ >
54
+ <div className="flex items-center gap-2.5 select-none">
55
+ <TanukiLogo className="w-6 h-6 text-accent" />
56
+ <span className="text-accent text-[11px] font-bold tracking-widest">TANUKI</span>
57
+ <span className="text-[9px] text-text-dim font-mono">v{CLIENT_VERSION}</span>
58
+ </div>
59
+ <div className="h-4 w-px bg-border" />
60
+ {stats && (
61
+ <div ref={statsRef} className="flex gap-5 text-[11px] text-text-muted">
62
+ <StatItem label="sessions" value={String(stats.total_sessions)} />
63
+ <StatItem label="avg iter" value={String(stats.avg_iterations)} />
64
+ <StatItem label="tokens" value={formatTokens(stats.total_tokens)} />
65
+ <StatItem
66
+ label="pass"
67
+ value={stats.pass_rate + "%"}
68
+ accent={stats.pass_rate >= 70}
69
+ />
70
+ </div>
71
+ )}
72
+ <div className="ml-auto flex items-center gap-2 text-text-dim text-[10px]">
73
+ {updateAvailable && (
74
+ <button
75
+ onClick={() => window.location.reload()}
76
+ className={cn(
77
+ "flex items-center gap-1 px-2 py-0.5 border border-warning text-warning text-[9px] font-bold cursor-pointer",
78
+ "hover:bg-warning hover:text-bg transition-colors animate-pulse"
79
+ )}
80
+ >
81
+ <ArrowUp size={10} />
82
+ v{serverVersion}
83
+ </button>
84
+ )}
85
+ <span className="inline-block w-1 h-1 bg-accent" />
86
+ </div>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ function StatItem({
92
+ label,
93
+ value,
94
+ accent,
95
+ }: {
96
+ label: string
97
+ value: string
98
+ accent?: boolean
99
+ }) {
100
+ return (
101
+ <span>
102
+ <span className={accent ? "text-accent font-bold" : "text-text font-bold"}>
103
+ {value}
104
+ </span>{" "}
105
+ {label}
106
+ </span>
107
+ )
108
+ }
@@ -0,0 +1,142 @@
1
+ import { useRef, useEffect, type ReactNode } from "react"
2
+ import gsap from "gsap"
3
+ import { AlertTriangle, CheckCircle2, HelpCircle, ArrowRight, BookOpen } from "lucide-react"
4
+ import type { Insight } from "@/types"
5
+ import { cn } from "@/lib/utils"
6
+
7
+ interface Props {
8
+ insights: Insight[]
9
+ }
10
+
11
+ const typeIcons: Record<string, ReactNode> = {
12
+ mistake: <AlertTriangle size={10} />,
13
+ success_pattern: <CheckCircle2 size={10} />,
14
+ codebase_gotcha: <HelpCircle size={10} />,
15
+ optimization: <ArrowRight size={10} />,
16
+ rule_learned: <BookOpen size={10} />,
17
+ }
18
+
19
+ const typeColors: Record<string, string> = {
20
+ mistake: "text-error border-error/30",
21
+ success_pattern: "text-accent border-accent/30",
22
+ codebase_gotcha: "text-warning border-warning/30",
23
+ optimization: "text-info border-info/30",
24
+ rule_learned: "text-purple border-purple/30",
25
+ }
26
+
27
+ export function InsightsPanel({ insights }: Props) {
28
+ const ref = useRef<HTMLDivElement>(null)
29
+ const prevCountRef = useRef(0)
30
+
31
+ useEffect(() => {
32
+ if (!ref.current) return
33
+ if (insights.length === prevCountRef.current) return
34
+ const isInitial = prevCountRef.current === 0
35
+ prevCountRef.current = insights.length
36
+ if (isInitial) {
37
+ const items = ref.current.querySelectorAll(".insight-card")
38
+ gsap.fromTo(
39
+ items,
40
+ { opacity: 0, y: 8 },
41
+ { opacity: 1, y: 0, duration: 0.3, stagger: 0.06, ease: "power2.out" }
42
+ )
43
+ }
44
+ }, [insights])
45
+
46
+ if (insights.length === 0) return null
47
+
48
+ return (
49
+ <div ref={ref} className="mt-2 space-y-2">
50
+ {insights.map((ins) => {
51
+ const filePatterns = ins.file_patterns
52
+ ? (JSON.parse(ins.file_patterns) as string[])
53
+ : []
54
+ const errorPatterns = ins.error_patterns
55
+ ? (JSON.parse(ins.error_patterns) as string[])
56
+ : []
57
+
58
+ return (
59
+ <div
60
+ key={ins.id}
61
+ className="insight-card border border-border rounded bg-bg p-3 hover:bg-bg-hover transition-colors duration-150"
62
+ >
63
+ {/* Header row */}
64
+ <div className="flex items-start gap-2 mb-2">
65
+ <span
66
+ className={cn(
67
+ "text-[10px] px-1.5 py-0.5 border rounded font-bold font-mono flex-shrink-0",
68
+ typeColors[ins.insight_type] || "text-text-dim border-border"
69
+ )}
70
+ >
71
+ {typeIcons[ins.insight_type] || "--"}
72
+ </span>
73
+ <div className="flex-1 min-w-0">
74
+ <div className="text-xs font-bold text-text">{ins.title}</div>
75
+ <div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-dim">
76
+ <span className="text-text-muted">{ins.category}</span>
77
+ <span className="text-border-bright">|</span>
78
+ <ConfidenceBar confidence={ins.confidence} />
79
+ {ins.times_validated > 0 && (
80
+ <>
81
+ <span className="text-border-bright">|</span>
82
+ <span>
83
+ validated{" "}
84
+ <span className="text-accent">{ins.times_validated}x</span>
85
+ </span>
86
+ </>
87
+ )}
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ {/* Description */}
93
+ <div className="text-[11px] text-text-muted leading-relaxed mb-2 pl-8">
94
+ {ins.description}
95
+ </div>
96
+
97
+ {/* Evidence */}
98
+ {ins.evidence && (
99
+ <div className="pl-8 mb-2">
100
+ <pre className="text-[10px] text-text-dim bg-bg-elevated border border-border rounded p-2 overflow-auto max-h-24 whitespace-pre-wrap">
101
+ {ins.evidence}
102
+ </pre>
103
+ </div>
104
+ )}
105
+
106
+ {/* Patterns */}
107
+ {(filePatterns.length > 0 || errorPatterns.length > 0) && (
108
+ <div className="pl-8 flex gap-3 flex-wrap text-[10px]">
109
+ {filePatterns.length > 0 && (
110
+ <span className="text-text-dim">
111
+ <span className="text-accent mr-1">files:</span>
112
+ {filePatterns.join(", ")}
113
+ </span>
114
+ )}
115
+ {errorPatterns.length > 0 && (
116
+ <span className="text-text-dim">
117
+ <span className="text-error mr-1">errors:</span>
118
+ {errorPatterns.join(", ")}
119
+ </span>
120
+ )}
121
+ </div>
122
+ )}
123
+ </div>
124
+ )
125
+ })}
126
+ </div>
127
+ )
128
+ }
129
+
130
+ function ConfidenceBar({ confidence }: { confidence: number }) {
131
+ const pct = Math.round(confidence * 100)
132
+ const bars = Math.round(confidence * 5)
133
+ const filled = "\u2588".repeat(bars)
134
+ const empty = "\u2591".repeat(5 - bars)
135
+ return (
136
+ <span className="font-mono">
137
+ <span className="text-accent">{filled}</span>
138
+ <span className="text-border">{empty}</span>
139
+ <span className="ml-1">{pct}%</span>
140
+ </span>
141
+ )
142
+ }
@@ -0,0 +1,98 @@
1
+ import { useRef, useEffect } from "react"
2
+ import gsap from "gsap"
3
+ import type { Iteration } from "@/types"
4
+ import { formatDuration, cn } from "@/lib/utils"
5
+
6
+ interface Props {
7
+ iterations: Iteration[]
8
+ }
9
+
10
+ export function IterationsTable({ iterations }: Props) {
11
+ const ref = useRef<HTMLTableElement>(null)
12
+ const prevCountRef = useRef(0)
13
+
14
+ useEffect(() => {
15
+ if (!ref.current) return
16
+ if (iterations.length === prevCountRef.current) return
17
+ const isInitial = prevCountRef.current === 0
18
+ prevCountRef.current = iterations.length
19
+ if (isInitial) {
20
+ const rows = ref.current.querySelectorAll("tbody tr")
21
+ gsap.fromTo(
22
+ rows,
23
+ { opacity: 0, y: 6 },
24
+ { opacity: 1, y: 0, duration: 0.3, stagger: 0.05, ease: "power2.out" }
25
+ )
26
+ }
27
+ }, [iterations])
28
+
29
+ return (
30
+ <div className="mt-2 overflow-x-auto">
31
+ <table ref={ref} className="w-full text-xs">
32
+ <thead>
33
+ <tr className="text-text-dim text-[10px] uppercase tracking-wider">
34
+ <th className="text-left py-2 px-2 border-b border-border">#</th>
35
+ <th className="text-left py-2 px-2 border-b border-border">
36
+ TRIGGER
37
+ </th>
38
+ <th className="text-left py-2 px-2 border-b border-border">
39
+ ERROR
40
+ </th>
41
+ <th className="text-left py-2 px-2 border-b border-border">FIX</th>
42
+ <th className="text-left py-2 px-2 border-b border-border">
43
+ RESULT
44
+ </th>
45
+ <th className="text-left py-2 px-2 border-b border-border">
46
+ TIME
47
+ </th>
48
+ </tr>
49
+ </thead>
50
+ <tbody>
51
+ {iterations.map((it) => (
52
+ <tr
53
+ key={it.id}
54
+ className="hover:bg-bg-hover transition-colors duration-150"
55
+ >
56
+ <td className="py-2 px-2 border-b border-border text-text-dim">
57
+ {it.iteration_number}
58
+ </td>
59
+ <td className="py-2 px-2 border-b border-border text-warning">
60
+ {it.trigger}
61
+ </td>
62
+ <td className="py-2 px-2 border-b border-border text-text-muted max-w-[200px] truncate">
63
+ {it.error_summary}
64
+ </td>
65
+ <td className="py-2 px-2 border-b border-border text-text max-w-[200px] truncate">
66
+ {it.fix_description || "—"}
67
+ </td>
68
+ <td className="py-2 px-2 border-b border-border">
69
+ <ResultTag result={it.result} />
70
+ </td>
71
+ <td className="py-2 px-2 border-b border-border text-text-dim">
72
+ {formatDuration(it.duration_seconds)}
73
+ </td>
74
+ </tr>
75
+ ))}
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+ )
80
+ }
81
+
82
+ function ResultTag({ result }: { result: string }) {
83
+ const styles: Record<string, string> = {
84
+ pass: "text-accent border-accent",
85
+ fail: "text-error border-error",
86
+ partial: "text-warning border-warning",
87
+ }
88
+ return (
89
+ <span
90
+ className={cn(
91
+ "text-[10px] px-1.5 py-0.5 border rounded uppercase font-bold",
92
+ styles[result] || "text-text-dim border-border"
93
+ )}
94
+ >
95
+ {result}
96
+ </span>
97
+ )
98
+ }