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,308 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, type ReactNode } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
import { AlertTriangle, CheckCircle2, HelpCircle, ArrowRight, BookOpen, ChevronDown, ChevronRight } from "lucide-react"
|
|
4
|
+
import type { Insight } from "@/types"
|
|
5
|
+
import { useKnowledge } from "@/hooks/useKnowledge"
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
import type { WsMessage } from "@/hooks/useWebSocket"
|
|
8
|
+
|
|
9
|
+
const typeIcons: Record<string, ReactNode> = {
|
|
10
|
+
mistake: <AlertTriangle size={10} />,
|
|
11
|
+
success_pattern: <CheckCircle2 size={10} />,
|
|
12
|
+
codebase_gotcha: <HelpCircle size={10} />,
|
|
13
|
+
optimization: <ArrowRight size={10} />,
|
|
14
|
+
rule_learned: <BookOpen size={10} />,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const typeColors: Record<string, string> = {
|
|
18
|
+
mistake: "border-error/30 text-error",
|
|
19
|
+
success_pattern: "border-accent/30 text-accent",
|
|
20
|
+
codebase_gotcha: "border-warning/30 text-warning",
|
|
21
|
+
optimization: "border-info/30 text-info",
|
|
22
|
+
rule_learned: "border-purple/30 text-purple",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const typeBorderColors: Record<string, string> = {
|
|
26
|
+
mistake: "border-l-error/40",
|
|
27
|
+
success_pattern: "border-l-accent/40",
|
|
28
|
+
codebase_gotcha: "border-l-warning/40",
|
|
29
|
+
optimization: "border-l-info/40",
|
|
30
|
+
rule_learned: "border-l-purple/40",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const typeLabels: Record<string, string> = {
|
|
34
|
+
mistake: "MISTAKES",
|
|
35
|
+
success_pattern: "PATTERNS",
|
|
36
|
+
codebase_gotcha: "GOTCHAS",
|
|
37
|
+
optimization: "OPTIMIZATIONS",
|
|
38
|
+
rule_learned: "RULES",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function KnowledgePage({ wsMessages = [] }: { wsMessages?: WsMessage[] }) {
|
|
42
|
+
const [selectedCategory, setSelectedCategory] = useState<string | undefined>()
|
|
43
|
+
const [selectedType, setSelectedType] = useState<string | undefined>()
|
|
44
|
+
const [expandedId, setExpandedId] = useState<number | null>(null)
|
|
45
|
+
const { data, loading, reload } = useKnowledge(selectedCategory)
|
|
46
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
47
|
+
const animatedRef = useRef(false)
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (wsMessages.length === 0) return
|
|
51
|
+
const latest = wsMessages[wsMessages.length - 1]
|
|
52
|
+
if (latest.type === "insight") {
|
|
53
|
+
reload()
|
|
54
|
+
}
|
|
55
|
+
}, [wsMessages, reload])
|
|
56
|
+
|
|
57
|
+
const insights = data?.insights ?? []
|
|
58
|
+
const categories = data?.categories ?? []
|
|
59
|
+
|
|
60
|
+
const filtered = selectedType
|
|
61
|
+
? insights.filter((i) => i.insight_type === selectedType)
|
|
62
|
+
: insights
|
|
63
|
+
|
|
64
|
+
// Animate only on initial load
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!listRef.current || animatedRef.current || filtered.length === 0) return
|
|
67
|
+
animatedRef.current = true
|
|
68
|
+
const items = listRef.current.querySelectorAll(".k-card")
|
|
69
|
+
gsap.fromTo(
|
|
70
|
+
items,
|
|
71
|
+
{ opacity: 0, y: 6 },
|
|
72
|
+
{ opacity: 1, y: 0, duration: 0.2, stagger: 0.02, ease: "power2.out" }
|
|
73
|
+
)
|
|
74
|
+
}, [filtered.length > 0])
|
|
75
|
+
|
|
76
|
+
if (loading && !data) {
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex-1 flex items-center justify-center text-text-dim text-xs">
|
|
79
|
+
loading knowledge base...
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (insights.length === 0) {
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex-1 flex items-center justify-center text-text-dim text-xs">
|
|
87
|
+
<div className="text-center space-y-2">
|
|
88
|
+
<div className="text-text-muted">knowledge base is empty</div>
|
|
89
|
+
<div className="text-[10px]">run sessions with reflection to populate insights</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const typeCounts: Record<string, number> = {}
|
|
96
|
+
for (const ins of insights) {
|
|
97
|
+
typeCounts[ins.insight_type] = (typeCounts[ins.insight_type] || 0) + 1
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex-1 flex overflow-hidden">
|
|
102
|
+
{/* Left sidebar */}
|
|
103
|
+
<div className="w-[300px] min-w-[260px] border-r border-border flex flex-col flex-shrink-0">
|
|
104
|
+
<div className="px-4 h-9 border-b border-border flex items-center justify-between sticky top-0 bg-bg z-10">
|
|
105
|
+
<span className="text-text-dim text-[10px]">
|
|
106
|
+
<span className="text-accent">$</span> knowledge/
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Type filters */}
|
|
111
|
+
<div className="px-4 py-2.5 border-b border-border">
|
|
112
|
+
<div className="text-[9px] text-text-dim uppercase tracking-wider mb-2 font-bold">Type</div>
|
|
113
|
+
<div className="space-y-px">
|
|
114
|
+
<FilterButton
|
|
115
|
+
active={!selectedType}
|
|
116
|
+
onClick={() => setSelectedType(undefined)}
|
|
117
|
+
label="ALL"
|
|
118
|
+
count={insights.length}
|
|
119
|
+
/>
|
|
120
|
+
{Object.entries(typeLabels).map(([type, label]) => (
|
|
121
|
+
<FilterButton
|
|
122
|
+
key={type}
|
|
123
|
+
active={selectedType === type}
|
|
124
|
+
onClick={() => setSelectedType(selectedType === type ? undefined : type)}
|
|
125
|
+
label={label}
|
|
126
|
+
count={typeCounts[type] || 0}
|
|
127
|
+
icon={typeIcons[type]}
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Category filters */}
|
|
134
|
+
<div className="px-4 py-2.5 flex-1 overflow-y-auto">
|
|
135
|
+
<div className="text-[9px] text-text-dim uppercase tracking-wider mb-2 font-bold">Category</div>
|
|
136
|
+
<div className="space-y-px">
|
|
137
|
+
<FilterButton
|
|
138
|
+
active={!selectedCategory}
|
|
139
|
+
onClick={() => setSelectedCategory(undefined)}
|
|
140
|
+
label="all"
|
|
141
|
+
count={insights.length}
|
|
142
|
+
/>
|
|
143
|
+
{categories.map((cat) => (
|
|
144
|
+
<FilterButton
|
|
145
|
+
key={cat.category}
|
|
146
|
+
active={selectedCategory === cat.category}
|
|
147
|
+
onClick={() => setSelectedCategory(selectedCategory === cat.category ? undefined : cat.category)}
|
|
148
|
+
label={cat.category}
|
|
149
|
+
count={cat.count}
|
|
150
|
+
meta={`${Math.round(cat.avg_confidence * 100)}%`}
|
|
151
|
+
/>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Main content */}
|
|
158
|
+
<div ref={listRef} className="flex-1 overflow-y-auto p-4 space-y-1">
|
|
159
|
+
{/* Summary */}
|
|
160
|
+
<div className="flex items-center gap-4 mb-3 text-[10px] text-text-dim px-2">
|
|
161
|
+
<span><span className="text-text font-bold">{filtered.length}</span> insights</span>
|
|
162
|
+
<span className="text-border">|</span>
|
|
163
|
+
<span>
|
|
164
|
+
avg conf <span className="text-accent font-bold">
|
|
165
|
+
{filtered.length > 0
|
|
166
|
+
? Math.round((filtered.reduce((a, i) => a + i.confidence, 0) / filtered.length) * 100)
|
|
167
|
+
: 0}%
|
|
168
|
+
</span>
|
|
169
|
+
</span>
|
|
170
|
+
<span className="text-border">|</span>
|
|
171
|
+
<span>
|
|
172
|
+
<span className="text-text font-bold">{filtered.reduce((a, i) => a + i.times_validated, 0)}</span> validations
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{filtered.map((ins) => (
|
|
177
|
+
<InsightCard
|
|
178
|
+
key={ins.id}
|
|
179
|
+
insight={ins}
|
|
180
|
+
expanded={expandedId === ins.id}
|
|
181
|
+
onToggle={() => setExpandedId(expandedId === ins.id ? null : ins.id)}
|
|
182
|
+
/>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function FilterButton({
|
|
190
|
+
active,
|
|
191
|
+
onClick,
|
|
192
|
+
label,
|
|
193
|
+
count,
|
|
194
|
+
icon,
|
|
195
|
+
meta,
|
|
196
|
+
}: {
|
|
197
|
+
active: boolean
|
|
198
|
+
onClick: () => void
|
|
199
|
+
label: string
|
|
200
|
+
count: number
|
|
201
|
+
icon?: ReactNode
|
|
202
|
+
meta?: string
|
|
203
|
+
}) {
|
|
204
|
+
return (
|
|
205
|
+
<button
|
|
206
|
+
onClick={onClick}
|
|
207
|
+
className={cn(
|
|
208
|
+
"w-full text-left px-2 py-1.5 text-[11px] transition-colors flex items-center gap-2 border-l-2",
|
|
209
|
+
active
|
|
210
|
+
? "bg-bg-hover text-accent border-l-accent"
|
|
211
|
+
: "text-text-muted hover:text-text border-l-transparent"
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
{icon && <span className="flex-shrink-0">{icon}</span>}
|
|
215
|
+
<span className="flex-1 truncate">{label}</span>
|
|
216
|
+
<span className="text-[10px] text-text-dim flex-shrink-0">
|
|
217
|
+
{meta ? `${count} · ${meta}` : count}
|
|
218
|
+
</span>
|
|
219
|
+
</button>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function InsightCard({
|
|
224
|
+
insight: ins,
|
|
225
|
+
expanded,
|
|
226
|
+
onToggle,
|
|
227
|
+
}: {
|
|
228
|
+
insight: Insight
|
|
229
|
+
expanded: boolean
|
|
230
|
+
onToggle: () => void
|
|
231
|
+
}) {
|
|
232
|
+
const filePatterns = ins.file_patterns ? (JSON.parse(ins.file_patterns) as string[]) : []
|
|
233
|
+
const errorPatterns = ins.error_patterns ? (JSON.parse(ins.error_patterns) as string[]) : []
|
|
234
|
+
const pct = Math.round(ins.confidence * 100)
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div
|
|
238
|
+
className={cn(
|
|
239
|
+
"k-card border border-border border-l-2 bg-bg cursor-pointer transition-colors hover:bg-bg-elevated",
|
|
240
|
+
typeBorderColors[ins.insight_type] || "border-l-border",
|
|
241
|
+
expanded && "bg-bg-elevated border-border-bright"
|
|
242
|
+
)}
|
|
243
|
+
onClick={onToggle}
|
|
244
|
+
>
|
|
245
|
+
<div className="flex items-baseline gap-3 px-3 pt-2.5 pb-2">
|
|
246
|
+
<span
|
|
247
|
+
className={cn(
|
|
248
|
+
"text-[10px] px-1.5 py-0.5 border font-bold font-mono flex-shrink-0",
|
|
249
|
+
typeColors[ins.insight_type] || "border-border text-text-dim"
|
|
250
|
+
)}
|
|
251
|
+
>
|
|
252
|
+
{typeIcons[ins.insight_type] || "--"}
|
|
253
|
+
</span>
|
|
254
|
+
|
|
255
|
+
<div className="flex-1 min-w-0">
|
|
256
|
+
<div className="text-[11px] font-bold text-text">{ins.title}</div>
|
|
257
|
+
<div className="flex items-center gap-3 mt-1 text-[10px] text-text-dim">
|
|
258
|
+
<span className="text-text-muted">{ins.category}</span>
|
|
259
|
+
<span className="text-accent font-mono">{pct}%</span>
|
|
260
|
+
{ins.times_validated > 0 && (
|
|
261
|
+
<span>{ins.times_validated}x validated</span>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<span className="text-text-dim flex-shrink-0">
|
|
267
|
+
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
268
|
+
</span>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{expanded && (
|
|
272
|
+
<div data-expand className="px-3 pb-3 border-t border-border pt-2.5 ml-9">
|
|
273
|
+
<div className="text-[11px] text-text-muted leading-relaxed mb-3">
|
|
274
|
+
{ins.description}
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{ins.evidence && (
|
|
278
|
+
<div className="mb-3">
|
|
279
|
+
<div className="text-[9px] text-text-dim uppercase tracking-wider mb-1 font-bold">Evidence</div>
|
|
280
|
+
<pre className="text-[10px] text-text-dim bg-bg border border-border p-2 overflow-auto max-h-32 whitespace-pre-wrap">
|
|
281
|
+
{ins.evidence}
|
|
282
|
+
</pre>
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
<div className="flex gap-4 flex-wrap text-[10px]">
|
|
287
|
+
{filePatterns.length > 0 && (
|
|
288
|
+
<div>
|
|
289
|
+
<span className="text-accent mr-1">files:</span>
|
|
290
|
+
<span className="text-text-muted">{filePatterns.join(", ")}</span>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
{errorPatterns.length > 0 && (
|
|
294
|
+
<div>
|
|
295
|
+
<span className="text-error mr-1">errors:</span>
|
|
296
|
+
<span className="text-text-muted">{errorPatterns.join(", ")}</span>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
<div>
|
|
300
|
+
<span className="text-text-dim mr-1">session:</span>
|
|
301
|
+
<span className="text-text-muted font-mono text-[9px]">{ins.session_id.slice(0, 8)}</span>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useRef, useEffect } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
|
|
4
|
+
export function LoginPage() {
|
|
5
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (!ref.current) return
|
|
9
|
+
gsap.fromTo(
|
|
10
|
+
ref.current.children,
|
|
11
|
+
{ opacity: 0, y: 12 },
|
|
12
|
+
{ opacity: 1, y: 0, duration: 0.5, stagger: 0.15, ease: "power2.out" }
|
|
13
|
+
)
|
|
14
|
+
}, [])
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div ref={ref} className="h-screen flex flex-col items-center justify-center bg-bg">
|
|
18
|
+
{/* Scanline overlay */}
|
|
19
|
+
<div className="pointer-events-none fixed inset-0 z-[100] opacity-[0.03]">
|
|
20
|
+
<div
|
|
21
|
+
className="w-full h-px bg-accent"
|
|
22
|
+
style={{ animation: "scanline 8s linear infinite" }}
|
|
23
|
+
/>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div className="text-center space-y-8">
|
|
27
|
+
<div className="space-y-2">
|
|
28
|
+
<h1 className="text-accent text-lg font-bold tracking-widest font-mono">
|
|
29
|
+
TANUKI
|
|
30
|
+
</h1>
|
|
31
|
+
<p className="text-text-dim text-[11px] font-mono">
|
|
32
|
+
autonomous workflow monitor
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<a
|
|
37
|
+
href="/auth/google"
|
|
38
|
+
className="inline-flex items-center gap-3 px-6 py-3 border border-accent text-accent hover:bg-accent hover:text-bg transition-colors duration-200 text-xs font-mono tracking-wider"
|
|
39
|
+
>
|
|
40
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
41
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
|
42
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
|
43
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
|
44
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
|
45
|
+
</svg>
|
|
46
|
+
SIGN IN WITH GOOGLE
|
|
47
|
+
</a>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div className="absolute bottom-4 text-[10px] text-text-dim font-mono">
|
|
51
|
+
access restricted to authorized team members
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useRef, useEffect, type ReactNode } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
import { Circle, Play, CheckCircle2, MinusCircle, AlertTriangle } from "lucide-react"
|
|
4
|
+
import type { PlanStep } from "@/types"
|
|
5
|
+
import { formatDuration, cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
steps: PlanStep[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const statusIcons: Record<string, ReactNode> = {
|
|
12
|
+
pending: <Circle size={10} />,
|
|
13
|
+
in_progress: <Play size={10} fill="currentColor" />,
|
|
14
|
+
completed: <CheckCircle2 size={10} />,
|
|
15
|
+
skipped: <MinusCircle size={10} />,
|
|
16
|
+
failed: <AlertTriangle size={10} />,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const statusColors: Record<string, string> = {
|
|
20
|
+
pending: "text-text-dim",
|
|
21
|
+
in_progress: "text-warning",
|
|
22
|
+
completed: "text-accent",
|
|
23
|
+
skipped: "text-text-dim",
|
|
24
|
+
failed: "text-error",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function PlanProgress({ steps }: Props) {
|
|
28
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
29
|
+
const completed = steps.filter((s) => s.status === "completed").length
|
|
30
|
+
const failed = steps.filter((s) => s.status === "failed").length
|
|
31
|
+
const total = steps.length
|
|
32
|
+
const pct = total > 0 ? Math.round((completed / total) * 100) : 0
|
|
33
|
+
|
|
34
|
+
const prevCountRef = useRef(0)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!ref.current) return
|
|
38
|
+
if (steps.length === prevCountRef.current) return
|
|
39
|
+
const isInitial = prevCountRef.current === 0
|
|
40
|
+
prevCountRef.current = steps.length
|
|
41
|
+
if (isInitial) {
|
|
42
|
+
const items = ref.current.querySelectorAll(".plan-step")
|
|
43
|
+
gsap.fromTo(
|
|
44
|
+
items,
|
|
45
|
+
{ opacity: 0, x: -6 },
|
|
46
|
+
{ opacity: 1, x: 0, duration: 0.2, stagger: 0.04, ease: "power2.out" }
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}, [steps])
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div ref={ref} className="mt-2">
|
|
53
|
+
{/* Progress bar */}
|
|
54
|
+
<div className="flex items-center gap-3 mb-3">
|
|
55
|
+
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
|
|
56
|
+
<div
|
|
57
|
+
className="h-full bg-accent rounded-full transition-all duration-500"
|
|
58
|
+
style={{ width: `${pct}%` }}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
<span className="text-[10px] text-text-muted tabular-nums w-16 text-right">
|
|
62
|
+
{completed}/{total}
|
|
63
|
+
{failed > 0 && (
|
|
64
|
+
<span className="text-error ml-1">({failed} fail)</span>
|
|
65
|
+
)}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{/* Steps */}
|
|
70
|
+
<div className="space-y-0">
|
|
71
|
+
{steps.map((step) => {
|
|
72
|
+
const isSubStep = step.parent_step != null
|
|
73
|
+
const fileTargets = step.file_targets
|
|
74
|
+
? (JSON.parse(step.file_targets) as string[])
|
|
75
|
+
: []
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
key={step.id}
|
|
80
|
+
className={cn(
|
|
81
|
+
"plan-step flex items-baseline gap-2 pt-2 pb-1.5 px-2 border-l-2 transition-colors duration-200",
|
|
82
|
+
isSubStep && "ml-6",
|
|
83
|
+
step.status === "in_progress"
|
|
84
|
+
? "border-l-warning bg-warning/[0.03]"
|
|
85
|
+
: step.status === "completed"
|
|
86
|
+
? "border-l-accent/40"
|
|
87
|
+
: step.status === "failed"
|
|
88
|
+
? "border-l-error/40 bg-error/[0.03]"
|
|
89
|
+
: "border-l-border"
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{/* Status icon */}
|
|
93
|
+
<span
|
|
94
|
+
className={cn(
|
|
95
|
+
"text-[10px] font-mono font-bold flex-shrink-0 w-6",
|
|
96
|
+
statusColors[step.status],
|
|
97
|
+
step.status === "in_progress" && "animate-pulse"
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
{statusIcons[step.status]}
|
|
101
|
+
</span>
|
|
102
|
+
|
|
103
|
+
{/* Step number */}
|
|
104
|
+
<span className="text-[10px] text-text-dim w-4 flex-shrink-0 tabular-nums">
|
|
105
|
+
{step.step_number}
|
|
106
|
+
</span>
|
|
107
|
+
|
|
108
|
+
{/* Content */}
|
|
109
|
+
<div className="flex-1 min-w-0">
|
|
110
|
+
<div className="flex items-center gap-2">
|
|
111
|
+
<span
|
|
112
|
+
className={cn(
|
|
113
|
+
"text-xs font-bold",
|
|
114
|
+
step.status === "completed"
|
|
115
|
+
? "text-text line-through opacity-60"
|
|
116
|
+
: step.status === "in_progress"
|
|
117
|
+
? "text-warning"
|
|
118
|
+
: step.status === "failed"
|
|
119
|
+
? "text-error"
|
|
120
|
+
: step.status === "skipped"
|
|
121
|
+
? "text-text-dim line-through"
|
|
122
|
+
: "text-text"
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{step.title}
|
|
126
|
+
</span>
|
|
127
|
+
{step.duration_seconds != null && (
|
|
128
|
+
<span className="text-[10px] text-text-dim">
|
|
129
|
+
{formatDuration(step.duration_seconds)}
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{step.description && step.status !== "completed" && (
|
|
135
|
+
<div className="text-[10px] text-text-muted mt-0.5">
|
|
136
|
+
{step.description}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{step.outcome && (
|
|
141
|
+
<div
|
|
142
|
+
className={cn(
|
|
143
|
+
"text-[10px] mt-0.5",
|
|
144
|
+
step.status === "failed" ? "text-error/80" : "text-accent/70"
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{"\u2192"} {step.outcome}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{fileTargets.length > 0 && step.status !== "completed" && (
|
|
152
|
+
<div className="text-[9px] text-text-dim mt-0.5">
|
|
153
|
+
{fileTargets.join(", ")}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
})}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|