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.
- package/Dockerfile +22 -0
- package/bin/tanuki.mjs +251 -0
- package/frontend/eslint.config.js +23 -0
- package/frontend/index.html +13 -0
- package/frontend/package.json +39 -0
- package/frontend/src/App.tsx +232 -0
- package/frontend/src/assets/hero.png +0 -0
- package/frontend/src/assets/react.svg +1 -0
- package/frontend/src/assets/vite.svg +1 -0
- package/frontend/src/components/ArtifactsPanel.tsx +429 -0
- package/frontend/src/components/ChildStreams.tsx +176 -0
- package/frontend/src/components/CoordinatorPage.tsx +317 -0
- package/frontend/src/components/Header.tsx +108 -0
- package/frontend/src/components/InsightsPanel.tsx +142 -0
- package/frontend/src/components/IterationsTable.tsx +98 -0
- package/frontend/src/components/KnowledgePage.tsx +308 -0
- package/frontend/src/components/LoginPage.tsx +55 -0
- package/frontend/src/components/PlanProgress.tsx +163 -0
- package/frontend/src/components/QualityReport.tsx +276 -0
- package/frontend/src/components/ScreenshotUpload.tsx +117 -0
- package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
- package/frontend/src/components/SessionDetail.tsx +265 -0
- package/frontend/src/components/SessionList.tsx +234 -0
- package/frontend/src/components/SettingsPage.tsx +213 -0
- package/frontend/src/components/StreamComms.tsx +228 -0
- package/frontend/src/components/TanukiLogo.tsx +16 -0
- package/frontend/src/components/Timeline.tsx +416 -0
- package/frontend/src/components/WalkthroughPage.tsx +458 -0
- package/frontend/src/hooks/useApi.ts +81 -0
- package/frontend/src/hooks/useAuth.ts +54 -0
- package/frontend/src/hooks/useKnowledge.ts +33 -0
- package/frontend/src/hooks/useWebSocket.ts +95 -0
- package/frontend/src/index.css +66 -0
- package/frontend/src/lib/api.ts +15 -0
- package/frontend/src/lib/utils.ts +58 -0
- package/frontend/src/main.tsx +10 -0
- package/frontend/src/types.ts +181 -0
- package/frontend/tsconfig.app.json +32 -0
- package/frontend/tsconfig.json +7 -0
- package/frontend/vite.config.ts +25 -0
- package/install.sh +87 -0
- package/package.json +63 -0
- package/src/api-keys.ts +97 -0
- package/src/auth.ts +165 -0
- package/src/coordinator.ts +136 -0
- package/src/dashboard-server.ts +5 -0
- package/src/dashboard.ts +826 -0
- package/src/db.ts +1009 -0
- package/src/index.ts +20 -0
- package/src/middleware.ts +76 -0
- package/src/tools.ts +864 -0
- package/src/types-shim.d.ts +18 -0
- package/src/types.ts +171 -0
- 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
|
+
}
|