@standardagents/cli 0.10.1-next.bbd142a → 0.11.0-next.7711e15

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,850 @@
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
+ // Chevron icon for collapse/expand
232
+ function ChevronIcon({ className, expanded }: { className?: string; expanded: boolean }) {
233
+ return (
234
+ <svg
235
+ className={`${className} transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`}
236
+ viewBox="0 0 24 24"
237
+ fill="none"
238
+ stroke="currentColor"
239
+ strokeWidth={2}
240
+ strokeLinecap="round"
241
+ strokeLinejoin="round"
242
+ >
243
+ <path d="M9 18l6-6-6-6" />
244
+ </svg>
245
+ )
246
+ }
247
+
248
+ // Workblock component
249
+ function Workblock({ workblock, isLast, threadLoading }: { workblock: WorkMessage; isLast?: boolean; threadLoading?: boolean }) {
250
+ const [isExpanded, setIsExpanded] = useState(false)
251
+ const { text } = parseMessageContent(workblock.content)
252
+ const isPending = workblock.status === 'pending'
253
+
254
+ // Group work items by tool_call_id to pair calls with results
255
+ const toolCalls = workblock.workItems.filter(item => item.type === 'tool_call')
256
+ const toolResults = workblock.workItems.filter(item => item.type === 'tool_result')
257
+
258
+ // Count completed vs total and check for errors
259
+ const completedCount = toolCalls.filter(item => item.status === 'success' || item.status === 'error').length
260
+ const errorCount = toolCalls.filter(item => item.status === 'error').length
261
+ const totalCount = toolCalls.length
262
+ const hasErrors = errorCount > 0
263
+
264
+ // Get most recent info for the badge (last tool result or last tool call name)
265
+ const lastResult = toolResults[toolResults.length - 1]
266
+ const lastToolCall = toolCalls[toolCalls.length - 1]
267
+ const badgeText = lastResult?.content
268
+ ? (lastResult.content.length > 40 ? lastResult.content.slice(0, 40) + '...' : lastResult.content)
269
+ : lastToolCall?.name
270
+ ? formatToolName(lastToolCall.name)
271
+ : null
272
+
273
+ // Determine status label and color
274
+ const statusLabel = isPending ? 'Working' : hasErrors ? 'Completed with errors' : 'Completed'
275
+ const statusColor = isPending ? 'text-violet-500' : hasErrors ? 'text-amber-500' : 'text-emerald-500'
276
+
277
+ // Show loader if this is the last workblock and thread is still loading
278
+ const showLoader = isLast && threadLoading && !isPending
279
+
280
+ return (
281
+ <div className="animate-fade-in">
282
+ {/* Workblock panel */}
283
+ <div className={`bg-[var(--bg-tertiary)] border border-[var(--border-primary)] rounded-xl overflow-hidden max-w-[85%] ${isPending ? 'workblock-shimmer' : ''}`}>
284
+ {/* Header - clickable to toggle */}
285
+ <button
286
+ onClick={() => setIsExpanded(!isExpanded)}
287
+ className="w-full px-3 py-2 bg-[var(--bg-hover)] flex items-center gap-2 hover:bg-[var(--bg-tertiary)] transition-colors text-left"
288
+ >
289
+ <ChevronIcon className="w-3.5 h-3.5 text-[var(--text-tertiary)]" expanded={isExpanded} />
290
+ <span className={`text-xs font-medium uppercase tracking-wide ${statusColor}`}>
291
+ {statusLabel}
292
+ </span>
293
+ {(isPending || showLoader) && (
294
+ <SpinnerIcon className="w-3 h-3 text-violet-500" />
295
+ )}
296
+ {/* Badge with most recent info when collapsed */}
297
+ {!isExpanded && badgeText && (
298
+ <span className="ml-auto text-xs text-[var(--text-tertiary)] bg-[var(--bg-secondary)] px-2 py-0.5 rounded truncate max-w-[200px]">
299
+ {badgeText}
300
+ </span>
301
+ )}
302
+ {isExpanded && totalCount > 1 && (
303
+ <span className="ml-auto text-xs text-[var(--text-tertiary)]">
304
+ {completedCount}/{totalCount}
305
+ </span>
306
+ )}
307
+ </button>
308
+
309
+ {/* Collapsible content */}
310
+ {isExpanded && (
311
+ <>
312
+ {/* Text content (thinking/reasoning) inside the workblock */}
313
+ {text && text.trim() && (
314
+ <div className="px-3 py-2.5 border-t border-[var(--border-primary)] bg-[var(--bg-secondary)]">
315
+ <p className="text-sm text-[var(--text-secondary)] italic whitespace-pre-wrap break-words">
316
+ {text.trim()}
317
+ </p>
318
+ </div>
319
+ )}
320
+
321
+ {/* Tool calls list */}
322
+ <div className="divide-y divide-[var(--border-primary)] border-t border-[var(--border-primary)]">
323
+ {toolCalls.map((item, index) => (
324
+ <div
325
+ key={item.id}
326
+ className="px-3 py-2.5 flex items-center gap-3 animate-slide-in"
327
+ style={{ animationDelay: `${index * 50}ms` }}
328
+ >
329
+ <WorkItemStatus status={item.status} />
330
+ <span className="text-sm text-[var(--text-primary)]">
331
+ {formatToolName(item.name)}
332
+ </span>
333
+ </div>
334
+ ))}
335
+ </div>
336
+ </>
337
+ )}
338
+ </div>
339
+ </div>
340
+ )
341
+ }
342
+
343
+ // Image attachment component
344
+ function ImageAttachment({ url, name, width, height, onLoad }: { url: string; name?: string; width?: number; height?: number; onLoad?: () => void }) {
345
+ // Use aspect-ratio to reserve space and prevent layout shift
346
+ const style: React.CSSProperties = {}
347
+ if (width && height) {
348
+ style.aspectRatio = `${width} / ${height}`
349
+ // Cap the width to max-w-sm (384px) while maintaining aspect ratio
350
+ style.width = Math.min(width, 384)
351
+ }
352
+
353
+ return (
354
+ <div
355
+ className="rounded-lg overflow-hidden max-w-sm bg-[var(--bg-hover)]"
356
+ style={style}
357
+ >
358
+ <img
359
+ src={url}
360
+ alt={name || 'Attached image'}
361
+ className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
362
+ onClick={() => window.open(url, '_blank')}
363
+ onLoad={onLoad}
364
+ />
365
+ </div>
366
+ )
367
+ }
368
+
369
+ // File attachment component (for non-image files)
370
+ function FileAttachment({ name, mimeType, url }: { name: string; mimeType: string; url: string }) {
371
+ return (
372
+ <a
373
+ href={url}
374
+ target="_blank"
375
+ rel="noopener noreferrer"
376
+ className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-hover)] hover:bg-[var(--bg-active)] transition-colors"
377
+ >
378
+ <svg className="w-4 h-4 text-[var(--text-secondary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
379
+ <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" />
380
+ </svg>
381
+ <span className="text-sm text-[var(--text-primary)] truncate max-w-[200px]">{name}</span>
382
+ </a>
383
+ )
384
+ }
385
+
386
+ // Build attachment URL - check localPreviewUrl first for optimistic preview
387
+ function getAttachmentUrl(threadId: string, attachment: Attachment): string {
388
+ // Check localPreviewUrl first (optimistic preview from SDK)
389
+ if (attachment.localPreviewUrl) {
390
+ return attachment.localPreviewUrl
391
+ }
392
+ // Otherwise use the server path
393
+ if (attachment.path) {
394
+ const endpoint = getApiEndpoint()
395
+ // Remove leading slash from path if present for clean join
396
+ const cleanPath = attachment.path.startsWith('/') ? attachment.path.slice(1) : attachment.path
397
+ return `${endpoint}/api/threads/${threadId}/fs/${cleanPath}`
398
+ }
399
+ // Fallback - shouldn't happen
400
+ return ''
401
+ }
402
+
403
+ // Render attachments
404
+ function AttachmentList({ attachments, threadId, onImageLoad }: { attachments: Attachment[]; threadId: string; onImageLoad?: () => void }) {
405
+ const imageAttachments = attachments.filter(a => a.mimeType.startsWith('image/'))
406
+ const otherAttachments = attachments.filter(a => !a.mimeType.startsWith('image/'))
407
+
408
+ return (
409
+ <div className="space-y-2">
410
+ {/* Image attachments */}
411
+ {imageAttachments.length > 0 && (
412
+ <div className="flex flex-wrap gap-2">
413
+ {imageAttachments.map((attachment) => (
414
+ <ImageAttachment
415
+ key={attachment.id}
416
+ url={getAttachmentUrl(threadId, attachment)}
417
+ name={attachment.name}
418
+ width={attachment.width}
419
+ height={attachment.height}
420
+ onLoad={onImageLoad}
421
+ />
422
+ ))}
423
+ </div>
424
+ )}
425
+
426
+ {/* Other file attachments */}
427
+ {otherAttachments.length > 0 && (
428
+ <div className="flex flex-wrap gap-2">
429
+ {otherAttachments.map((attachment) => (
430
+ <FileAttachment
431
+ key={attachment.id}
432
+ name={attachment.name}
433
+ mimeType={attachment.mimeType}
434
+ url={getAttachmentUrl(threadId, attachment)}
435
+ />
436
+ ))}
437
+ </div>
438
+ )}
439
+ </div>
440
+ )
441
+ }
442
+
443
+ // Message actions component
444
+ interface MessageActionsProps {
445
+ message: Message
446
+ threadId: string
447
+ allMessages: ThreadMessage[]
448
+ onBranchCreated?: (newThreadId: string) => void
449
+ }
450
+
451
+ function MessageActions({ message, threadId, allMessages, onBranchCreated }: MessageActionsProps) {
452
+ const [copied, setCopied] = useState(false)
453
+ const [showDeleteWarning, setShowDeleteWarning] = useState(false)
454
+ const [showRewindWarning, setShowRewindWarning] = useState(false)
455
+ const [isDeleting, setIsDeleting] = useState(false)
456
+ const [isBranching, setIsBranching] = useState(false)
457
+
458
+ const { baseUrl, token } = getApiConfig()
459
+ const headers = useMemo(() => {
460
+ const h: Record<string, string> = { 'Content-Type': 'application/json' }
461
+ if (token) h['Authorization'] = `Bearer ${token}`
462
+ return h
463
+ }, [token])
464
+
465
+ // Copy message content to clipboard
466
+ const handleCopy = useCallback(async () => {
467
+ const { text } = parseMessageContent(message.content)
468
+ if (text) {
469
+ await navigator.clipboard.writeText(text)
470
+ setCopied(true)
471
+ setTimeout(() => setCopied(false), 2000)
472
+ }
473
+ }, [message.content])
474
+
475
+ // Delete this message - show confirmation first
476
+ const handleDelete = useCallback(() => {
477
+ setShowDeleteWarning(true)
478
+ }, [])
479
+
480
+ const confirmDelete = useCallback(async () => {
481
+ setShowDeleteWarning(false)
482
+ setIsDeleting(true)
483
+ try {
484
+ // Try the messages endpoint path
485
+ const response = await fetch(`${baseUrl}/api/threads/${threadId}/messages/${message.id}`, {
486
+ method: 'DELETE',
487
+ headers,
488
+ })
489
+ if (!response.ok) {
490
+ // If that fails, the endpoint might not exist yet
491
+ console.error('Failed to delete message:', response.status, response.statusText)
492
+ alert('Delete functionality is not yet available.')
493
+ }
494
+ // The thread will auto-refresh via WebSocket
495
+ } catch (error) {
496
+ console.error('Failed to delete message:', error)
497
+ alert('Delete functionality is not yet available.')
498
+ } finally {
499
+ setIsDeleting(false)
500
+ }
501
+ }, [baseUrl, threadId, message.id, headers])
502
+
503
+ // Rewind thread to this point (stub - endpoint not implemented yet)
504
+ const handleRewind = useCallback(() => {
505
+ setShowRewindWarning(true)
506
+ }, [])
507
+
508
+ const confirmRewind = useCallback(() => {
509
+ // TODO: Implement when endpoint is available
510
+ console.warn('Rewind endpoint not yet implemented')
511
+ alert('Rewind functionality is not yet available. This feature will be implemented soon.')
512
+ setShowRewindWarning(false)
513
+ }, [])
514
+
515
+ // Branch: Create new thread with messages up to this point
516
+ const handleBranch = useCallback(async () => {
517
+ setIsBranching(true)
518
+ try {
519
+ // Get all messages up to and including this message
520
+ const messageIndex = allMessages.findIndex(m => 'id' in m && m.id === message.id)
521
+ if (messageIndex === -1) return
522
+
523
+ // Filter to only regular messages (not workblocks) up to this point
524
+ const messagesToCopy = allMessages
525
+ .slice(0, messageIndex + 1)
526
+ .filter((m): m is Message => !isWorkMessage(m) && m.role !== 'system' && m.role !== 'tool')
527
+
528
+ // Get the agent_id from the current thread
529
+ const threadResponse = await fetch(`${baseUrl}/api/threads/${threadId}`, { headers })
530
+ if (!threadResponse.ok) throw new Error('Failed to get thread info')
531
+ const threadData = await threadResponse.json()
532
+
533
+ // Create new thread
534
+ const createResponse = await fetch(`${baseUrl}/api/threads`, {
535
+ method: 'POST',
536
+ headers,
537
+ body: JSON.stringify({ agent_id: threadData.agent_id }),
538
+ })
539
+ if (!createResponse.ok) throw new Error('Failed to create new thread')
540
+ const newThread = await createResponse.json()
541
+ const newThreadId = newThread.threadId
542
+
543
+ // Copy messages to new thread
544
+ for (const msg of messagesToCopy) {
545
+ await fetch(`${baseUrl}/api/threads/${newThreadId}/messages`, {
546
+ method: 'POST',
547
+ headers,
548
+ body: JSON.stringify({
549
+ role: msg.role,
550
+ content: msg.content,
551
+ }),
552
+ })
553
+ }
554
+
555
+ // Navigate to new thread
556
+ if (onBranchCreated) {
557
+ onBranchCreated(newThreadId)
558
+ } else {
559
+ // Fallback: update URL
560
+ const url = new URL(window.location.href)
561
+ url.searchParams.set('thread', newThreadId)
562
+ window.location.href = url.toString()
563
+ }
564
+ } catch (error) {
565
+ console.error('Failed to branch thread:', error)
566
+ } finally {
567
+ setIsBranching(false)
568
+ }
569
+ }, [baseUrl, threadId, message.id, allMessages, headers, onBranchCreated])
570
+
571
+ const iconClass = "w-[18px] h-[18px]"
572
+ const buttonClass = "p-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors disabled:opacity-50"
573
+
574
+ return (
575
+ <div className="flex items-center gap-1 mt-2">
576
+ {/* Copy */}
577
+ <button
578
+ onClick={handleCopy}
579
+ className={buttonClass}
580
+ title={copied ? 'Copied!' : 'Copy'}
581
+ >
582
+ {copied ? (
583
+ <CheckIcon className={`${iconClass} text-emerald-500`} />
584
+ ) : (
585
+ <CopyIcon className={iconClass} />
586
+ )}
587
+ </button>
588
+
589
+ {/* Delete */}
590
+ <button
591
+ onClick={handleDelete}
592
+ disabled={isDeleting}
593
+ className={buttonClass}
594
+ title="Delete"
595
+ >
596
+ {isDeleting ? (
597
+ <SpinnerIcon className={iconClass} />
598
+ ) : (
599
+ <TrashIcon className={iconClass} />
600
+ )}
601
+ </button>
602
+
603
+ {/* Rewind */}
604
+ <button
605
+ onClick={handleRewind}
606
+ className={buttonClass}
607
+ title="Rewind to here"
608
+ >
609
+ <RewindIcon className={iconClass} />
610
+ </button>
611
+
612
+ {/* Branch */}
613
+ <button
614
+ onClick={handleBranch}
615
+ disabled={isBranching}
616
+ className={buttonClass}
617
+ title="Branch from here"
618
+ >
619
+ {isBranching ? (
620
+ <SpinnerIcon className={iconClass} />
621
+ ) : (
622
+ <BranchIcon className={iconClass} />
623
+ )}
624
+ </button>
625
+
626
+ {/* Delete confirmation dialog */}
627
+ {showDeleteWarning && createPortal(
628
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
629
+ <div className="bg-[var(--bg-elevated)] border border-[var(--border-primary)] rounded-xl p-4 max-w-sm mx-4 shadow-xl">
630
+ <h3 className="text-[var(--text-primary)] font-medium mb-2">Delete Message</h3>
631
+ <p className="text-sm text-[var(--text-secondary)] mb-4">
632
+ Are you sure you want to delete this message? This action cannot be undone.
633
+ </p>
634
+ <div className="flex gap-2 justify-end">
635
+ <button
636
+ onClick={() => setShowDeleteWarning(false)}
637
+ className="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
638
+ >
639
+ Cancel
640
+ </button>
641
+ <button
642
+ onClick={confirmDelete}
643
+ className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors"
644
+ >
645
+ Delete
646
+ </button>
647
+ </div>
648
+ </div>
649
+ </div>,
650
+ document.body
651
+ )}
652
+
653
+ {/* Rewind confirmation dialog */}
654
+ {showRewindWarning && createPortal(
655
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
656
+ <div className="bg-[var(--bg-elevated)] border border-[var(--border-primary)] rounded-xl p-4 max-w-sm mx-4 shadow-xl">
657
+ <h3 className="text-[var(--text-primary)] font-medium mb-2">Rewind Thread</h3>
658
+ <p className="text-sm text-[var(--text-secondary)] mb-4">
659
+ This will remove all messages after this point. This action cannot be undone.
660
+ </p>
661
+ <div className="flex gap-2 justify-end">
662
+ <button
663
+ onClick={() => setShowRewindWarning(false)}
664
+ className="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
665
+ >
666
+ Cancel
667
+ </button>
668
+ <button
669
+ onClick={confirmRewind}
670
+ className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors"
671
+ >
672
+ Rewind
673
+ </button>
674
+ </div>
675
+ </div>
676
+ </div>,
677
+ document.body
678
+ )}
679
+ </div>
680
+ )
681
+ }
682
+
683
+ // Regular message component
684
+ function MessageBubble({ message, threadId, allMessages, onBranchCreated, onImageLoad }: { message: Message; threadId: string; allMessages: ThreadMessage[]; onBranchCreated?: (newThreadId: string) => void; onImageLoad?: () => void }) {
685
+ const isUser = message.role === 'user'
686
+ const { text, images: inlineImages } = parseMessageContent(message.content)
687
+
688
+ // Get attachments from the message (cast to extended type)
689
+ const rawAttachments = (message as MessageWithAttachments).attachments
690
+ const attachments = Array.isArray(rawAttachments) ? rawAttachments : []
691
+ const hasAttachments = attachments.length > 0
692
+
693
+ return (
694
+ <div className={`animate-fade-in ${isUser ? 'flex flex-col items-end' : ''}`}>
695
+ {/* Attachments shown above message bubble for user messages */}
696
+ {isUser && hasAttachments && (
697
+ <div className="mb-2 max-w-[85%]">
698
+ <AttachmentList attachments={attachments} threadId={threadId} onImageLoad={onImageLoad} />
699
+ </div>
700
+ )}
701
+
702
+ {isUser ? (
703
+ <div className="inline-block max-w-[85%] px-4 py-3 rounded-2xl rounded-br-md bg-[var(--bg-tertiary)] text-[var(--text-primary)]">
704
+ {text && (
705
+ <div className="whitespace-pre-wrap break-words text-[15px] leading-relaxed">
706
+ {text}
707
+ </div>
708
+ )}
709
+
710
+ {/* Inline images from content (fallback) */}
711
+ {inlineImages.length > 0 && (
712
+ <div className={`${text ? 'mt-2' : ''} space-y-2`}>
713
+ {inlineImages.map((url, index) => (
714
+ <ImageAttachment key={index} url={url} onLoad={onImageLoad} />
715
+ ))}
716
+ </div>
717
+ )}
718
+ </div>
719
+ ) : (
720
+ <div className="max-w-[85%] text-[var(--text-primary)]">
721
+ {text && (
722
+ <Markdown className="text-[15px] leading-relaxed">
723
+ {text}
724
+ </Markdown>
725
+ )}
726
+
727
+ {/* Inline images from content (fallback) */}
728
+ {inlineImages.length > 0 && (
729
+ <div className={`${text ? 'mt-2' : ''} space-y-2`}>
730
+ {inlineImages.map((url, index) => (
731
+ <ImageAttachment key={index} url={url} onLoad={onImageLoad} />
732
+ ))}
733
+ </div>
734
+ )}
735
+
736
+ {/* Attachments shown below content for assistant messages */}
737
+ {hasAttachments && (
738
+ <div className="mt-2">
739
+ <AttachmentList attachments={attachments} threadId={threadId} onImageLoad={onImageLoad} />
740
+ </div>
741
+ )}
742
+
743
+ {/* Action icons for assistant messages */}
744
+ {message.status !== 'pending' && (
745
+ <MessageActions
746
+ message={message}
747
+ threadId={threadId}
748
+ allMessages={allMessages}
749
+ onBranchCreated={onBranchCreated}
750
+ />
751
+ )}
752
+ </div>
753
+ )}
754
+
755
+ </div>
756
+ )
757
+ }
758
+
759
+ // Thinking indicator component
760
+ function ThinkingIndicator() {
761
+ return (
762
+ <div className="animate-fade-in">
763
+ <div className="flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
764
+ <SpinnerIcon className="w-4 h-4" />
765
+ <span>Thinking...</span>
766
+ </div>
767
+ </div>
768
+ )
769
+ }
770
+
771
+ export function MessageList() {
772
+ const { workblocks: messages, loading, threadId } = useThread()
773
+ const bottomRef = useRef<HTMLDivElement>(null)
774
+
775
+ const scrollToBottom = useCallback(() => {
776
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
777
+ }, [])
778
+
779
+ useEffect(() => {
780
+ scrollToBottom()
781
+ }, [messages, loading, scrollToBottom])
782
+
783
+ // Determine if we should show typing indicator
784
+ const showTypingIndicator = useMemo(() => {
785
+ // Don't show typing indicator if there are no messages yet (initial load)
786
+ if (messages.length === 0) return false
787
+
788
+ // Check if the last user-visible message needs a response
789
+ const visibleMessages = messages.filter(msg => {
790
+ if (isWorkMessage(msg)) return true
791
+ return msg.role !== 'system' && msg.role !== 'tool'
792
+ })
793
+
794
+ if (visibleMessages.length === 0) return false
795
+
796
+ const lastMessage = visibleMessages[visibleMessages.length - 1]
797
+
798
+ // If last message is from user and we're loading, we're waiting for response
799
+ if (!isWorkMessage(lastMessage) && lastMessage.role === 'user' && loading) {
800
+ return true
801
+ }
802
+
803
+ // If last message is a pending workblock, we're still working
804
+ if (isWorkMessage(lastMessage) && lastMessage.status === 'pending') {
805
+ return true
806
+ }
807
+
808
+ // If last assistant message is pending
809
+ if (!isWorkMessage(lastMessage) && lastMessage.role === 'assistant' && lastMessage.status === 'pending') {
810
+ return true
811
+ }
812
+
813
+ return false
814
+ }, [messages, loading])
815
+
816
+ if (messages.length === 0 && !loading) {
817
+ return (
818
+ <div className="flex-1 flex items-center justify-center">
819
+ <p className="text-[var(--text-muted)] text-sm">Send a message to start the conversation</p>
820
+ </div>
821
+ )
822
+ }
823
+
824
+ return (
825
+ <div className="py-6 px-4 space-y-6">
826
+ {messages.map((msg, index) => {
827
+ const isLast = index === messages.length - 1
828
+
829
+ // Handle workblocks
830
+ if (isWorkMessage(msg)) {
831
+ return <Workblock key={msg.id} workblock={msg} isLast={isLast} threadLoading={loading} />
832
+ }
833
+
834
+ // Skip system and tool messages (tool messages are shown in workblocks)
835
+ if (msg.role === 'system' || msg.role === 'tool') {
836
+ return null
837
+ }
838
+
839
+ return <MessageBubble key={msg.id} message={msg} threadId={threadId} allMessages={messages} onImageLoad={scrollToBottom} />
840
+ })}
841
+
842
+ {/* Thinking indicator - shown when waiting for response */}
843
+ {showTypingIndicator && !messages.some(m => isWorkMessage(m) && m.status === 'pending') && (
844
+ <ThinkingIndicator />
845
+ )}
846
+
847
+ <div ref={bottomRef} className="h-4" />
848
+ </div>
849
+ )
850
+ }