@ash-ai/dashboard 0.0.1
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/LICENSE +21 -0
- package/app/agents/page.tsx +408 -0
- package/app/analytics/page.tsx +226 -0
- package/app/globals.css +33 -0
- package/app/layout.tsx +38 -0
- package/app/logs/page.tsx +233 -0
- package/app/page.tsx +140 -0
- package/app/playground/page.tsx +44 -0
- package/app/queue/page.tsx +295 -0
- package/app/sessions/page.tsx +529 -0
- package/app/settings/api-keys/page.tsx +222 -0
- package/app/settings/credentials/page.tsx +250 -0
- package/components/nav.tsx +151 -0
- package/components/providers.tsx +18 -0
- package/components/ui/badge.tsx +43 -0
- package/components/ui/button.tsx +44 -0
- package/components/ui/card.tsx +43 -0
- package/components/ui/empty-state.tsx +20 -0
- package/components/ui/input.tsx +36 -0
- package/components/ui/select.tsx +50 -0
- package/components/ui/shimmer.tsx +53 -0
- package/lib/client.ts +41 -0
- package/lib/exports.ts +55 -0
- package/lib/hooks.ts +169 -0
- package/lib/utils.ts +44 -0
- package/next.config.ts +28 -0
- package/out/404/index.html +1 -0
- package/out/404.html +1 -0
- package/out/_next/static/J9asKIV7Gq221ygeAP958/_buildManifest.js +1 -0
- package/out/_next/static/J9asKIV7Gq221ygeAP958/_ssgManifest.js +1 -0
- package/out/_next/static/chunks/322-bab4df5c5188e993.js +1 -0
- package/out/_next/static/chunks/432-11ec8af7ccfbd019.js +1 -0
- package/out/_next/static/chunks/447.6d3368efa2d996b0.js +1 -0
- package/out/_next/static/chunks/513-c4683887323154aa.js +1 -0
- package/out/_next/static/chunks/522-cf174cf1bbbe9557.js +1 -0
- package/out/_next/static/chunks/53-b012ce05184a4754.js +1 -0
- package/out/_next/static/chunks/929-6faf1adeb65ee383.js +1 -0
- package/out/_next/static/chunks/app/_not-found/page-04f9d3958a76bc38.js +1 -0
- package/out/_next/static/chunks/app/agents/page-8d68e3019b4d7077.js +1 -0
- package/out/_next/static/chunks/app/analytics/page-6b725a46e9c48019.js +1 -0
- package/out/_next/static/chunks/app/layout-9cae773a790a15b2.js +1 -0
- package/out/_next/static/chunks/app/logs/page-2efd945345a44a0e.js +1 -0
- package/out/_next/static/chunks/app/page-06f62e11f9cd82d5.js +1 -0
- package/out/_next/static/chunks/app/playground/page-10d3461f118bfb21.js +1 -0
- package/out/_next/static/chunks/app/queue/page-38e79b84cbd59335.js +1 -0
- package/out/_next/static/chunks/app/sessions/page-2a67c9eddfac029e.js +1 -0
- package/out/_next/static/chunks/app/settings/api-keys/page-619682cf8a1c26eb.js +1 -0
- package/out/_next/static/chunks/app/settings/credentials/page-106d0ba4f12afe81.js +1 -0
- package/out/_next/static/chunks/b59f762a-cea625f74e98e0aa.js +1 -0
- package/out/_next/static/chunks/framework-5a02266cf144994c.js +1 -0
- package/out/_next/static/chunks/main-994a6af9cdcd7fb9.js +1 -0
- package/out/_next/static/chunks/main-app-5bcd4dcc44c4ae09.js +1 -0
- package/out/_next/static/chunks/pages/_app-9fd734050704698a.js +1 -0
- package/out/_next/static/chunks/pages/_error-310ed5880fd5d5e6.js +1 -0
- package/out/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/out/_next/static/chunks/webpack-a50a78a04aed446d.js +1 -0
- package/out/_next/static/css/4b2beada31dbc623.css +1 -0
- package/out/agents/index.html +1 -0
- package/out/agents/index.txt +22 -0
- package/out/analytics/index.html +1 -0
- package/out/analytics/index.txt +22 -0
- package/out/index.html +1 -0
- package/out/index.txt +22 -0
- package/out/logs/index.html +1 -0
- package/out/logs/index.txt +22 -0
- package/out/playground/index.html +1 -0
- package/out/playground/index.txt +22 -0
- package/out/queue/index.html +1 -0
- package/out/queue/index.txt +22 -0
- package/out/sessions/index.html +1 -0
- package/out/sessions/index.txt +22 -0
- package/out/settings/api-keys/index.html +1 -0
- package/out/settings/api-keys/index.txt +22 -0
- package/out/settings/credentials/index.html +1 -0
- package/out/settings/credentials/index.txt +22 -0
- package/package.json +40 -0
- package/postcss.config.mjs +7 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
|
|
4
|
+
import { useSearchParams } from 'next/navigation'
|
|
5
|
+
import { useAgents, useSessions } from '@/lib/hooks'
|
|
6
|
+
import { getClient } from '@/lib/client'
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
import { Badge, StatusBadge } from '@/components/ui/badge'
|
|
10
|
+
import { EmptyState } from '@/components/ui/empty-state'
|
|
11
|
+
import { Select } from '@/components/ui/select'
|
|
12
|
+
import { ShimmerBlock } from '@/components/ui/shimmer'
|
|
13
|
+
import { cn, formatRelativeTime, truncateId } from '@/lib/utils'
|
|
14
|
+
import {
|
|
15
|
+
Activity,
|
|
16
|
+
ChevronDown,
|
|
17
|
+
ChevronRight,
|
|
18
|
+
Copy,
|
|
19
|
+
Download,
|
|
20
|
+
FileText,
|
|
21
|
+
MessageSquare,
|
|
22
|
+
Pause,
|
|
23
|
+
Play,
|
|
24
|
+
Square,
|
|
25
|
+
Terminal,
|
|
26
|
+
} from 'lucide-react'
|
|
27
|
+
import type { Session, Message, SessionEvent } from '@ash-ai/shared'
|
|
28
|
+
|
|
29
|
+
export default function SessionsPage() {
|
|
30
|
+
return (
|
|
31
|
+
<Suspense fallback={<div className="text-sm text-white/40">Loading...</div>}>
|
|
32
|
+
<SessionsPageInner />
|
|
33
|
+
</Suspense>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function SessionsPageInner() {
|
|
38
|
+
const searchParams = useSearchParams()
|
|
39
|
+
const initialId = searchParams.get('id')
|
|
40
|
+
|
|
41
|
+
const { agents } = useAgents()
|
|
42
|
+
const [agentFilter, setAgentFilter] = useState<string>('')
|
|
43
|
+
const [statusFilter, setStatusFilter] = useState<string>('')
|
|
44
|
+
const { sessions, loading, refetch } = useSessions({
|
|
45
|
+
agent: agentFilter || undefined,
|
|
46
|
+
autoRefresh: true,
|
|
47
|
+
})
|
|
48
|
+
const [selectedId, setSelectedId] = useState<string | null>(initialId)
|
|
49
|
+
|
|
50
|
+
// Auto-select first session
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!selectedId && sessions.length > 0) {
|
|
53
|
+
setSelectedId(sessions[0].id)
|
|
54
|
+
}
|
|
55
|
+
}, [sessions, selectedId])
|
|
56
|
+
|
|
57
|
+
const filteredSessions = sessions.filter((s) => {
|
|
58
|
+
if (statusFilter && s.status !== statusFilter) return false
|
|
59
|
+
return true
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const selectedSession = sessions.find((s) => s.id === selectedId)
|
|
63
|
+
|
|
64
|
+
const agentOptions = [
|
|
65
|
+
{ value: '', label: 'All Agents' },
|
|
66
|
+
...agents.map((a) => ({ value: a.name, label: a.name })),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const statusOptions = [
|
|
70
|
+
{ value: '', label: 'All Statuses' },
|
|
71
|
+
{ value: 'active', label: 'Active' },
|
|
72
|
+
{ value: 'paused', label: 'Paused' },
|
|
73
|
+
{ value: 'ended', label: 'Ended' },
|
|
74
|
+
{ value: 'error', label: 'Error' },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex h-[calc(100vh-5rem)] gap-4">
|
|
79
|
+
{/* Left: Session List */}
|
|
80
|
+
<div className="w-80 shrink-0 flex flex-col">
|
|
81
|
+
<div className="mb-4">
|
|
82
|
+
<h1 className="text-2xl font-bold text-white">Sessions</h1>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex gap-2 mb-3">
|
|
85
|
+
<Select
|
|
86
|
+
options={agentOptions}
|
|
87
|
+
value={agentFilter}
|
|
88
|
+
onChange={(e) => setAgentFilter(e.target.value)}
|
|
89
|
+
className="flex-1 h-8 text-xs"
|
|
90
|
+
/>
|
|
91
|
+
<Select
|
|
92
|
+
options={statusOptions}
|
|
93
|
+
value={statusFilter}
|
|
94
|
+
onChange={(e) => setStatusFilter(e.target.value)}
|
|
95
|
+
className="flex-1 h-8 text-xs"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="flex-1 overflow-auto space-y-1 scrollbar-thin">
|
|
99
|
+
{loading ? (
|
|
100
|
+
<div className="space-y-2">
|
|
101
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
102
|
+
<ShimmerBlock key={i} height={64} />
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
) : filteredSessions.length === 0 ? (
|
|
106
|
+
<p className="text-sm text-white/40 text-center py-8">
|
|
107
|
+
No sessions found
|
|
108
|
+
</p>
|
|
109
|
+
) : (
|
|
110
|
+
filteredSessions.map((session) => (
|
|
111
|
+
<button
|
|
112
|
+
key={session.id}
|
|
113
|
+
onClick={() => setSelectedId(session.id)}
|
|
114
|
+
className={cn(
|
|
115
|
+
'w-full text-left rounded-lg border px-3 py-2.5 transition-colors',
|
|
116
|
+
selectedId === session.id
|
|
117
|
+
? 'bg-indigo-500/10 border-indigo-500/30'
|
|
118
|
+
: 'border-transparent hover:bg-white/5'
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
<div className="flex items-center justify-between">
|
|
122
|
+
<span className="text-sm font-medium text-white truncate">
|
|
123
|
+
{session.agentName}
|
|
124
|
+
</span>
|
|
125
|
+
<StatusBadge status={session.status} />
|
|
126
|
+
</div>
|
|
127
|
+
<div className="flex items-center justify-between mt-1">
|
|
128
|
+
<span className="text-xs text-white/30 font-mono">
|
|
129
|
+
{truncateId(session.id)}
|
|
130
|
+
</span>
|
|
131
|
+
<span className="text-xs text-white/30">
|
|
132
|
+
{formatRelativeTime(session.createdAt)}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
</button>
|
|
136
|
+
))
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Right: Detail */}
|
|
142
|
+
<div className="flex-1 min-w-0">
|
|
143
|
+
{selectedSession ? (
|
|
144
|
+
<SessionDetail session={selectedSession} />
|
|
145
|
+
) : (
|
|
146
|
+
<EmptyState
|
|
147
|
+
icon={<Activity className="h-12 w-12" />}
|
|
148
|
+
title="Select a session"
|
|
149
|
+
description="Choose a session from the list to view details"
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Session Detail ───
|
|
158
|
+
|
|
159
|
+
function SessionDetail({ session }: { session: Session }) {
|
|
160
|
+
const [tab, setTab] = useState<'messages' | 'events' | 'terminal'>('messages')
|
|
161
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
162
|
+
const [events, setEvents] = useState<SessionEvent[]>([])
|
|
163
|
+
const [logs, setLogs] = useState<string[]>([])
|
|
164
|
+
const [loadingData, setLoadingData] = useState(true)
|
|
165
|
+
|
|
166
|
+
const fetchData = useCallback(async () => {
|
|
167
|
+
setLoadingData(true)
|
|
168
|
+
try {
|
|
169
|
+
const client = getClient()
|
|
170
|
+
const [msgs, evts] = await Promise.all([
|
|
171
|
+
client.listMessages(session.id).catch(() => []),
|
|
172
|
+
client.listSessionEvents(session.id).catch(() => []),
|
|
173
|
+
])
|
|
174
|
+
setMessages(msgs)
|
|
175
|
+
setEvents(evts)
|
|
176
|
+
} catch {
|
|
177
|
+
// Silently handle errors
|
|
178
|
+
} finally {
|
|
179
|
+
setLoadingData(false)
|
|
180
|
+
}
|
|
181
|
+
}, [session.id])
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
fetchData()
|
|
185
|
+
}, [fetchData])
|
|
186
|
+
|
|
187
|
+
// Auto-refresh for active sessions
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (session.status !== 'active') return
|
|
190
|
+
const interval = setInterval(fetchData, 5000)
|
|
191
|
+
return () => clearInterval(interval)
|
|
192
|
+
}, [session.status, fetchData])
|
|
193
|
+
|
|
194
|
+
// Fetch logs when terminal tab is selected
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (tab !== 'terminal') return
|
|
197
|
+
const fetchLogs = async () => {
|
|
198
|
+
try {
|
|
199
|
+
const result = await getClient().getSessionLogs(session.id)
|
|
200
|
+
if (result?.logs) {
|
|
201
|
+
setLogs(result.logs.map((l) => l.text))
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
setLogs(['Failed to load logs'])
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
fetchLogs()
|
|
208
|
+
if (session.status === 'active') {
|
|
209
|
+
const interval = setInterval(fetchLogs, 2000)
|
|
210
|
+
return () => clearInterval(interval)
|
|
211
|
+
}
|
|
212
|
+
}, [tab, session.id, session.status])
|
|
213
|
+
|
|
214
|
+
async function handleAction(action: 'pause' | 'resume' | 'stop' | 'end') {
|
|
215
|
+
const client = getClient()
|
|
216
|
+
try {
|
|
217
|
+
if (action === 'pause') await client.pauseSession(session.id)
|
|
218
|
+
else if (action === 'resume') await client.resumeSession(session.id)
|
|
219
|
+
else if (action === 'stop') await client.stopSession(session.id)
|
|
220
|
+
else if (action === 'end') await client.endSession(session.id)
|
|
221
|
+
} catch (e) {
|
|
222
|
+
console.error(`Failed to ${action} session:`, e)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<Card className="h-full flex flex-col">
|
|
228
|
+
{/* Header */}
|
|
229
|
+
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
|
230
|
+
<div>
|
|
231
|
+
<div className="flex items-center gap-3">
|
|
232
|
+
<h2 className="text-lg font-semibold text-white">{session.agentName}</h2>
|
|
233
|
+
<StatusBadge status={session.status} />
|
|
234
|
+
</div>
|
|
235
|
+
<div className="flex items-center gap-4 mt-1">
|
|
236
|
+
<span className="text-xs text-white/30 font-mono">{session.id}</span>
|
|
237
|
+
<span className="text-xs text-white/30">
|
|
238
|
+
{formatRelativeTime(session.createdAt)}
|
|
239
|
+
</span>
|
|
240
|
+
{session.model && <Badge variant="info">{session.model}</Badge>}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="flex items-center gap-2">
|
|
244
|
+
{session.status === 'active' && (
|
|
245
|
+
<>
|
|
246
|
+
<Button size="sm" variant="ghost" onClick={() => handleAction('pause')} title="Pause">
|
|
247
|
+
<Pause className="h-4 w-4" />
|
|
248
|
+
</Button>
|
|
249
|
+
<Button size="sm" variant="ghost" onClick={() => handleAction('stop')} title="Stop">
|
|
250
|
+
<Square className="h-4 w-4" />
|
|
251
|
+
</Button>
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
{(session.status === 'paused' || session.status === 'stopped') && (
|
|
255
|
+
<Button size="sm" variant="ghost" onClick={() => handleAction('resume')} title="Resume">
|
|
256
|
+
<Play className="h-4 w-4" />
|
|
257
|
+
</Button>
|
|
258
|
+
)}
|
|
259
|
+
<Button
|
|
260
|
+
size="sm"
|
|
261
|
+
variant="ghost"
|
|
262
|
+
onClick={() => navigator.clipboard.writeText(session.id)}
|
|
263
|
+
title="Copy ID"
|
|
264
|
+
>
|
|
265
|
+
<Copy className="h-4 w-4" />
|
|
266
|
+
</Button>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* Tabs */}
|
|
271
|
+
<div className="flex gap-1 px-6 py-2 border-b border-white/10">
|
|
272
|
+
{[
|
|
273
|
+
{ key: 'messages', label: 'Messages', icon: MessageSquare, count: messages.length },
|
|
274
|
+
{ key: 'events', label: 'Events', icon: Activity, count: events.length },
|
|
275
|
+
{ key: 'terminal', label: 'Terminal', icon: Terminal },
|
|
276
|
+
].map(({ key, label, icon: Icon, count }) => (
|
|
277
|
+
<button
|
|
278
|
+
key={key}
|
|
279
|
+
onClick={() => setTab(key as typeof tab)}
|
|
280
|
+
className={cn(
|
|
281
|
+
'flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md transition-colors',
|
|
282
|
+
tab === key
|
|
283
|
+
? 'bg-white/10 text-white'
|
|
284
|
+
: 'text-white/40 hover:text-white/70'
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
<Icon className="h-3.5 w-3.5" />
|
|
288
|
+
{label}
|
|
289
|
+
{count !== undefined && count > 0 && (
|
|
290
|
+
<span className="text-xs text-white/30">{count}</span>
|
|
291
|
+
)}
|
|
292
|
+
</button>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
{/* Tab Content */}
|
|
297
|
+
<div className="flex-1 overflow-auto p-6 scrollbar-thin">
|
|
298
|
+
{loadingData ? (
|
|
299
|
+
<div className="space-y-3">
|
|
300
|
+
{[1, 2, 3].map((i) => (
|
|
301
|
+
<ShimmerBlock key={i} height={60} />
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
) : tab === 'messages' ? (
|
|
305
|
+
<MessagesTab messages={messages} />
|
|
306
|
+
) : tab === 'events' ? (
|
|
307
|
+
<EventsTab events={events} />
|
|
308
|
+
) : (
|
|
309
|
+
<TerminalTab logs={logs} />
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
</Card>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Messages Tab ───
|
|
317
|
+
|
|
318
|
+
function MessagesTab({ messages }: { messages: Message[] }) {
|
|
319
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
320
|
+
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
323
|
+
}, [messages.length])
|
|
324
|
+
|
|
325
|
+
if (messages.length === 0) {
|
|
326
|
+
return (
|
|
327
|
+
<p className="text-sm text-white/40 text-center py-8">
|
|
328
|
+
No messages yet
|
|
329
|
+
</p>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div className="space-y-4">
|
|
335
|
+
{messages.map((msg, i) => (
|
|
336
|
+
<MessageBlock key={msg.id || i} message={msg} />
|
|
337
|
+
))}
|
|
338
|
+
<div ref={bottomRef} />
|
|
339
|
+
</div>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function MessageBlock({ message }: { message: Message }) {
|
|
344
|
+
const isUser = message.role === 'user'
|
|
345
|
+
const [expanded, setExpanded] = useState(false)
|
|
346
|
+
|
|
347
|
+
let displayContent = ''
|
|
348
|
+
let toolCalls: Array<{ id?: string; name: string; input?: unknown }> = []
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const parsed = JSON.parse(message.content)
|
|
352
|
+
if (Array.isArray(parsed)) {
|
|
353
|
+
const textBlocks = parsed.filter(
|
|
354
|
+
(b: Record<string, unknown>) => b.type === 'text'
|
|
355
|
+
)
|
|
356
|
+
toolCalls = parsed.filter(
|
|
357
|
+
(b: Record<string, unknown>) => b.type === 'tool_use'
|
|
358
|
+
) as typeof toolCalls
|
|
359
|
+
displayContent = textBlocks
|
|
360
|
+
.map((b: Record<string, unknown>) => String(b.text || ''))
|
|
361
|
+
.join('\n')
|
|
362
|
+
} else if (typeof parsed === 'string') {
|
|
363
|
+
displayContent = parsed
|
|
364
|
+
} else {
|
|
365
|
+
displayContent = message.content
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
displayContent = message.content
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<div className={cn('rounded-lg border p-4', isUser ? 'border-blue-500/20 bg-blue-500/5' : 'border-white/5 bg-white/[0.02]')}>
|
|
373
|
+
<div className="flex items-center gap-2 mb-2">
|
|
374
|
+
<Badge variant={isUser ? 'info' : 'default'}>
|
|
375
|
+
{isUser ? 'User' : 'Assistant'}
|
|
376
|
+
</Badge>
|
|
377
|
+
{message.createdAt && (
|
|
378
|
+
<span className="text-xs text-white/30">
|
|
379
|
+
{formatRelativeTime(message.createdAt)}
|
|
380
|
+
</span>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
{displayContent && (
|
|
384
|
+
<div className="text-sm text-white/80 whitespace-pre-wrap">{displayContent}</div>
|
|
385
|
+
)}
|
|
386
|
+
{toolCalls.length > 0 && (
|
|
387
|
+
<div className="mt-3 space-y-2">
|
|
388
|
+
{toolCalls.map((tc, idx) => (
|
|
389
|
+
<ToolCallDisplay key={tc.id || idx} toolCall={tc} />
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function ToolCallDisplay({ toolCall }: { toolCall: { id?: string; name: string; input?: unknown } }) {
|
|
398
|
+
const [expanded, setExpanded] = useState(false)
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<div className="rounded-md border border-white/10 bg-black/20 overflow-hidden">
|
|
402
|
+
<button
|
|
403
|
+
onClick={() => setExpanded(!expanded)}
|
|
404
|
+
aria-expanded={expanded}
|
|
405
|
+
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-white/60 hover:text-white/80 transition-colors"
|
|
406
|
+
>
|
|
407
|
+
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
408
|
+
<span className="font-mono text-indigo-400">{toolCall.name}</span>
|
|
409
|
+
</button>
|
|
410
|
+
{expanded && (
|
|
411
|
+
<div className="px-3 pb-3">
|
|
412
|
+
<div className="text-xs text-white/40 mb-1">Input:</div>
|
|
413
|
+
<pre className="text-xs text-white/60 overflow-auto max-h-48 bg-black/30 rounded p-2">
|
|
414
|
+
{JSON.stringify(toolCall.input, null, 2)}
|
|
415
|
+
</pre>
|
|
416
|
+
</div>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── Events Tab ───
|
|
423
|
+
|
|
424
|
+
function EventsTab({ events }: { events: SessionEvent[] }) {
|
|
425
|
+
if (events.length === 0) {
|
|
426
|
+
return (
|
|
427
|
+
<p className="text-sm text-white/40 text-center py-8">
|
|
428
|
+
No events recorded
|
|
429
|
+
</p>
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const eventTypeColors: Record<string, string> = {
|
|
434
|
+
text: 'text-blue-400',
|
|
435
|
+
tool_start: 'text-purple-400',
|
|
436
|
+
tool_result: 'text-purple-400',
|
|
437
|
+
reasoning: 'text-amber-400',
|
|
438
|
+
error: 'text-red-400',
|
|
439
|
+
turn_complete: 'text-green-400',
|
|
440
|
+
lifecycle: 'text-zinc-400',
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<div className="space-y-1">
|
|
445
|
+
{events.map((event, i) => (
|
|
446
|
+
<EventRow key={event.id || i} event={event} typeColors={eventTypeColors} />
|
|
447
|
+
))}
|
|
448
|
+
</div>
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function EventRow({
|
|
453
|
+
event,
|
|
454
|
+
typeColors,
|
|
455
|
+
}: {
|
|
456
|
+
event: SessionEvent
|
|
457
|
+
typeColors: Record<string, string>
|
|
458
|
+
}) {
|
|
459
|
+
const [expanded, setExpanded] = useState(false)
|
|
460
|
+
const color = typeColors[event.type] || 'text-white/40'
|
|
461
|
+
|
|
462
|
+
let summary = ''
|
|
463
|
+
try {
|
|
464
|
+
const data =
|
|
465
|
+
typeof event.data === 'string' ? JSON.parse(event.data) : event.data
|
|
466
|
+
if (data && typeof data === 'object') {
|
|
467
|
+
const d = data as Record<string, unknown>
|
|
468
|
+
if (typeof d.text === 'string') summary = d.text.slice(0, 100)
|
|
469
|
+
else if (typeof d.name === 'string') summary = d.name
|
|
470
|
+
else if (typeof d.error === 'string') summary = d.error.slice(0, 100)
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
// event.data is not valid JSON — ignore
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<div className="rounded-md border border-white/5 bg-white/[0.01]">
|
|
478
|
+
<button
|
|
479
|
+
onClick={() => setExpanded(!expanded)}
|
|
480
|
+
aria-expanded={expanded}
|
|
481
|
+
className="flex items-center gap-3 w-full px-3 py-2 text-sm hover:bg-white/[0.02] transition-colors"
|
|
482
|
+
>
|
|
483
|
+
{expanded ? <ChevronDown className="h-3 w-3 text-white/30" /> : <ChevronRight className="h-3 w-3 text-white/30" />}
|
|
484
|
+
<span className="text-xs text-white/20 font-mono w-8">#{event.sequence}</span>
|
|
485
|
+
<Badge className={color}>{event.type}</Badge>
|
|
486
|
+
<span className="text-xs text-white/40 truncate flex-1 text-left">{summary}</span>
|
|
487
|
+
<span className="text-xs text-white/20">
|
|
488
|
+
{event.createdAt ? formatRelativeTime(event.createdAt) : ''}
|
|
489
|
+
</span>
|
|
490
|
+
</button>
|
|
491
|
+
{expanded && (
|
|
492
|
+
<div className="px-3 pb-3 pt-1">
|
|
493
|
+
<pre className="text-xs text-white/50 overflow-auto max-h-64 bg-black/30 rounded p-2">
|
|
494
|
+
{typeof event.data === 'string' ? event.data : JSON.stringify(event.data, null, 2)}
|
|
495
|
+
</pre>
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ─── Terminal Tab ───
|
|
503
|
+
|
|
504
|
+
function TerminalTab({ logs }: { logs: string[] }) {
|
|
505
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
506
|
+
|
|
507
|
+
useEffect(() => {
|
|
508
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
509
|
+
}, [logs.length])
|
|
510
|
+
|
|
511
|
+
if (logs.length === 0) {
|
|
512
|
+
return (
|
|
513
|
+
<p className="text-sm text-white/40 text-center py-8">
|
|
514
|
+
No terminal output
|
|
515
|
+
</p>
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<div className="bg-black/40 rounded-lg p-4 font-mono text-xs">
|
|
521
|
+
{logs.map((line, i) => (
|
|
522
|
+
<div key={i} className="text-white/60 whitespace-pre-wrap">
|
|
523
|
+
{line}
|
|
524
|
+
</div>
|
|
525
|
+
))}
|
|
526
|
+
<div ref={bottomRef} />
|
|
527
|
+
</div>
|
|
528
|
+
)
|
|
529
|
+
}
|