@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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/app/agents/page.tsx +408 -0
  3. package/app/analytics/page.tsx +226 -0
  4. package/app/globals.css +33 -0
  5. package/app/layout.tsx +38 -0
  6. package/app/logs/page.tsx +233 -0
  7. package/app/page.tsx +140 -0
  8. package/app/playground/page.tsx +44 -0
  9. package/app/queue/page.tsx +295 -0
  10. package/app/sessions/page.tsx +529 -0
  11. package/app/settings/api-keys/page.tsx +222 -0
  12. package/app/settings/credentials/page.tsx +250 -0
  13. package/components/nav.tsx +151 -0
  14. package/components/providers.tsx +18 -0
  15. package/components/ui/badge.tsx +43 -0
  16. package/components/ui/button.tsx +44 -0
  17. package/components/ui/card.tsx +43 -0
  18. package/components/ui/empty-state.tsx +20 -0
  19. package/components/ui/input.tsx +36 -0
  20. package/components/ui/select.tsx +50 -0
  21. package/components/ui/shimmer.tsx +53 -0
  22. package/lib/client.ts +41 -0
  23. package/lib/exports.ts +55 -0
  24. package/lib/hooks.ts +169 -0
  25. package/lib/utils.ts +44 -0
  26. package/next.config.ts +28 -0
  27. package/out/404/index.html +1 -0
  28. package/out/404.html +1 -0
  29. package/out/_next/static/J9asKIV7Gq221ygeAP958/_buildManifest.js +1 -0
  30. package/out/_next/static/J9asKIV7Gq221ygeAP958/_ssgManifest.js +1 -0
  31. package/out/_next/static/chunks/322-bab4df5c5188e993.js +1 -0
  32. package/out/_next/static/chunks/432-11ec8af7ccfbd019.js +1 -0
  33. package/out/_next/static/chunks/447.6d3368efa2d996b0.js +1 -0
  34. package/out/_next/static/chunks/513-c4683887323154aa.js +1 -0
  35. package/out/_next/static/chunks/522-cf174cf1bbbe9557.js +1 -0
  36. package/out/_next/static/chunks/53-b012ce05184a4754.js +1 -0
  37. package/out/_next/static/chunks/929-6faf1adeb65ee383.js +1 -0
  38. package/out/_next/static/chunks/app/_not-found/page-04f9d3958a76bc38.js +1 -0
  39. package/out/_next/static/chunks/app/agents/page-8d68e3019b4d7077.js +1 -0
  40. package/out/_next/static/chunks/app/analytics/page-6b725a46e9c48019.js +1 -0
  41. package/out/_next/static/chunks/app/layout-9cae773a790a15b2.js +1 -0
  42. package/out/_next/static/chunks/app/logs/page-2efd945345a44a0e.js +1 -0
  43. package/out/_next/static/chunks/app/page-06f62e11f9cd82d5.js +1 -0
  44. package/out/_next/static/chunks/app/playground/page-10d3461f118bfb21.js +1 -0
  45. package/out/_next/static/chunks/app/queue/page-38e79b84cbd59335.js +1 -0
  46. package/out/_next/static/chunks/app/sessions/page-2a67c9eddfac029e.js +1 -0
  47. package/out/_next/static/chunks/app/settings/api-keys/page-619682cf8a1c26eb.js +1 -0
  48. package/out/_next/static/chunks/app/settings/credentials/page-106d0ba4f12afe81.js +1 -0
  49. package/out/_next/static/chunks/b59f762a-cea625f74e98e0aa.js +1 -0
  50. package/out/_next/static/chunks/framework-5a02266cf144994c.js +1 -0
  51. package/out/_next/static/chunks/main-994a6af9cdcd7fb9.js +1 -0
  52. package/out/_next/static/chunks/main-app-5bcd4dcc44c4ae09.js +1 -0
  53. package/out/_next/static/chunks/pages/_app-9fd734050704698a.js +1 -0
  54. package/out/_next/static/chunks/pages/_error-310ed5880fd5d5e6.js +1 -0
  55. package/out/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  56. package/out/_next/static/chunks/webpack-a50a78a04aed446d.js +1 -0
  57. package/out/_next/static/css/4b2beada31dbc623.css +1 -0
  58. package/out/agents/index.html +1 -0
  59. package/out/agents/index.txt +22 -0
  60. package/out/analytics/index.html +1 -0
  61. package/out/analytics/index.txt +22 -0
  62. package/out/index.html +1 -0
  63. package/out/index.txt +22 -0
  64. package/out/logs/index.html +1 -0
  65. package/out/logs/index.txt +22 -0
  66. package/out/playground/index.html +1 -0
  67. package/out/playground/index.txt +22 -0
  68. package/out/queue/index.html +1 -0
  69. package/out/queue/index.txt +22 -0
  70. package/out/sessions/index.html +1 -0
  71. package/out/sessions/index.txt +22 -0
  72. package/out/settings/api-keys/index.html +1 -0
  73. package/out/settings/api-keys/index.txt +22 -0
  74. package/out/settings/credentials/index.html +1 -0
  75. package/out/settings/credentials/index.txt +22 -0
  76. package/package.json +40 -0
  77. package/postcss.config.mjs +7 -0
  78. 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
+ }