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,265 @@
|
|
|
1
|
+
import { useRef, useEffect } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
import type { SessionDetail as SessionDetailType, FinalResult } from "@/types"
|
|
4
|
+
import { formatDuration, formatTokens, cn } from "@/lib/utils"
|
|
5
|
+
import { Timeline } from "./Timeline"
|
|
6
|
+
import { IterationsTable } from "./IterationsTable"
|
|
7
|
+
import { ScreenshotsGrid } from "./ScreenshotsGrid"
|
|
8
|
+
import { InsightsPanel } from "./InsightsPanel"
|
|
9
|
+
import { PlanProgress } from "./PlanProgress"
|
|
10
|
+
import { ChildStreams } from "./ChildStreams"
|
|
11
|
+
import { StreamComms } from "./StreamComms"
|
|
12
|
+
import { ScreenshotUpload } from "./ScreenshotUpload"
|
|
13
|
+
import { ArtifactsPanel } from "./ArtifactsPanel"
|
|
14
|
+
import { QualityReport } from "./QualityReport"
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
detail: SessionDetailType | null
|
|
18
|
+
loading: boolean
|
|
19
|
+
onSelectSession?: (id: string) => void
|
|
20
|
+
onReload?: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SessionDetail({ detail, loading, onSelectSession, onReload }: Props) {
|
|
24
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
25
|
+
const prevSessionId = useRef<string | null>(null)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!ref.current || !detail) return
|
|
29
|
+
// Only animate on session change, not on live refreshes
|
|
30
|
+
if (detail.session.id === prevSessionId.current) return
|
|
31
|
+
prevSessionId.current = detail.session.id
|
|
32
|
+
gsap.fromTo(
|
|
33
|
+
ref.current.children,
|
|
34
|
+
{ opacity: 0, y: 16 },
|
|
35
|
+
{ opacity: 1, y: 0, duration: 0.5, stagger: 0.1, ease: "power2.out" }
|
|
36
|
+
)
|
|
37
|
+
}, [detail])
|
|
38
|
+
|
|
39
|
+
if (loading) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex-1 flex items-center justify-center text-text-dim text-xs">
|
|
42
|
+
<pre>
|
|
43
|
+
{`> loading session data...
|
|
44
|
+
> ████████░░░░░░░░ 50%`}
|
|
45
|
+
</pre>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!detail) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex-1 flex items-center justify-center text-text-dim text-xs">
|
|
53
|
+
<pre className="text-center">
|
|
54
|
+
{`╔══════════════════════════════╗
|
|
55
|
+
║ ║
|
|
56
|
+
║ select a session to view ║
|
|
57
|
+
║ session data ║
|
|
58
|
+
║ ║
|
|
59
|
+
║ ← click a session ║
|
|
60
|
+
║ ║
|
|
61
|
+
╚══════════════════════════════╝`}
|
|
62
|
+
</pre>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const s = detail.session
|
|
68
|
+
const totalTokens = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
|
|
69
|
+
let finalResult: FinalResult | null = null
|
|
70
|
+
try {
|
|
71
|
+
if (s.final_result) finalResult = JSON.parse(s.final_result)
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div ref={ref} className="flex-1 overflow-y-auto p-5 space-y-4">
|
|
78
|
+
{/* Session header */}
|
|
79
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
80
|
+
<div className="flex items-center gap-3 mb-3">
|
|
81
|
+
<StatusBadge status={s.status} />
|
|
82
|
+
<h2 className="text-base font-bold text-text">{s.worktree_name}</h2>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
|
86
|
+
{s.ticket_id && (
|
|
87
|
+
<MetaField label="TICKET" value={`${s.ticket_id}${s.ticket_title ? " — " + s.ticket_title : ""}`} />
|
|
88
|
+
)}
|
|
89
|
+
{s.branch_name && (
|
|
90
|
+
<MetaField label="BRANCH" value={s.branch_name} />
|
|
91
|
+
)}
|
|
92
|
+
<MetaField label="MODE" value={s.mode.toUpperCase()} />
|
|
93
|
+
<MetaField label="DURATION" value={formatDuration(s.duration_seconds)} />
|
|
94
|
+
<MetaField label="TOKENS" value={formatTokens(totalTokens)} />
|
|
95
|
+
<MetaField
|
|
96
|
+
label="ITERATIONS"
|
|
97
|
+
value={`${s.total_iterations}/${s.max_iterations}`}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Quality report */}
|
|
103
|
+
<QualityReport events={detail.events} />
|
|
104
|
+
|
|
105
|
+
{/* Child streams */}
|
|
106
|
+
{detail.child_sessions && detail.child_sessions.length > 0 && (
|
|
107
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
108
|
+
<SectionHeader title={`CHILD STREAMS (${detail.child_sessions.length})`} />
|
|
109
|
+
<ChildStreams
|
|
110
|
+
children={detail.child_sessions}
|
|
111
|
+
onSelectSession={onSelectSession || (() => {})}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Stream communications */}
|
|
117
|
+
{detail.child_sessions && detail.child_sessions.length > 0 && (
|
|
118
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
119
|
+
<SectionHeader title="STREAM COMMS" />
|
|
120
|
+
<StreamComms childSessions={detail.child_sessions} parentSessionId={detail.session.id} />
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{/* Final result */}
|
|
125
|
+
{finalResult && (
|
|
126
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
127
|
+
<SectionHeader title="FINAL RESULT" />
|
|
128
|
+
<div className="flex gap-3 flex-wrap mt-2">
|
|
129
|
+
{finalResult.tests_passed !== undefined && (
|
|
130
|
+
<ResultBadge
|
|
131
|
+
label="TESTS"
|
|
132
|
+
pass={finalResult.tests_passed}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
{finalResult.lint_passed !== undefined && (
|
|
136
|
+
<ResultBadge
|
|
137
|
+
label="LINT"
|
|
138
|
+
pass={finalResult.lint_passed}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
{finalResult.typecheck_passed !== undefined && (
|
|
142
|
+
<ResultBadge
|
|
143
|
+
label="TYPECHECK"
|
|
144
|
+
pass={finalResult.typecheck_passed}
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
{finalResult.pr_url && (
|
|
148
|
+
<a
|
|
149
|
+
href={finalResult.pr_url}
|
|
150
|
+
target="_blank"
|
|
151
|
+
rel="noopener noreferrer"
|
|
152
|
+
className="text-xs text-info hover:underline"
|
|
153
|
+
>
|
|
154
|
+
PR #{finalResult.pr_number || ""}
|
|
155
|
+
</a>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{/* Plan */}
|
|
162
|
+
{detail.plan_steps && detail.plan_steps.length > 0 && (
|
|
163
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
164
|
+
<SectionHeader title={`PLAN (${detail.plan_steps.filter(s => s.status === "completed").length}/${detail.plan_steps.length} complete)`} />
|
|
165
|
+
<PlanProgress steps={detail.plan_steps} />
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{/* Timeline */}
|
|
170
|
+
{detail.events.length > 0 && (
|
|
171
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
172
|
+
<SectionHeader title={`TIMELINE (${detail.events.length} events)`} />
|
|
173
|
+
<Timeline events={detail.events} sessionStart={s.started_at} screenshots={detail.screenshots} />
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{/* Iterations */}
|
|
178
|
+
{detail.iterations.length > 0 && (
|
|
179
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
180
|
+
<SectionHeader title={`ITERATIONS (${detail.iterations.length})`} />
|
|
181
|
+
<IterationsTable iterations={detail.iterations} />
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{/* Screenshots */}
|
|
186
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
187
|
+
<SectionHeader title={`SCREENSHOTS (${detail.screenshots.length})`} />
|
|
188
|
+
{detail.screenshots.length > 0 && (
|
|
189
|
+
<ScreenshotsGrid screenshots={detail.screenshots} />
|
|
190
|
+
)}
|
|
191
|
+
<ScreenshotUpload sessionId={s.id} onUploaded={() => onReload?.()} />
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Artifacts */}
|
|
195
|
+
{detail.artifacts && detail.artifacts.length > 0 && (
|
|
196
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
197
|
+
<SectionHeader title={`ARTIFACTS (${detail.artifacts.length})`} />
|
|
198
|
+
<ArtifactsPanel artifacts={detail.artifacts} />
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* Insights — post-game analysis */}
|
|
203
|
+
{detail.insights && detail.insights.length > 0 && (
|
|
204
|
+
<div className="border border-border rounded-md p-4 bg-bg-elevated">
|
|
205
|
+
<SectionHeader title={`INSIGHTS (${detail.insights.length})`} />
|
|
206
|
+
<InsightsPanel insights={detail.insights} />
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function SectionHeader({ title }: { title: string }) {
|
|
214
|
+
return (
|
|
215
|
+
<div className="text-[10px] text-text-muted uppercase tracking-widest mb-2 font-bold">
|
|
216
|
+
<span className="text-accent mr-1">▸</span>
|
|
217
|
+
{title}
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function MetaField({ label, value }: { label: string; value: string }) {
|
|
223
|
+
return (
|
|
224
|
+
<div>
|
|
225
|
+
<div className="text-[9px] text-text-dim uppercase tracking-wider mb-0.5">
|
|
226
|
+
{label}
|
|
227
|
+
</div>
|
|
228
|
+
<div className="text-xs text-text truncate">{value}</div>
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function StatusBadge({ status }: { status: string }) {
|
|
234
|
+
const styles: Record<string, string> = {
|
|
235
|
+
completed: "border-accent text-accent",
|
|
236
|
+
failed: "border-error text-error",
|
|
237
|
+
in_progress: "border-warning text-warning",
|
|
238
|
+
interrupted: "border-text-dim text-text-dim",
|
|
239
|
+
}
|
|
240
|
+
return (
|
|
241
|
+
<span
|
|
242
|
+
className={cn(
|
|
243
|
+
"text-[10px] px-2 py-0.5 border rounded font-bold uppercase tracking-wider",
|
|
244
|
+
styles[status] || "border-text-dim text-text-dim"
|
|
245
|
+
)}
|
|
246
|
+
>
|
|
247
|
+
{status === "in_progress" ? "LIVE" : status}
|
|
248
|
+
</span>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function ResultBadge({ label, pass }: { label: string; pass: boolean }) {
|
|
253
|
+
return (
|
|
254
|
+
<span
|
|
255
|
+
className={cn(
|
|
256
|
+
"text-[10px] px-2 py-0.5 border rounded font-bold tracking-wider",
|
|
257
|
+
pass
|
|
258
|
+
? "border-accent text-accent"
|
|
259
|
+
: "border-error text-error"
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
262
|
+
{label}: {pass ? "PASS" : "FAIL"}
|
|
263
|
+
</span>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useMemo } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
import type { Session } from "@/types"
|
|
4
|
+
import { formatDuration, formatTokens, cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const FILTERS = [
|
|
7
|
+
{ label: "ALL", value: "" },
|
|
8
|
+
{ label: "PASS", value: "completed" },
|
|
9
|
+
{ label: "FAIL", value: "failed" },
|
|
10
|
+
{ label: "LIVE", value: "in_progress" },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
sessions: Session[]
|
|
15
|
+
activeId: string | null
|
|
16
|
+
filter: string
|
|
17
|
+
onSelect: (id: string) => void
|
|
18
|
+
onFilterChange: (f: string) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface WorktreeGroup {
|
|
22
|
+
name: string
|
|
23
|
+
sessions: Session[]
|
|
24
|
+
latest: Session
|
|
25
|
+
hasLive: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function workLabel(s: Session): string {
|
|
29
|
+
if (s.ticket_title) return s.ticket_title
|
|
30
|
+
if (s.ticket_id) return s.ticket_id
|
|
31
|
+
// Fall back to branch name or a short id
|
|
32
|
+
if (s.branch_name) return s.branch_name
|
|
33
|
+
return s.started_at.slice(0, 16).replace("T", " ")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SessionList({
|
|
37
|
+
sessions,
|
|
38
|
+
activeId,
|
|
39
|
+
filter,
|
|
40
|
+
onSelect,
|
|
41
|
+
onFilterChange,
|
|
42
|
+
}: Props) {
|
|
43
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
44
|
+
const prevCountRef = useRef(0)
|
|
45
|
+
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
|
46
|
+
|
|
47
|
+
// Auto-expand the group containing the active session
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!activeId) return
|
|
50
|
+
const session = sessions.find((s) => s.id === activeId)
|
|
51
|
+
if (session) {
|
|
52
|
+
setExpandedGroups((prev) => new Set([...prev, session.worktree_name]))
|
|
53
|
+
}
|
|
54
|
+
}, [activeId, sessions])
|
|
55
|
+
|
|
56
|
+
const groups = useMemo((): WorktreeGroup[] => {
|
|
57
|
+
const map = new Map<string, Session[]>()
|
|
58
|
+
for (const s of sessions) {
|
|
59
|
+
if (s.parent_session_id) continue
|
|
60
|
+
const existing = map.get(s.worktree_name) || []
|
|
61
|
+
existing.push(s)
|
|
62
|
+
map.set(s.worktree_name, existing)
|
|
63
|
+
}
|
|
64
|
+
return Array.from(map.entries())
|
|
65
|
+
.map(([name, sess]) => ({
|
|
66
|
+
name,
|
|
67
|
+
sessions: sess.sort((a, b) => b.created_at.localeCompare(a.created_at)),
|
|
68
|
+
latest: sess[0],
|
|
69
|
+
hasLive: sess.some((s) => s.status === "in_progress"),
|
|
70
|
+
}))
|
|
71
|
+
.sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at))
|
|
72
|
+
}, [sessions])
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!listRef.current) return
|
|
76
|
+
if (sessions.length === prevCountRef.current) return
|
|
77
|
+
const isInitial = prevCountRef.current === 0
|
|
78
|
+
prevCountRef.current = sessions.length
|
|
79
|
+
if (isInitial) {
|
|
80
|
+
const items = listRef.current.querySelectorAll(".session-item, .group-header")
|
|
81
|
+
gsap.fromTo(
|
|
82
|
+
items,
|
|
83
|
+
{ opacity: 0, x: -12 },
|
|
84
|
+
{ opacity: 1, x: 0, duration: 0.3, stagger: 0.04, ease: "power2.out" }
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}, [sessions])
|
|
88
|
+
|
|
89
|
+
const toggleGroup = (name: string) => {
|
|
90
|
+
setExpandedGroups((prev) => {
|
|
91
|
+
const next = new Set(prev)
|
|
92
|
+
if (next.has(name)) next.delete(name)
|
|
93
|
+
else next.add(name)
|
|
94
|
+
return next
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="w-[300px] min-w-[260px] border-r border-border flex flex-col flex-shrink-0">
|
|
100
|
+
{/* Filter bar */}
|
|
101
|
+
<div className="px-4 h-9 border-b border-border flex items-center justify-between sticky top-0 bg-bg z-10">
|
|
102
|
+
<span className="text-text-dim text-[10px]">
|
|
103
|
+
<span className="text-accent">$</span> sessions/
|
|
104
|
+
</span>
|
|
105
|
+
<div className="flex gap-1">
|
|
106
|
+
{FILTERS.map((f) => (
|
|
107
|
+
<button
|
|
108
|
+
key={f.value}
|
|
109
|
+
onClick={() => onFilterChange(f.value)}
|
|
110
|
+
className={cn(
|
|
111
|
+
"px-2 py-0.5 text-[10px] border font-mono transition-colors",
|
|
112
|
+
filter === f.value
|
|
113
|
+
? "border-accent text-accent bg-accent/5"
|
|
114
|
+
: "border-border text-text-dim hover:border-border-bright hover:text-text-muted"
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{f.label}
|
|
118
|
+
</button>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Sessions */}
|
|
124
|
+
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
125
|
+
{sessions.length === 0 && (
|
|
126
|
+
<div className="p-8 text-center text-text-dim text-xs">
|
|
127
|
+
<pre className="inline-block text-left">
|
|
128
|
+
{`> no sessions found
|
|
129
|
+
> waiting for data...
|
|
130
|
+
> _`}
|
|
131
|
+
</pre>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{groups.map((group) => {
|
|
136
|
+
const isExpanded = expandedGroups.has(group.name)
|
|
137
|
+
return (
|
|
138
|
+
<div key={group.name}>
|
|
139
|
+
{/* Group header — workspace name */}
|
|
140
|
+
<div
|
|
141
|
+
className={cn(
|
|
142
|
+
"group-header px-4 py-2 border-b border-border border-l-2 cursor-pointer hover:bg-bg-hover flex items-center gap-2",
|
|
143
|
+
group.hasLive ? "border-l-warning" : isExpanded ? "border-l-accent/40" : "border-l-transparent"
|
|
144
|
+
)}
|
|
145
|
+
onClick={() => toggleGroup(group.name)}
|
|
146
|
+
>
|
|
147
|
+
<span className="text-[10px] text-text-dim w-3">
|
|
148
|
+
{isExpanded ? "\u25BE" : "\u25B8"}
|
|
149
|
+
</span>
|
|
150
|
+
<StatusIndicator status={group.latest.status} />
|
|
151
|
+
<span className="text-[11px] font-bold text-text truncate flex-1">
|
|
152
|
+
{group.name}
|
|
153
|
+
</span>
|
|
154
|
+
<span className="text-[10px] text-text-dim flex-shrink-0">
|
|
155
|
+
{group.sessions.length}
|
|
156
|
+
</span>
|
|
157
|
+
{group.hasLive && (
|
|
158
|
+
<span className="inline-block w-1.5 h-1.5 bg-warning animate-pulse flex-shrink-0" />
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{/* Child sessions — work items */}
|
|
163
|
+
{isExpanded && (
|
|
164
|
+
<div data-expand>
|
|
165
|
+
{group.sessions.map((s) => (
|
|
166
|
+
<SessionItem
|
|
167
|
+
key={s.id}
|
|
168
|
+
session={s}
|
|
169
|
+
active={activeId === s.id}
|
|
170
|
+
onSelect={onSelect}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
})}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function SessionItem({
|
|
184
|
+
session: s,
|
|
185
|
+
active,
|
|
186
|
+
onSelect,
|
|
187
|
+
}: {
|
|
188
|
+
session: Session
|
|
189
|
+
active: boolean
|
|
190
|
+
onSelect: (id: string) => void
|
|
191
|
+
}) {
|
|
192
|
+
const totalTokens = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
onClick={() => onSelect(s.id)}
|
|
196
|
+
className={cn(
|
|
197
|
+
"session-item pl-9 pr-4 py-2 border-b border-border border-l-2 cursor-pointer transition-colors hover:bg-bg-hover",
|
|
198
|
+
active ? "bg-bg-hover border-l-accent" : "border-l-transparent"
|
|
199
|
+
)}
|
|
200
|
+
>
|
|
201
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
202
|
+
<StatusIndicator status={s.status} />
|
|
203
|
+
<span className="text-[11px] text-text truncate flex-1">
|
|
204
|
+
{workLabel(s)}
|
|
205
|
+
</span>
|
|
206
|
+
<span className="text-[10px] text-text-dim flex-shrink-0">
|
|
207
|
+
{formatDuration(s.duration_seconds)}
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="flex gap-3 text-[10px] text-text-dim pl-4">
|
|
211
|
+
<span>{s.total_iterations}/{s.max_iterations} iter</span>
|
|
212
|
+
<span>{formatTokens(totalTokens)} tok</span>
|
|
213
|
+
<span>{s.status}</span>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function StatusIndicator({ status }: { status: Session["status"] }) {
|
|
220
|
+
const colors: Record<string, string> = {
|
|
221
|
+
completed: "bg-accent",
|
|
222
|
+
failed: "bg-error",
|
|
223
|
+
in_progress: "bg-warning animate-pulse",
|
|
224
|
+
interrupted: "bg-text-dim",
|
|
225
|
+
}
|
|
226
|
+
return (
|
|
227
|
+
<span
|
|
228
|
+
className={cn(
|
|
229
|
+
"inline-block w-1.5 h-1.5 flex-shrink-0",
|
|
230
|
+
colors[status] || "bg-text-dim"
|
|
231
|
+
)}
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
}
|