@standardagents/cli 0.10.1-next.bbd142a → 0.11.0-next.ab7e1ea

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.
@@ -0,0 +1,796 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { useThread } from '@standardagents/react'
6
+ import type { Message, WorkMessage, ThreadMessage } from '@standardagents/react'
7
+ import { Markdown } from './Markdown'
8
+
9
+ // Get API config
10
+ function getApiConfig() {
11
+ const isVite = typeof import.meta !== 'undefined' && import.meta.env
12
+ const baseUrl = isVite
13
+ ? (import.meta.env.VITE_AGENTBUILDER_URL || '')
14
+ : (process.env.NEXT_PUBLIC_AGENTBUILDER_URL || '')
15
+
16
+ // Token is stored in localStorage by auth flow
17
+ const token = typeof localStorage !== 'undefined'
18
+ ? localStorage.getItem('agentbuilder_auth_token') || ''
19
+ : ''
20
+
21
+ return { baseUrl, token }
22
+ }
23
+
24
+ // Attachment type from API response
25
+ interface Attachment {
26
+ id: string
27
+ type: 'file'
28
+ path: string
29
+ name: string
30
+ mimeType: string
31
+ size?: number
32
+ width?: number
33
+ height?: number
34
+ description?: string
35
+ localPreviewUrl?: string // Optimistic preview data URL for images
36
+ }
37
+
38
+ // Extended message type with attachments
39
+ interface MessageWithAttachments extends Message {
40
+ attachments?: Attachment[] | null
41
+ }
42
+
43
+ // Type guard to check if a message is a WorkMessage
44
+ function isWorkMessage(msg: ThreadMessage): msg is WorkMessage {
45
+ return 'type' in msg && msg.type === 'workblock'
46
+ }
47
+
48
+ // Get the API endpoint from environment
49
+ function getApiEndpoint(): string {
50
+ const isVite = typeof import.meta !== 'undefined' && import.meta.env
51
+ return isVite
52
+ ? (import.meta.env.VITE_AGENTBUILDER_URL || '')
53
+ : (process.env.NEXT_PUBLIC_AGENTBUILDER_URL || '')
54
+ }
55
+
56
+ // Type for OpenAI-style multipart content
57
+ interface TextContent {
58
+ type: 'text'
59
+ text: string
60
+ }
61
+
62
+ interface ImageUrlContent {
63
+ type: 'image_url'
64
+ image_url: {
65
+ url: string
66
+ detail?: 'low' | 'high' | 'auto'
67
+ }
68
+ }
69
+
70
+ type ContentPart = TextContent | ImageUrlContent
71
+
72
+ // Parse message content - handles both string and multipart array formats
73
+ function parseMessageContent(content: string | null): { text: string | null; images: string[] } {
74
+ if (!content) return { text: null, images: [] }
75
+
76
+ // Try to parse as JSON array (multipart content - OpenAI format)
77
+ try {
78
+ const parsed = JSON.parse(content)
79
+ if (Array.isArray(parsed)) {
80
+ const texts: string[] = []
81
+ const images: string[] = []
82
+
83
+ for (const part of parsed as ContentPart[]) {
84
+ if (part.type === 'text' && 'text' in part) {
85
+ texts.push(part.text)
86
+ } else if (part.type === 'image_url' && 'image_url' in part) {
87
+ images.push(part.image_url.url)
88
+ }
89
+ }
90
+
91
+ return {
92
+ text: texts.length > 0 ? texts.join('\n') : null,
93
+ images
94
+ }
95
+ }
96
+ } catch {
97
+ // Not JSON, continue with text parsing
98
+ }
99
+
100
+ // Check for inline base64 images or image URLs in the text
101
+ const images: string[] = []
102
+ let textContent = content
103
+
104
+ // Extract markdown images: ![alt](url)
105
+ const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
106
+ let match
107
+ while ((match = markdownImageRegex.exec(content)) !== null) {
108
+ images.push(match[2])
109
+ }
110
+ // Remove markdown images from text
111
+ textContent = textContent.replace(markdownImageRegex, '')
112
+
113
+ // Extract standalone image URLs (common image extensions)
114
+ const imageUrlRegex = /(?:^|\s)(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?[^\s]*)?)/gi
115
+ while ((match = imageUrlRegex.exec(content)) !== null) {
116
+ if (!images.includes(match[1])) {
117
+ images.push(match[1])
118
+ }
119
+ }
120
+
121
+ // Extract base64 data URLs
122
+ const base64Regex = /(data:image\/[^;]+;base64,[^\s]+)/g
123
+ while ((match = base64Regex.exec(content)) !== null) {
124
+ images.push(match[1])
125
+ }
126
+
127
+ // Clean up text - trim excess whitespace from removed images
128
+ textContent = textContent.replace(/\n{3,}/g, '\n\n').trim()
129
+
130
+ return {
131
+ text: textContent || null,
132
+ images
133
+ }
134
+ }
135
+
136
+ // Icon components for workblocks
137
+ function ToolIcon({ className }: { className?: string }) {
138
+ return (
139
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
140
+ <path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
141
+ </svg>
142
+ )
143
+ }
144
+
145
+ function CheckIcon({ className }: { className?: string }) {
146
+ return (
147
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
148
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
149
+ </svg>
150
+ )
151
+ }
152
+
153
+ function ErrorIcon({ className }: { className?: string }) {
154
+ return (
155
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
156
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
157
+ </svg>
158
+ )
159
+ }
160
+
161
+ function SpinnerIcon({ className }: { className?: string }) {
162
+ return (
163
+ <svg className={`animate-spin ${className}`} fill="none" viewBox="0 0 24 24">
164
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
165
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
166
+ </svg>
167
+ )
168
+ }
169
+
170
+ // Action icons for message actions (smooth outline style)
171
+ function CopyIcon({ className }: { className?: string }) {
172
+ return (
173
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
174
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
175
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
176
+ </svg>
177
+ )
178
+ }
179
+
180
+ function TrashIcon({ className }: { className?: string }) {
181
+ return (
182
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
183
+ <path d="M3 6h18" />
184
+ <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
185
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
186
+ </svg>
187
+ )
188
+ }
189
+
190
+ function RewindIcon({ className }: { className?: string }) {
191
+ return (
192
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
193
+ <path d="M1 4v6h6" />
194
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
195
+ </svg>
196
+ )
197
+ }
198
+
199
+ function BranchIcon({ className }: { className?: string }) {
200
+ return (
201
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
202
+ <path d="M6 3v12" />
203
+ <circle cx="18" cy="6" r="3" />
204
+ <circle cx="6" cy="18" r="3" />
205
+ <path d="M18 9a9 9 0 0 1-9 9" />
206
+ </svg>
207
+ )
208
+ }
209
+
210
+ // Format tool name for display
211
+ function formatToolName(name: string | undefined): string {
212
+ if (!name) return 'Running tool'
213
+ // Convert snake_case or camelCase to Title Case
214
+ return name
215
+ .replace(/_/g, ' ')
216
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
217
+ .replace(/\b\w/g, c => c.toUpperCase())
218
+ }
219
+
220
+ // Get status indicator for a work item
221
+ function WorkItemStatus({ status }: { status: 'pending' | 'success' | 'error' | null | undefined }) {
222
+ if (status === 'success') {
223
+ return <CheckIcon className="w-3.5 h-3.5 text-emerald-500" />
224
+ }
225
+ if (status === 'error') {
226
+ return <ErrorIcon className="w-3.5 h-3.5 text-red-500" />
227
+ }
228
+ return <SpinnerIcon className="w-3.5 h-3.5 text-[var(--text-secondary)]" />
229
+ }
230
+
231
+ // Workblock component
232
+ function Workblock({ workblock }: { workblock: WorkMessage }) {
233
+ const { text } = parseMessageContent(workblock.content)
234
+ const isPending = workblock.status === 'pending'
235
+
236
+ // Group work items by tool_call_id to pair calls with results
237
+ const toolCalls = workblock.workItems.filter(item => item.type === 'tool_call')
238
+
239
+ // Count completed vs total
240
+ const completedCount = toolCalls.filter(item => item.status === 'success' || item.status === 'error').length
241
+ const totalCount = toolCalls.length
242
+
243
+ return (
244
+ <div className="animate-fade-in space-y-3">
245
+ {/* Tool calls summary */}
246
+ <div className={`bg-[var(--bg-tertiary)] border border-[var(--border-primary)] rounded-xl overflow-hidden max-w-[85%] ${isPending ? 'workblock-shimmer' : ''}`}>
247
+ <div className="px-3 py-2 bg-[var(--bg-hover)] border-b border-[var(--border-primary)] flex items-center justify-between">
248
+ <div className="flex items-center gap-2">
249
+ <span className={`text-xs font-medium uppercase tracking-wide ${isPending ? 'text-violet-500' : 'text-emerald-500'}`}>
250
+ {isPending ? 'Working' : 'Completed'}
251
+ </span>
252
+ {isPending && (
253
+ <SpinnerIcon className="w-3 h-3 text-violet-500" />
254
+ )}
255
+ </div>
256
+ {totalCount > 1 && (
257
+ <span className="text-xs text-[var(--text-tertiary)]">
258
+ {completedCount}/{totalCount}
259
+ </span>
260
+ )}
261
+ </div>
262
+
263
+ <div className="divide-y divide-[var(--border-primary)]">
264
+ {toolCalls.map((item, index) => (
265
+ <div
266
+ key={item.id}
267
+ className="px-3 py-2.5 flex items-center gap-3 animate-slide-in"
268
+ style={{ animationDelay: `${index * 50}ms` }}
269
+ >
270
+ <WorkItemStatus status={item.status} />
271
+ <span className="text-sm text-[var(--text-primary)]">
272
+ {formatToolName(item.name)}
273
+ </span>
274
+ </div>
275
+ ))}
276
+ </div>
277
+ </div>
278
+
279
+ {/* Text content if present */}
280
+ {text && (
281
+ <div className="max-w-[85%]">
282
+ <div className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text-primary)]">
283
+ {text}
284
+ </div>
285
+ </div>
286
+ )}
287
+ </div>
288
+ )
289
+ }
290
+
291
+ // Image attachment component
292
+ function ImageAttachment({ url, name, width, height, onLoad }: { url: string; name?: string; width?: number; height?: number; onLoad?: () => void }) {
293
+ // Use aspect-ratio to reserve space and prevent layout shift
294
+ const style: React.CSSProperties = {}
295
+ if (width && height) {
296
+ style.aspectRatio = `${width} / ${height}`
297
+ // Cap the width to max-w-sm (384px) while maintaining aspect ratio
298
+ style.width = Math.min(width, 384)
299
+ }
300
+
301
+ return (
302
+ <div
303
+ className="rounded-lg overflow-hidden max-w-sm bg-[var(--bg-hover)]"
304
+ style={style}
305
+ >
306
+ <img
307
+ src={url}
308
+ alt={name || 'Attached image'}
309
+ className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
310
+ onClick={() => window.open(url, '_blank')}
311
+ onLoad={onLoad}
312
+ />
313
+ </div>
314
+ )
315
+ }
316
+
317
+ // File attachment component (for non-image files)
318
+ function FileAttachment({ name, mimeType, url }: { name: string; mimeType: string; url: string }) {
319
+ return (
320
+ <a
321
+ href={url}
322
+ target="_blank"
323
+ rel="noopener noreferrer"
324
+ className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-hover)] hover:bg-[var(--bg-active)] transition-colors"
325
+ >
326
+ <svg className="w-4 h-4 text-[var(--text-secondary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
327
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
328
+ </svg>
329
+ <span className="text-sm text-[var(--text-primary)] truncate max-w-[200px]">{name}</span>
330
+ </a>
331
+ )
332
+ }
333
+
334
+ // Build attachment URL - check localPreviewUrl first for optimistic preview
335
+ function getAttachmentUrl(threadId: string, attachment: Attachment): string {
336
+ // Check localPreviewUrl first (optimistic preview from SDK)
337
+ if (attachment.localPreviewUrl) {
338
+ return attachment.localPreviewUrl
339
+ }
340
+ // Otherwise use the server path
341
+ if (attachment.path) {
342
+ const endpoint = getApiEndpoint()
343
+ // Remove leading slash from path if present for clean join
344
+ const cleanPath = attachment.path.startsWith('/') ? attachment.path.slice(1) : attachment.path
345
+ return `${endpoint}/api/threads/${threadId}/fs/${cleanPath}`
346
+ }
347
+ // Fallback - shouldn't happen
348
+ return ''
349
+ }
350
+
351
+ // Render attachments
352
+ function AttachmentList({ attachments, threadId, onImageLoad }: { attachments: Attachment[]; threadId: string; onImageLoad?: () => void }) {
353
+ const imageAttachments = attachments.filter(a => a.mimeType.startsWith('image/'))
354
+ const otherAttachments = attachments.filter(a => !a.mimeType.startsWith('image/'))
355
+
356
+ return (
357
+ <div className="space-y-2">
358
+ {/* Image attachments */}
359
+ {imageAttachments.length > 0 && (
360
+ <div className="flex flex-wrap gap-2">
361
+ {imageAttachments.map((attachment) => (
362
+ <ImageAttachment
363
+ key={attachment.id}
364
+ url={getAttachmentUrl(threadId, attachment)}
365
+ name={attachment.name}
366
+ width={attachment.width}
367
+ height={attachment.height}
368
+ onLoad={onImageLoad}
369
+ />
370
+ ))}
371
+ </div>
372
+ )}
373
+
374
+ {/* Other file attachments */}
375
+ {otherAttachments.length > 0 && (
376
+ <div className="flex flex-wrap gap-2">
377
+ {otherAttachments.map((attachment) => (
378
+ <FileAttachment
379
+ key={attachment.id}
380
+ name={attachment.name}
381
+ mimeType={attachment.mimeType}
382
+ url={getAttachmentUrl(threadId, attachment)}
383
+ />
384
+ ))}
385
+ </div>
386
+ )}
387
+ </div>
388
+ )
389
+ }
390
+
391
+ // Message actions component
392
+ interface MessageActionsProps {
393
+ message: Message
394
+ threadId: string
395
+ allMessages: ThreadMessage[]
396
+ onBranchCreated?: (newThreadId: string) => void
397
+ }
398
+
399
+ function MessageActions({ message, threadId, allMessages, onBranchCreated }: MessageActionsProps) {
400
+ const [copied, setCopied] = useState(false)
401
+ const [showDeleteWarning, setShowDeleteWarning] = useState(false)
402
+ const [showRewindWarning, setShowRewindWarning] = useState(false)
403
+ const [isDeleting, setIsDeleting] = useState(false)
404
+ const [isBranching, setIsBranching] = useState(false)
405
+
406
+ const { baseUrl, token } = getApiConfig()
407
+ const headers = useMemo(() => {
408
+ const h: Record<string, string> = { 'Content-Type': 'application/json' }
409
+ if (token) h['Authorization'] = `Bearer ${token}`
410
+ return h
411
+ }, [token])
412
+
413
+ // Copy message content to clipboard
414
+ const handleCopy = useCallback(async () => {
415
+ const { text } = parseMessageContent(message.content)
416
+ if (text) {
417
+ await navigator.clipboard.writeText(text)
418
+ setCopied(true)
419
+ setTimeout(() => setCopied(false), 2000)
420
+ }
421
+ }, [message.content])
422
+
423
+ // Delete this message - show confirmation first
424
+ const handleDelete = useCallback(() => {
425
+ setShowDeleteWarning(true)
426
+ }, [])
427
+
428
+ const confirmDelete = useCallback(async () => {
429
+ setShowDeleteWarning(false)
430
+ setIsDeleting(true)
431
+ try {
432
+ // Try the messages endpoint path
433
+ const response = await fetch(`${baseUrl}/api/threads/${threadId}/messages/${message.id}`, {
434
+ method: 'DELETE',
435
+ headers,
436
+ })
437
+ if (!response.ok) {
438
+ // If that fails, the endpoint might not exist yet
439
+ console.error('Failed to delete message:', response.status, response.statusText)
440
+ alert('Delete functionality is not yet available.')
441
+ }
442
+ // The thread will auto-refresh via WebSocket
443
+ } catch (error) {
444
+ console.error('Failed to delete message:', error)
445
+ alert('Delete functionality is not yet available.')
446
+ } finally {
447
+ setIsDeleting(false)
448
+ }
449
+ }, [baseUrl, threadId, message.id, headers])
450
+
451
+ // Rewind thread to this point (stub - endpoint not implemented yet)
452
+ const handleRewind = useCallback(() => {
453
+ setShowRewindWarning(true)
454
+ }, [])
455
+
456
+ const confirmRewind = useCallback(() => {
457
+ // TODO: Implement when endpoint is available
458
+ console.warn('Rewind endpoint not yet implemented')
459
+ alert('Rewind functionality is not yet available. This feature will be implemented soon.')
460
+ setShowRewindWarning(false)
461
+ }, [])
462
+
463
+ // Branch: Create new thread with messages up to this point
464
+ const handleBranch = useCallback(async () => {
465
+ setIsBranching(true)
466
+ try {
467
+ // Get all messages up to and including this message
468
+ const messageIndex = allMessages.findIndex(m => 'id' in m && m.id === message.id)
469
+ if (messageIndex === -1) return
470
+
471
+ // Filter to only regular messages (not workblocks) up to this point
472
+ const messagesToCopy = allMessages
473
+ .slice(0, messageIndex + 1)
474
+ .filter((m): m is Message => !isWorkMessage(m) && m.role !== 'system' && m.role !== 'tool')
475
+
476
+ // Get the agent_id from the current thread
477
+ const threadResponse = await fetch(`${baseUrl}/api/threads/${threadId}`, { headers })
478
+ if (!threadResponse.ok) throw new Error('Failed to get thread info')
479
+ const threadData = await threadResponse.json()
480
+
481
+ // Create new thread
482
+ const createResponse = await fetch(`${baseUrl}/api/threads`, {
483
+ method: 'POST',
484
+ headers,
485
+ body: JSON.stringify({ agent_id: threadData.agent_id }),
486
+ })
487
+ if (!createResponse.ok) throw new Error('Failed to create new thread')
488
+ const newThread = await createResponse.json()
489
+ const newThreadId = newThread.threadId
490
+
491
+ // Copy messages to new thread
492
+ for (const msg of messagesToCopy) {
493
+ await fetch(`${baseUrl}/api/threads/${newThreadId}/messages`, {
494
+ method: 'POST',
495
+ headers,
496
+ body: JSON.stringify({
497
+ role: msg.role,
498
+ content: msg.content,
499
+ }),
500
+ })
501
+ }
502
+
503
+ // Navigate to new thread
504
+ if (onBranchCreated) {
505
+ onBranchCreated(newThreadId)
506
+ } else {
507
+ // Fallback: update URL
508
+ const url = new URL(window.location.href)
509
+ url.searchParams.set('thread', newThreadId)
510
+ window.location.href = url.toString()
511
+ }
512
+ } catch (error) {
513
+ console.error('Failed to branch thread:', error)
514
+ } finally {
515
+ setIsBranching(false)
516
+ }
517
+ }, [baseUrl, threadId, message.id, allMessages, headers, onBranchCreated])
518
+
519
+ const iconClass = "w-[18px] h-[18px]"
520
+ const buttonClass = "p-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors disabled:opacity-50"
521
+
522
+ return (
523
+ <div className="flex items-center gap-1 mt-2">
524
+ {/* Copy */}
525
+ <button
526
+ onClick={handleCopy}
527
+ className={buttonClass}
528
+ title={copied ? 'Copied!' : 'Copy'}
529
+ >
530
+ {copied ? (
531
+ <CheckIcon className={`${iconClass} text-emerald-500`} />
532
+ ) : (
533
+ <CopyIcon className={iconClass} />
534
+ )}
535
+ </button>
536
+
537
+ {/* Delete */}
538
+ <button
539
+ onClick={handleDelete}
540
+ disabled={isDeleting}
541
+ className={buttonClass}
542
+ title="Delete"
543
+ >
544
+ {isDeleting ? (
545
+ <SpinnerIcon className={iconClass} />
546
+ ) : (
547
+ <TrashIcon className={iconClass} />
548
+ )}
549
+ </button>
550
+
551
+ {/* Rewind */}
552
+ <button
553
+ onClick={handleRewind}
554
+ className={buttonClass}
555
+ title="Rewind to here"
556
+ >
557
+ <RewindIcon className={iconClass} />
558
+ </button>
559
+
560
+ {/* Branch */}
561
+ <button
562
+ onClick={handleBranch}
563
+ disabled={isBranching}
564
+ className={buttonClass}
565
+ title="Branch from here"
566
+ >
567
+ {isBranching ? (
568
+ <SpinnerIcon className={iconClass} />
569
+ ) : (
570
+ <BranchIcon className={iconClass} />
571
+ )}
572
+ </button>
573
+
574
+ {/* Delete confirmation dialog */}
575
+ {showDeleteWarning && createPortal(
576
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
577
+ <div className="bg-[var(--bg-elevated)] border border-[var(--border-primary)] rounded-xl p-4 max-w-sm mx-4 shadow-xl">
578
+ <h3 className="text-[var(--text-primary)] font-medium mb-2">Delete Message</h3>
579
+ <p className="text-sm text-[var(--text-secondary)] mb-4">
580
+ Are you sure you want to delete this message? This action cannot be undone.
581
+ </p>
582
+ <div className="flex gap-2 justify-end">
583
+ <button
584
+ onClick={() => setShowDeleteWarning(false)}
585
+ className="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
586
+ >
587
+ Cancel
588
+ </button>
589
+ <button
590
+ onClick={confirmDelete}
591
+ className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors"
592
+ >
593
+ Delete
594
+ </button>
595
+ </div>
596
+ </div>
597
+ </div>,
598
+ document.body
599
+ )}
600
+
601
+ {/* Rewind confirmation dialog */}
602
+ {showRewindWarning && createPortal(
603
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
604
+ <div className="bg-[var(--bg-elevated)] border border-[var(--border-primary)] rounded-xl p-4 max-w-sm mx-4 shadow-xl">
605
+ <h3 className="text-[var(--text-primary)] font-medium mb-2">Rewind Thread</h3>
606
+ <p className="text-sm text-[var(--text-secondary)] mb-4">
607
+ This will remove all messages after this point. This action cannot be undone.
608
+ </p>
609
+ <div className="flex gap-2 justify-end">
610
+ <button
611
+ onClick={() => setShowRewindWarning(false)}
612
+ className="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
613
+ >
614
+ Cancel
615
+ </button>
616
+ <button
617
+ onClick={confirmRewind}
618
+ className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors"
619
+ >
620
+ Rewind
621
+ </button>
622
+ </div>
623
+ </div>
624
+ </div>,
625
+ document.body
626
+ )}
627
+ </div>
628
+ )
629
+ }
630
+
631
+ // Regular message component
632
+ function MessageBubble({ message, threadId, allMessages, onBranchCreated, onImageLoad }: { message: Message; threadId: string; allMessages: ThreadMessage[]; onBranchCreated?: (newThreadId: string) => void; onImageLoad?: () => void }) {
633
+ const isUser = message.role === 'user'
634
+ const { text, images: inlineImages } = parseMessageContent(message.content)
635
+
636
+ // Get attachments from the message (cast to extended type)
637
+ const rawAttachments = (message as MessageWithAttachments).attachments
638
+ const attachments = Array.isArray(rawAttachments) ? rawAttachments : []
639
+ const hasAttachments = attachments.length > 0
640
+
641
+ return (
642
+ <div className={`animate-fade-in ${isUser ? 'flex flex-col items-end' : ''}`}>
643
+ {/* Attachments shown above message bubble for user messages */}
644
+ {isUser && hasAttachments && (
645
+ <div className="mb-2 max-w-[85%]">
646
+ <AttachmentList attachments={attachments} threadId={threadId} onImageLoad={onImageLoad} />
647
+ </div>
648
+ )}
649
+
650
+ {isUser ? (
651
+ <div className="inline-block max-w-[85%] px-4 py-3 rounded-2xl rounded-br-md bg-[var(--bg-tertiary)] text-[var(--text-primary)]">
652
+ {text && (
653
+ <div className="whitespace-pre-wrap break-words text-[15px] leading-relaxed">
654
+ {text}
655
+ </div>
656
+ )}
657
+
658
+ {/* Inline images from content (fallback) */}
659
+ {inlineImages.length > 0 && (
660
+ <div className={`${text ? 'mt-2' : ''} space-y-2`}>
661
+ {inlineImages.map((url, index) => (
662
+ <ImageAttachment key={index} url={url} onLoad={onImageLoad} />
663
+ ))}
664
+ </div>
665
+ )}
666
+ </div>
667
+ ) : (
668
+ <div className="max-w-[85%] text-[var(--text-primary)]">
669
+ {text && (
670
+ <Markdown className="text-[15px] leading-relaxed">
671
+ {text}
672
+ </Markdown>
673
+ )}
674
+
675
+ {/* Inline images from content (fallback) */}
676
+ {inlineImages.length > 0 && (
677
+ <div className={`${text ? 'mt-2' : ''} space-y-2`}>
678
+ {inlineImages.map((url, index) => (
679
+ <ImageAttachment key={index} url={url} onLoad={onImageLoad} />
680
+ ))}
681
+ </div>
682
+ )}
683
+
684
+ {/* Attachments shown below content for assistant messages */}
685
+ {hasAttachments && (
686
+ <div className="mt-2">
687
+ <AttachmentList attachments={attachments} threadId={threadId} onImageLoad={onImageLoad} />
688
+ </div>
689
+ )}
690
+
691
+ {/* Action icons for assistant messages */}
692
+ {message.status !== 'pending' && (
693
+ <MessageActions
694
+ message={message}
695
+ threadId={threadId}
696
+ allMessages={allMessages}
697
+ onBranchCreated={onBranchCreated}
698
+ />
699
+ )}
700
+ </div>
701
+ )}
702
+
703
+ </div>
704
+ )
705
+ }
706
+
707
+ // Thinking indicator component
708
+ function ThinkingIndicator() {
709
+ return (
710
+ <div className="animate-fade-in">
711
+ <div className="flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
712
+ <SpinnerIcon className="w-4 h-4" />
713
+ <span>Thinking...</span>
714
+ </div>
715
+ </div>
716
+ )
717
+ }
718
+
719
+ export function MessageList() {
720
+ const { messages, loading, threadId } = useThread()
721
+ const bottomRef = useRef<HTMLDivElement>(null)
722
+
723
+ const scrollToBottom = useCallback(() => {
724
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
725
+ }, [])
726
+
727
+ useEffect(() => {
728
+ scrollToBottom()
729
+ }, [messages, loading, scrollToBottom])
730
+
731
+ // Determine if we should show typing indicator
732
+ const showTypingIndicator = useMemo(() => {
733
+ // Don't show typing indicator if there are no messages yet (initial load)
734
+ if (messages.length === 0) return false
735
+
736
+ // Check if the last user-visible message needs a response
737
+ const visibleMessages = messages.filter(msg => {
738
+ if (isWorkMessage(msg)) return true
739
+ return msg.role !== 'system' && msg.role !== 'tool'
740
+ })
741
+
742
+ if (visibleMessages.length === 0) return false
743
+
744
+ const lastMessage = visibleMessages[visibleMessages.length - 1]
745
+
746
+ // If last message is from user and we're loading, we're waiting for response
747
+ if (!isWorkMessage(lastMessage) && lastMessage.role === 'user' && loading) {
748
+ return true
749
+ }
750
+
751
+ // If last message is a pending workblock, we're still working
752
+ if (isWorkMessage(lastMessage) && lastMessage.status === 'pending') {
753
+ return true
754
+ }
755
+
756
+ // If last assistant message is pending
757
+ if (!isWorkMessage(lastMessage) && lastMessage.role === 'assistant' && lastMessage.status === 'pending') {
758
+ return true
759
+ }
760
+
761
+ return false
762
+ }, [messages, loading])
763
+
764
+ if (messages.length === 0 && !loading) {
765
+ return (
766
+ <div className="flex-1 flex items-center justify-center">
767
+ <p className="text-[var(--text-muted)] text-sm">Send a message to start the conversation</p>
768
+ </div>
769
+ )
770
+ }
771
+
772
+ return (
773
+ <div className="py-6 px-4 space-y-6">
774
+ {messages.map((msg) => {
775
+ // Handle workblocks
776
+ if (isWorkMessage(msg)) {
777
+ return <Workblock key={msg.id} workblock={msg} />
778
+ }
779
+
780
+ // Skip system and tool messages (tool messages are shown in workblocks)
781
+ if (msg.role === 'system' || msg.role === 'tool') {
782
+ return null
783
+ }
784
+
785
+ return <MessageBubble key={msg.id} message={msg} threadId={threadId} allMessages={messages} onImageLoad={scrollToBottom} />
786
+ })}
787
+
788
+ {/* Thinking indicator - shown when waiting for response */}
789
+ {showTypingIndicator && !messages.some(m => isWorkMessage(m) && m.status === 'pending') && (
790
+ <ThinkingIndicator />
791
+ )}
792
+
793
+ <div ref={bottomRef} className="h-4" />
794
+ </div>
795
+ )
796
+ }