message-bubble.tsx
1 'use client' 2 3 import { isValidElement, memo, useState, useCallback, useMemo } from 'react' 4 import ReactMarkdown from 'react-markdown' 5 import remarkGfm from 'remark-gfm' 6 import rehypeHighlight from 'rehype-highlight' 7 import type { Message } from '@/types' 8 import { useMediaQuery } from '@/hooks/use-media-query' 9 import { useAppStore } from '@/stores/use-app-store' 10 import { useChatStore } from '@/stores/use-chat-store' 11 import type { ToolEvent } from '@/stores/use-chat-store' 12 import { AiAvatar } from '@/components/shared/avatar' 13 import { AgentAvatar } from '@/components/agents/agent-avatar' 14 import { CodeBlock } from './code-block' 15 import { extractMedia, isExplicitScreenshot } from './tool-call-bubble' 16 import { ToolEventsSection, ToolActivityPill } from './tool-events-section' 17 import { MessageAttachments } from '@/components/shared/attachment-chip' 18 import { MarkdownBody } from '@/components/shared/markdown-body' 19 import { MessageActions, ActionButton } from '@/components/shared/message-actions' 20 import { isStructuredMarkdown } from '@/components/shared/markdown-utils' 21 import { FilePathChip, FILE_PATH_RE, DIR_PATH_RE } from './file-path-chip' 22 import { TransferAgentPicker } from './transfer-agent-picker' 23 import { DelegationSourceBanner, TaskCompletionCard, parseTaskCompletion } from './delegation-banner' 24 import { ConnectorPlatformIcon, getConnectorPlatformLabel } from '@/components/shared/connector-platform-icon' 25 import { copyTextToClipboard } from '@/lib/clipboard' 26 import { formatMessageTimestamp } from '@/lib/chat/chat-display' 27 import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata' 28 import { GroundingPanel } from '@/components/knowledge/grounding-panel' 29 30 /** Parse delegation-source metadata prefix from system messages */ 31 const DELEGATION_SOURCE_RE = /^\[delegation-source:([^:]*):([^:]*):([^\]]*)\]/ 32 const UPLOAD_IMAGE_RE = /\.(png|jpe?g|gif|webp|svg|avif)$/i 33 const UPLOAD_VIDEO_RE = /\.(mp4|webm|mov|avi)$/i 34 const UPLOAD_PDF_RE = /\.pdf$/i 35 36 function parseDelegationSource(text: string): { delegatorId: string; delegatorName: string; delegatorAvatarSeed: string; rest: string } | null { 37 const m = text.match(DELEGATION_SOURCE_RE) 38 if (!m) return null 39 return { delegatorId: m[1], delegatorName: m[2], delegatorAvatarSeed: m[3], rest: text.slice(m[0].length).replace(/^\n/, '') } 40 } 41 42 /** Try to parse JSON safely, returning null on failure */ 43 function tryParseJson(s: string): Record<string, unknown> | null { 44 try { return JSON.parse(s) } catch { return null } 45 } 46 47 function connectorThreadMeta(message: Message, isUser: boolean): string | null { 48 const source = message.source 49 if (!source) return null 50 const connectorName = source.connectorName?.trim() || getConnectorPlatformLabel(source.platform) 51 if (isUser) { 52 const sender = source.senderName?.trim() || source.senderId?.trim() || source.channelId?.trim() 53 return sender ? `${connectorName} · ${sender}` : connectorName 54 } 55 const recipient = source.senderName?.trim() || source.senderId?.trim() || source.channelId?.trim() 56 return recipient ? `${connectorName} · to ${recipient}` : connectorName 57 } 58 59 interface HeartbeatMeta { 60 goal?: string 61 status?: string 62 next_action?: string 63 } 64 65 function parseHeartbeatMeta(text: string): HeartbeatMeta | null { 66 const match = text.match(/\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i) 67 if (!match?.[1]) return null 68 try { 69 const parsed = JSON.parse(match[1]) 70 if (typeof parsed === 'object' && parsed !== null) return parsed as HeartbeatMeta 71 } catch { /* ignore */ } 72 return null 73 } 74 75 function heartbeatSummary(text: string): string { 76 const clean = (text || '') 77 .replace(/\bHEARTBEAT_OK\b/gi, '') 78 .replace(/\[AGENT_HEARTBEAT_META\]\s*\{[^\n]*\}/gi, '') 79 .replace(/\*\*(.*?)\*\*/g, '$1') 80 .replace(/\*(.*?)\*/g, '$1') 81 .replace(/`([^`]+)`/g, '$1') 82 .replace(/\[(.*?)\]\([^)]+\)/g, '$1') 83 .replace(/\bHeartbeat Response\s*:\s*/gi, '') 84 .replace(/\bCurrent (State|Status)\s*:\s*/gi, '') 85 .replace(/\bRecent Progress\s*:\s*/gi, '') 86 .replace(/\bNext (Step|Immediate Step)\s*:\s*/gi, '') 87 .replace(/\bStatus\s*:\s*/gi, '') 88 .replace(/\s+/g, ' ') 89 .trim() 90 if (!clean) return 'No new status update.' 91 return clean.length > 180 ? `${clean.slice(0, 180)}...` : clean 92 } 93 94 function normalizeUploadMediaKey(url: string): string { 95 const pathname = String(url || '').split('?')[0] 96 const basename = pathname.split('/').pop() || pathname 97 return basename.replace(/^\d+-/, '') 98 } 99 100 function extractReferencedUploadMediaKeys(text: string): Set<string> { 101 const urls = new Set<string>() 102 if (!text) return urls 103 for (const match of text.matchAll(/\((\/api\/uploads\/[^)\s]+)\)/g)) { 104 const url = match[1] 105 if (UPLOAD_IMAGE_RE.test(url) || UPLOAD_VIDEO_RE.test(url) || UPLOAD_PDF_RE.test(url)) { 106 urls.add(normalizeUploadMediaKey(url)) 107 } 108 } 109 return urls 110 } 111 112 function flattenMarkdownNodeText(node: unknown): string { 113 if (!node || typeof node !== 'object') return '' 114 if (Array.isArray(node)) return node.map(flattenMarkdownNodeText).join('') 115 const candidate = node as { type?: string; value?: unknown; children?: unknown[] } 116 if (candidate.type === 'text' && typeof candidate.value === 'string') return candidate.value 117 if (!Array.isArray(candidate.children)) return '' 118 return candidate.children.map(flattenMarkdownNodeText).join('') 119 } 120 121 function collectInlinePreviewLinks(node: unknown): Array<{ href: string; label: string; type: 'image' | 'video' | 'pdf' }> { 122 const links: Array<{ href: string; label: string; type: 'image' | 'video' | 'pdf' }> = [] 123 const seen = new Set<string>() 124 125 const visit = (value: unknown) => { 126 if (!value || typeof value !== 'object') return 127 if (Array.isArray(value)) { 128 value.forEach(visit) 129 return 130 } 131 132 const candidate = value as { 133 type?: string 134 tagName?: string 135 properties?: Record<string, unknown> 136 children?: unknown[] 137 } 138 139 if (candidate.type === 'element' && candidate.tagName === 'a') { 140 const href = typeof candidate.properties?.href === 'string' ? candidate.properties.href : '' 141 const key = normalizeUploadMediaKey(href) 142 if (href.startsWith('/api/uploads/') && !seen.has(key)) { 143 let type: 'image' | 'video' | 'pdf' | null = null 144 if (UPLOAD_IMAGE_RE.test(href)) type = 'image' 145 else if (UPLOAD_VIDEO_RE.test(href)) type = 'video' 146 else if (UPLOAD_PDF_RE.test(href)) type = 'pdf' 147 if (type) { 148 seen.add(key) 149 links.push({ 150 href, 151 label: flattenMarkdownNodeText(candidate.children || []).trim() || 'Download', 152 type, 153 }) 154 } 155 } 156 } 157 158 if (Array.isArray(candidate.children)) { 159 candidate.children.forEach(visit) 160 } 161 } 162 163 visit(node) 164 return links 165 } 166 167 const STATUS_COLORS: Record<string, string> = { 168 progress: '#F59E0B', 169 ok: '#22C55E', 170 idle: '#6B7280', 171 blocked: '#EF4444', 172 } 173 174 const emptyToolEvents: NonNullable<Message['toolEvents']> = [] 175 const emptyLiveToolEvents: ToolEvent[] = [] 176 177 interface LiveStreamState { 178 active: boolean 179 phase: 'queued' | 'thinking' | 'tool' | 'responding' | 'connecting' 180 toolName: string 181 text: string 182 thinking: string 183 toolEvents: ToolEvent[] 184 } 185 186 type ToolMediaEntryKind = 'image' | 'video' | 'pdf' | 'file' 187 188 interface ToolMediaEntry { 189 kind: ToolMediaEntryKind 190 name: string 191 url: string 192 } 193 194 // AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS 195 // are now imported from @/components/shared/attachment-chip 196 197 function countDisplayParagraphs(text: string): number { 198 return text 199 .split(/\n\s*\n/g) 200 .map((block) => block.trim()) 201 .filter((block) => block.length > 0) 202 .length 203 } 204 205 function normalizeLiveStreamingMarkdown(text: string, options: { active: boolean; structured: boolean }): string { 206 if (!options.active || options.structured || !text) return text 207 const normalized = text.replace(/\r\n/g, '\n').trim() 208 if (!normalized.includes('\n') || /\n\s*\n/.test(normalized)) return normalized 209 return normalized.replace(/\n+/g, '\n\n') 210 } 211 212 function renderToolMediaEntry( 213 media: ToolMediaEntry, 214 key: string, 215 onOpenImage?: (image: { url: string; name: string }) => void, 216 ) { 217 if (media.kind === 'image') { 218 return ( 219 <div key={key} className="relative group/img"> 220 {/* eslint-disable-next-line @next/next/no-img-element */} 221 <img 222 src={media.url} 223 alt={media.name} 224 loading="lazy" 225 className="max-w-[400px] rounded-[10px] border border-white/10 cursor-pointer hover:border-white/25 transition-all" 226 onClick={() => onOpenImage?.({ url: media.url, name: media.name })} 227 onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} 228 /> 229 <a 230 href={media.url} 231 download 232 onClick={(e) => e.stopPropagation()} 233 className="absolute top-2 right-2 bg-black/60 backdrop-blur-sm rounded-[8px] p-1.5 hover:bg-black/80 opacity-0 group-hover/img:opacity-100 transition-opacity" 234 title="Download" 235 > 236 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round"> 237 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> 238 <polyline points="7 10 12 15 17 10" /> 239 <line x1="12" y1="15" x2="12" y2="3" /> 240 </svg> 241 </a> 242 </div> 243 ) 244 } 245 246 if (media.kind === 'video') { 247 return ( 248 <video 249 key={key} 250 src={media.url} 251 controls 252 playsInline 253 preload="none" 254 className="max-w-full rounded-[10px] border border-white/10" 255 /> 256 ) 257 } 258 259 if (media.kind === 'pdf') { 260 return ( 261 <div key={key} className="rounded-[10px] border border-white/10 overflow-hidden"> 262 <iframe src={media.url} loading="lazy" className="w-full h-[400px] bg-white" title={media.name} /> 263 <a 264 href={media.url} 265 download 266 onClick={(e) => e.stopPropagation()} 267 className="flex items-center gap-2 px-3 py-2 bg-surface/80 border-t border-white/10 text-[12px] text-text-2 hover:text-text no-underline transition-colors" 268 > 269 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 270 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> 271 <polyline points="7 10 12 15 17 10" /> 272 <line x1="12" y1="15" x2="12" y2="3" /> 273 </svg> 274 {media.name} 275 </a> 276 </div> 277 ) 278 } 279 280 return ( 281 <a 282 key={key} 283 href={media.url} 284 download 285 onClick={(e) => e.stopPropagation()} 286 className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/10 bg-surface/60 hover:bg-surface-2 transition-colors text-[13px] text-text-2 hover:text-text no-underline" 287 > 288 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 289 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> 290 <polyline points="14 2 14 8 20 8" /> 291 </svg> 292 {media.name} 293 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="ml-auto opacity-50"> 294 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> 295 <polyline points="7 10 12 15 17 10" /> 296 <line x1="12" y1="15" x2="12" y2="3" /> 297 </svg> 298 </a> 299 ) 300 } 301 302 interface Props { 303 message: Message 304 assistantName?: string 305 agentAvatarSeed?: string 306 agentAvatarUrl?: string | null 307 agentName?: string 308 cwd?: string 309 liveStream?: LiveStreamState 310 isLast?: boolean 311 onRetry?: () => void 312 messageIndex?: number 313 onToggleBookmark?: (index: number) => void 314 onEditResend?: (index: number, newText: string) => void 315 onTransferToAgent?: (messageIndex: number, agentId: string) => void 316 momentOverlay?: React.ReactNode 317 } 318 319 export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentAvatarUrl, agentName, cwd, liveStream, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onTransferToAgent, momentOverlay }: Props) { 320 const isUser = message.role === 'user' 321 const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || '')) 322 const isExtensionUI = !isUser && message.kind === 'extension-ui' 323 const scaffoldRequest = useMemo(() => { 324 if (isUser) return null 325 try { 326 const data = JSON.parse(message.text) 327 if (data.type === 'extension_scaffold_result') return data 328 } catch { /* ignore */ } 329 return null 330 }, [message.text, isUser]) 331 332 const installRequest = useMemo(() => { 333 if (isUser) return null 334 try { 335 const data = JSON.parse(message.text) 336 if (data.type === 'extension_install_result') return data 337 } catch { /* ignore */ } 338 return null 339 }, [message.text, isUser]) 340 341 const currentUser = useAppStore((s) => s.currentUser) 342 const isDesktop = useMediaQuery('(min-width: 768px)') 343 const setPreviewContent = useChatStore((s) => s.setPreviewContent) 344 const [copied, setCopied] = useState(false) 345 const [heartbeatExpanded, setHeartbeatExpanded] = useState(false) 346 const [editing, setEditing] = useState(false) 347 const [editText, setEditText] = useState('') 348 const [transferPickerOpen, setTransferPickerOpen] = useState(false) 349 const liveStreamActive = !isUser && liveStream?.active === true 350 const liveToolEvents = liveStream?.toolEvents ?? emptyLiveToolEvents 351 const toolEvents = message.toolEvents ?? emptyToolEvents 352 const toolEventsForMedia = useMemo( 353 () => (liveStreamActive 354 ? (liveToolEvents.length > 0 355 ? liveToolEvents.map((event) => ({ 356 name: event.name, 357 input: event.input, 358 output: event.output, 359 error: event.status === 'error' || undefined, 360 })) 361 : toolEvents) 362 : toolEvents), 363 [liveStreamActive, liveToolEvents, toolEvents], 364 ) 365 // Separate send_file events — they render as inline attachments, not in the tool accordion 366 const persistedToolEvents = useMemo( 367 () => toolEvents.filter((ev) => ev.name !== 'send_file' || ev.error), 368 [toolEvents], 369 ) 370 const displayToolEvents = useMemo( 371 () => (liveStreamActive 372 ? (liveToolEvents.length > 0 373 ? liveToolEvents.filter((ev) => ev.name !== 'send_file' || ev.status === 'error') 374 : persistedToolEvents.map((ev, i) => ({ 375 id: ev.toolCallId || `${message.time}-${ev.name}-${i}`, 376 name: ev.name, 377 input: ev.input, 378 output: ev.output, 379 status: ev.error ? 'error' as const : 'done' as const, 380 }))) 381 : persistedToolEvents.map((ev, i) => ({ 382 id: ev.toolCallId || `${message.time}-${ev.name}-${i}`, 383 name: ev.name, 384 input: ev.input, 385 output: ev.output, 386 status: ev.error ? 'error' as const : 'done' as const, 387 }))), 388 [liveStreamActive, liveToolEvents, message.time, persistedToolEvents], 389 ) 390 const hasToolEvents = !isUser && displayToolEvents.length > 0 391 392 // Tool pill open/close state (lifted from ToolEventsSection) 393 const [toolSectionOpen, setToolSectionOpen] = useState(false) 394 const [toolUserToggled, setToolUserToggled] = useState(false) 395 396 // Auto-expand when tools start running (mirrors old ToolEventsSection behavior) 397 const toolRunningCount = useMemo(() => { 398 let c = 0 399 for (const ev of displayToolEvents) if (ev.status === 'running') c++ 400 return c 401 }, [displayToolEvents]) 402 403 // Derive effective open state instead of setState during render 404 const effectiveToolSectionOpen = toolSectionOpen || (!toolUserToggled && toolRunningCount > 0) 405 406 const handleToolPillToggle = useCallback(() => { 407 setToolUserToggled(true) 408 setToolSectionOpen((v) => !v) 409 }, []) 410 411 412 const effectiveThinking = !isUser 413 ? (liveStreamActive ? (liveStream?.thinking?.trim() ? liveStream.thinking : undefined) : message.thinking) 414 : undefined 415 416 const sourceText = liveStreamActive ? (liveStream?.text || message.text || '') : message.text 417 const connectorDeliveryTranscript = !isUser && message.kind === 'connector-delivery' 418 ? (message.source?.deliveryTranscript?.trim() || '') 419 : '' 420 const copySourceText = connectorDeliveryTranscript || (liveStreamActive ? (liveStream?.text || message.text || '') : message.text) 421 422 // Extract ALL media from ALL tool events for inline display after the message text. 423 // Covers send_file, browser screenshots, file tool outputs — everything. 424 const allToolMedia = useMemo(() => { 425 const ordered: ToolMediaEntry[] = [] 426 const seen = new Set<string>() 427 428 for (const ev of toolEventsForMedia) { 429 if (ev.error || !ev.output) continue 430 if (!isExplicitScreenshot(ev.name, ev.input)) continue 431 const m = extractMedia(ev.output) 432 for (const url of m.images) { 433 if (!seen.has(url)) { 434 seen.add(url) 435 ordered.push({ kind: 'image', name: url.split('/').pop() || 'Image', url }) 436 } 437 } 438 for (const url of m.videos) { 439 if (!seen.has(url)) { 440 seen.add(url) 441 ordered.push({ kind: 'video', name: url.split('/').pop() || 'Video', url }) 442 } 443 } 444 for (const p of m.pdfs) { 445 if (!seen.has(p.url)) { 446 seen.add(p.url) 447 ordered.push({ kind: 'pdf', name: p.name, url: p.url }) 448 } 449 } 450 for (const f of m.files) { 451 // Reclassify image-extension files as images (send_file uses [label](url) not ) 452 if (/\.(png|jpe?g|gif|webp|svg|avif)$/i.test(f.url)) { 453 if (!seen.has(f.url)) { 454 seen.add(f.url) 455 ordered.push({ kind: 'image', name: f.name, url: f.url }) 456 } 457 } else { 458 if (!seen.has(f.url)) { 459 seen.add(f.url) 460 ordered.push({ kind: 'file', name: f.name, url: f.url }) 461 } 462 } 463 } 464 } 465 466 return ordered.length > 0 ? ordered : null 467 }, [toolEventsForMedia]) 468 const isStructured = !isUser && !isHeartbeat && isStructuredMarkdown(sourceText) 469 470 // Collect all media URLs already rendered via tool events to avoid duplicates in markdown 471 const toolEventMediaUrls = useMemo(() => { 472 if (!toolEventsForMedia.length) return null 473 const urls = new Set<string>() 474 for (const ev of toolEventsForMedia) { 475 if (!ev.output) continue 476 const m = extractMedia(ev.output) 477 for (const url of m.images) urls.add(url) 478 for (const url of m.videos) urls.add(url) 479 } 480 return urls.size > 0 ? urls : null 481 }, [toolEventsForMedia]) 482 483 // Detect delegation-source system messages 484 const delegationSource = !isUser && message.kind === 'system' ? parseDelegationSource(message.text || '') : null 485 // Detect task completion system messages (delegated or direct) 486 const taskCompletion = !isUser && message.kind === 'system' ? parseTaskCompletion(message.text || '') : null 487 const rawDisplayText = connectorDeliveryTranscript || (delegationSource ? delegationSource.rest : sourceText) 488 const displayText = rawDisplayText 489 ? stripAllInternalMetadata( 490 rawDisplayText.split('\n') 491 .filter((l) => !/\[(MAIN_LOOP_META|MAIN_LOOP_PLAN|MAIN_LOOP_REVIEW|AGENT_HEARTBEAT_META)\]/.test(l)) 492 .join('\n'), 493 ) 494 : '' 495 const hasDisplayText = displayText.length > 0 496 const normalizedDisplayText = useMemo( 497 () => normalizeLiveStreamingMarkdown(displayText, { active: liveStreamActive, structured: isStructured }), 498 [displayText, isStructured, liveStreamActive], 499 ) 500 const referencedUploadMediaKeys = useMemo( 501 () => extractReferencedUploadMediaKeys(displayText), 502 [displayText], 503 ) 504 const unreferencedToolMedia = useMemo(() => { 505 if (!allToolMedia) return null 506 const filtered = allToolMedia.filter((media) => ( 507 media.kind === 'file' 508 ? true 509 : !referencedUploadMediaKeys.has(normalizeUploadMediaKey(media.url)) 510 )) 511 return filtered.length > 0 ? filtered : null 512 }, [allToolMedia, referencedUploadMediaKeys]) 513 514 const liveInlineToolMedia = useMemo(() => { 515 if (!liveStreamActive || !unreferencedToolMedia || referencedUploadMediaKeys.size > 0 || !hasDisplayText) return null 516 const inlineCount = Math.min(unreferencedToolMedia.length, countDisplayParagraphs(normalizedDisplayText)) 517 return inlineCount > 0 ? unreferencedToolMedia.slice(0, inlineCount) : null 518 }, [hasDisplayText, liveStreamActive, normalizedDisplayText, referencedUploadMediaKeys, unreferencedToolMedia]) 519 520 const trailingToolMedia = useMemo(() => { 521 if (!unreferencedToolMedia) return null 522 if (!liveInlineToolMedia) return unreferencedToolMedia 523 const remaining = unreferencedToolMedia.slice(liveInlineToolMedia.length) 524 return remaining.length > 0 ? remaining : null 525 }, [liveInlineToolMedia, unreferencedToolMedia]) 526 527 const handleOpenAttachmentImage = useCallback(({ url, filename }: { url: string; filename: string }) => { 528 setPreviewContent({ type: 'image', url, title: filename }) 529 }, [setPreviewContent]) 530 531 const handleOpenToolMediaImage = useCallback(({ url, name }: { url: string; name: string }) => { 532 setPreviewContent({ type: 'image', url, title: name }) 533 }, [setPreviewContent]) 534 535 const handleCopy = useCallback(() => { 536 void copyTextToClipboard(copySourceText).then((copiedText) => { 537 if (!copiedText) return 538 setCopied(true) 539 setTimeout(() => setCopied(false), 2000) 540 }) 541 }, [copySourceText]) 542 543 const connectorMeta = connectorThreadMeta(message, isUser) 544 const hasPrimaryAttachments = Boolean(message.imagePath || message.imageUrl || message.attachedFiles?.length) 545 const shouldRenderBubbleShell = hasPrimaryAttachments 546 || Boolean(allToolMedia) 547 || Boolean(installRequest) 548 || Boolean(scaffoldRequest) 549 || isExtensionUI 550 || isHeartbeat 551 || hasDisplayText 552 const canCopy = copySourceText.trim().length > 0 553 const showActions = canCopy 554 || (typeof messageIndex === 'number' && Boolean(onToggleBookmark)) 555 || (isUser && typeof messageIndex === 'number' && Boolean(onEditResend)) 556 || (!isUser && isLast && Boolean(onRetry)) 557 || (!isUser && typeof messageIndex === 'number' && Boolean(onTransferToAgent)) 558 const safeMomentOverlay = isValidElement(momentOverlay) ? momentOverlay : null 559 560 return ( 561 <div 562 data-testid="message-bubble" 563 data-message-role={message.role} 564 data-message-kind={message.kind || 'chat'} 565 data-message-time={message.time || undefined} 566 data-message-has-tools={hasToolEvents || undefined} 567 className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start relative pl-[44px]'}`} 568 > 569 {/* Avatar on spine (assistant) */} 570 {!isUser && ( 571 <div className="absolute left-[4px] top-0"> 572 <div style={safeMomentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}> 573 {agentName 574 ? <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={28} /> 575 : <AiAvatar size="sm" mood={liveStream?.phase === 'tool' ? 'tool' : liveStreamActive ? 'thinking' : undefined} />} 576 </div> 577 {safeMomentOverlay} 578 </div> 579 )} 580 {/* Sender label + timestamp */} 581 <div className={`flex flex-col gap-0.5 mb-2 px-1 ${isUser ? 'items-end' : 'items-start'}`}> 582 <div className={`flex items-center gap-2.5 ${isUser ? 'flex-row-reverse' : ''}`}> 583 <span className={`text-[12px] font-600 flex items-center gap-1.5 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}> 584 {message.source && ( 585 <ConnectorPlatformIcon platform={message.source.platform} size={12} /> 586 )} 587 {isUser 588 ? (message.source?.senderName 589 ? `${message.source.senderName} via ${getConnectorPlatformLabel(message.source.platform)}` 590 : (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You')) 591 : (message.source 592 ? `${assistantName || 'Claude'} via ${getConnectorPlatformLabel(message.source.platform)}` 593 : (assistantName || 'Claude'))} 594 </span> 595 {hasToolEvents && ( 596 <ToolActivityPill 597 toolEvents={displayToolEvents} 598 isOpen={effectiveToolSectionOpen} 599 onToggle={handleToolPillToggle} 600 /> 601 )} 602 {!isUser && liveStreamActive && ( 603 <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-accent-bright/10 border border-accent-bright/15" 604 style={{ animation: 'pulse-subtle 2s ease-in-out infinite' }}> 605 <span className={`w-1.5 h-1.5 rounded-full ${ 606 liveStream?.phase === 'queued' ? 'bg-amber-400' : 'bg-accent-bright' 607 }`} style={{ animation: 'pulse 1.5s ease infinite' }} /> 608 <span className="text-[10px] text-accent-bright/80 font-mono font-600"> 609 {liveStream?.phase === 'queued' ? 'Queued...' 610 : liveStream?.phase === 'tool' && liveStream.toolName ? `Using ${liveStream.toolName}...` 611 : liveStream?.phase === 'responding' ? 'Responding...' 612 : liveStream?.phase === 'connecting' ? 'Reconnecting...' 613 : 'Thinking...'} 614 </span> 615 </span> 616 )} 617 <span className="text-[11px] text-text-3/70 font-mono" title={message.time ? new Date(message.time).toLocaleString() : ''}> 618 {message.time ? formatMessageTimestamp(message) : ''} 619 </span> 620 </div> 621 {connectorMeta && ( 622 <div className={`text-[10px] font-mono text-text-3/55 ${isUser ? 'text-right' : ''}`}> 623 {connectorMeta} 624 </div> 625 )} 626 </div> 627 628 {/* Tool events expanded card (controlled by pill toggle) */} 629 {hasToolEvents && effectiveToolSectionOpen && ( 630 <div className="max-w-[85%] md:max-w-[72%] mb-2" data-testid="tool-activity"> 631 <div className="rounded-[16px] border border-white/[0.08] bg-surface/72 backdrop-blur-sm overflow-hidden"> 632 <ToolEventsSection toolEvents={displayToolEvents} controlled /> 633 </div> 634 </div> 635 )} 636 637 638 {/* Thinking block (collapsible, shown for assistant messages with persisted thinking) */} 639 {!isUser && effectiveThinking && ( 640 <div className="max-w-[85%] md:max-w-[72%] mb-2"> 641 <details className="group rounded-[12px] border border-purple-500/15 bg-purple-500/[0.04]"> 642 <summary className="flex items-center gap-2 px-3.5 py-2.5 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden"> 643 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-purple-400/60 shrink-0 transition-transform group-open:rotate-90"> 644 <polyline points="9 18 15 12 9 6" /> 645 </svg> 646 <span className="text-[11px] font-600 text-purple-400/70 uppercase tracking-[0.05em]">Thinking</span> 647 {!liveStreamActive && ( 648 <span className="text-[10px] text-text-3/40 font-mono">{Math.ceil(effectiveThinking.length / 4)} tokens</span> 649 )} 650 </summary> 651 <div className="px-3.5 pb-3 pt-1 max-h-[300px] overflow-y-auto"> 652 <div className="text-[13px] leading-[1.6] text-text-3/70 whitespace-pre-wrap break-words"> 653 {effectiveThinking} 654 </div> 655 </div> 656 </details> 657 </div> 658 )} 659 660 {/* Delegation source banner (receiving agent's chat) */} 661 {delegationSource && (() => { 662 const taskLinkMatch = delegationSource.rest.match(/\[([^\]]+)\]\(#task:([^)]+)\)/) 663 const dsTaskTitle = taskLinkMatch?.[1] || '' 664 const dsTaskId = taskLinkMatch?.[2] || null 665 const descLines = delegationSource.rest.split('\n\n').slice(1).filter((l) => !l.startsWith('Working directory:') && !l.startsWith("I'll begin")) 666 const dsDescription = descLines.join(' ').trim().slice(0, 200) 667 return ( 668 <div className="max-w-[85%] md:max-w-[72%] mb-2"> 669 <DelegationSourceBanner 670 delegatorName={delegationSource.delegatorName} 671 delegatorAvatarSeed={delegationSource.delegatorAvatarSeed || null} 672 taskTitle={dsTaskTitle} 673 taskId={dsTaskId} 674 description={dsDescription} 675 /> 676 </div> 677 ) 678 })()} 679 680 {/* Task completion card (replaces bubble for task result system messages) */} 681 {taskCompletion ? ( 682 <div className="max-w-[85%] md:max-w-[72%]"> 683 <TaskCompletionCard info={{ ...taskCompletion, imageUrl: message.imageUrl }} /> 684 </div> 685 ) : shouldRenderBubbleShell ? ( 686 /* Message bubble */ 687 <div className={`${isStructured ? 'max-w-[92%] md:max-w-[85%]' : 'max-w-[85%] md:max-w-[72%]'} ${isUser ? 'bubble-user px-5 py-3.5' : isHeartbeat ? 'bubble-ai px-4 py-3' : 'bubble-ai px-5 py-3.5'}`}> 688 {installRequest ? ( 689 <div className="flex flex-col gap-3 p-4 rounded-[18px] bg-emerald-500/[0.03] border border-emerald-500/20 shadow-[0_0_20px_rgba(16,185,129,0.05)]"> 690 <div className="flex items-center gap-2 mb-1"> 691 <div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center text-emerald-400"> 692 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> 693 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> 694 <polyline points="17 8 12 3 7 8" /> 695 <line x1="12" y1="3" x2="12" y2="15" /> 696 </svg> 697 </div> 698 <span className="text-[11px] font-700 uppercase tracking-wider text-emerald-400/80">Extension Installed</span> 699 </div> 700 <p className="text-[13px] text-text-2/90 leading-relaxed">{installRequest.message}</p> 701 <div className="p-3 rounded-[12px] bg-black/40 border border-white/5 flex flex-col gap-1"> 702 <div className="text-[11px] text-text-3/60 font-600 uppercase tracking-tight">Extension</div> 703 <div className="text-[12px] font-mono text-emerald-200/70">{installRequest.filename || installRequest.extensionId || 'extension'}</div> 704 <div className="text-[11px] text-text-3/60 font-600 uppercase tracking-tight mt-2">Source URL</div> 705 <div className="text-[12px] font-mono text-emerald-200/70 truncate">{installRequest.url}</div> 706 </div> 707 </div> 708 ) : scaffoldRequest ? ( 709 <div className="flex flex-col gap-3 p-4 rounded-[18px] bg-amber-500/[0.03] border border-amber-500/20 shadow-[0_0_20px_rgba(245,158,11,0.05)]"> 710 <div className="flex items-center gap-2 mb-1"> 711 <div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center text-amber-400"> 712 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> 713 <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" /> 714 </svg> 715 </div> 716 <span className="text-[11px] font-700 uppercase tracking-wider text-amber-400/80">Extension Created</span> 717 </div> 718 <p className="text-[13px] text-text-2/90 leading-relaxed">{scaffoldRequest.message}</p> 719 <div className="p-3 rounded-[12px] bg-black/40 border border-white/5"> 720 <div className="text-[11px] font-mono text-text-3/60 mb-2 border-b border-white/5 pb-1">filename: {scaffoldRequest.filename}</div> 721 {scaffoldRequest.filePath && ( 722 <div className="text-[12px] font-mono text-amber-200/70 break-all"> 723 {scaffoldRequest.filePath} 724 </div> 725 )} 726 </div> 727 </div> 728 ) : isExtensionUI ? ( 729 <div className="flex flex-col gap-2 p-4 rounded-[18px] bg-emerald-500/[0.03] border border-emerald-500/10 shadow-[0_0_20px_rgba(16,185,129,0.05)]"> 730 <div className="flex items-center gap-2 mb-2"> 731 <div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center"> 732 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="2.5"> 733 <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" /> 734 </svg> 735 </div> 736 <span className="text-[11px] font-700 uppercase tracking-wider text-emerald-400/80">Extension UI Extension</span> 737 </div> 738 <div className="text-[14px] text-text-2/90 leading-relaxed"> 739 <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.text}</ReactMarkdown> 740 </div> 741 <div className="flex gap-2 mt-2"> 742 {tryParseJson(message.text)?.actions ? (tryParseJson(message.text)!.actions as Array<{ id: string; href: string; label: string }>).map((action) => ( 743 <button 744 key={action.id} 745 onClick={() => window.open(action.href, '_blank')} 746 className="px-3 py-1.5 rounded-[10px] bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-[11px] font-600 transition-all border border-emerald-500/10" 747 > 748 {action.label} 749 </button> 750 )) : null} 751 </div> 752 </div> 753 ) : isHeartbeat ? ( 754 <div className="flex flex-col gap-2"> 755 <button 756 type="button" 757 onClick={() => setHeartbeatExpanded((v) => !v)} 758 className="w-full rounded-[12px] px-3.5 py-3 border border-white/[0.10] bg-white/[0.02] text-left hover:bg-white/[0.04] transition-colors cursor-pointer" 759 > 760 <div className="flex items-center justify-between gap-3"> 761 <div className="flex items-center gap-2"> 762 {(() => { 763 const meta = parseHeartbeatMeta(message.text) 764 const statusColor = meta?.status ? (STATUS_COLORS[meta.status] || '#6B7280') : '#22C55E' 765 return <span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: statusColor }} /> 766 })()} 767 <span className="text-[11px] uppercase tracking-[0.08em] text-text-2 font-600">Heartbeat</span> 768 {(() => { 769 const meta = parseHeartbeatMeta(message.text) 770 if (!meta?.status) return null 771 const color = STATUS_COLORS[meta.status] || '#6B7280' 772 return <span className="text-[10px] font-500 px-1.5 py-0.5 rounded-[4px]" style={{ color, background: `${color}18` }}>{meta.status}</span> 773 })()} 774 </div> 775 <span className="text-[11px] text-text-3">{heartbeatExpanded ? 'Collapse' : 'Expand'}</span> 776 </div> 777 {(() => { 778 const meta = parseHeartbeatMeta(message.text) 779 if (meta && (meta.goal || meta.next_action)) { 780 return ( 781 <div className="mt-2 flex flex-col gap-1"> 782 {meta.goal && ( 783 <div className="flex items-baseline gap-1.5"> 784 <span className="text-[10px] uppercase tracking-[0.06em] text-text-3 font-600 shrink-0">Goal</span> 785 <span className="text-[12px] text-text-2/90 truncate">{meta.goal}</span> 786 </div> 787 )} 788 {meta.next_action && ( 789 <div className="flex items-baseline gap-1.5"> 790 <span className="text-[10px] uppercase tracking-[0.06em] text-text-3 font-600 shrink-0">Next</span> 791 <span className="text-[12px] text-text-2/90 truncate">{meta.next_action}</span> 792 </div> 793 )} 794 </div> 795 ) 796 } 797 return <p className="text-[13px] text-text-2/90 leading-[1.5] mt-1.5">{heartbeatSummary(message.text)}</p> 798 })()} 799 </button> 800 {heartbeatExpanded && ( 801 <div className="msg-content text-[14px] leading-[1.7] text-text break-words px-3 py-2 rounded-[10px] border border-white/[0.08] bg-black/20"> 802 <ReactMarkdown 803 remarkPlugins={[remarkGfm]} 804 rehypePlugins={[rehypeHighlight]} 805 components={{ 806 pre({ children }) { 807 return <pre>{children}</pre> 808 }, 809 code({ className, children }) { 810 const isBlock = className?.startsWith('language-') || className?.startsWith('hljs') 811 if (isBlock) return <CodeBlock className={className}>{children}</CodeBlock> 812 return <code className={className}>{children}</code> 813 }, 814 }} 815 > 816 {message.text.replace(/\[AGENT_HEARTBEAT_META\]\s*\{[^\n]*\}/gi, '').trim()} 817 </ReactMarkdown> 818 </div> 819 )} 820 </div> 821 ) : hasDisplayText ? ( 822 <div className={`msg-content text-[15px] md:text-[14px] break-words ${liveStreamActive ? 'streaming-cursor' : ''} ${isUser ? 'leading-[1.6] text-white/95' : 'leading-[1.7] text-text'}`}> 823 {!isUser && message.kind === 'connector-delivery' && connectorDeliveryTranscript && ( 824 <div className="mb-3 inline-flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-3 py-1 text-[11px] font-700 uppercase tracking-[0.12em] text-emerald-200/85"> 825 <span>Delivered via connector</span> 826 {message.source?.deliveryMode === 'voice_note' && ( 827 <span className="text-emerald-100/70">voice note</span> 828 )} 829 </div> 830 )} 831 {(() => { 832 let liveInlineToolMediaIndex = 0 833 return ( 834 <MarkdownBody 835 text={normalizedDisplayText} 836 skipMediaUrls={toolEventMediaUrls || undefined} 837 renderParagraph={(node, children) => { 838 const previews = collectInlinePreviewLinks(node) 839 const streamedInlineMedia = previews.length === 0 840 ? liveInlineToolMedia?.[liveInlineToolMediaIndex++] ?? null 841 : null 842 if (previews.length === 0 && !streamedInlineMedia) return null // use default <p> 843 return ( 844 <> 845 <p>{children}</p> 846 <div className="mt-3 mb-1 flex flex-col gap-3"> 847 {previews.map((preview) => ( 848 <span key={`${preview.type}:${preview.href}`} className="block max-w-full"> 849 {preview.type === 'image' && ( 850 <a href={preview.href} download target="_blank" rel="noopener noreferrer" className="block max-w-full"> 851 {/* eslint-disable-next-line @next/next/no-img-element */} 852 <img 853 src={preview.href} 854 alt={preview.label} 855 loading="lazy" 856 className="max-w-[400px] rounded-[10px] border border-white/10 hover:border-white/25 transition-colors" 857 onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} 858 /> 859 </a> 860 )} 861 {preview.type === 'video' && ( 862 <video src={preview.href} controls playsInline preload="none" className="max-w-full rounded-[10px] border border-white/10" /> 863 )} 864 {preview.type === 'pdf' && ( 865 <span className="block w-full max-w-[520px] overflow-hidden rounded-[10px] border border-white/10"> 866 <iframe src={preview.href} loading="lazy" className="h-[360px] w-full bg-white" title={preview.label} /> 867 </span> 868 )} 869 </span> 870 ))} 871 {streamedInlineMedia && renderToolMediaEntry( 872 streamedInlineMedia, 873 `inline-tool-media-${liveInlineToolMediaIndex}-${streamedInlineMedia.url}`, 874 handleOpenToolMediaImage, 875 )} 876 </div> 877 </> 878 ) 879 }} 880 renderInlineCode={(text) => { 881 if (text && (FILE_PATH_RE.test(text) || (DIR_PATH_RE.test(text) && text.split('/').length > 2))) { 882 return <FilePathChip filePath={text.replace(/\/$/, '')} cwd={cwd} /> 883 } 884 return null 885 }} 886 renderLink={(href, children) => { 887 // Internal app links: #task:<id> 888 const taskMatch = href.match(/^#task:(.+)$/) 889 if (taskMatch) { 890 return ( 891 <button 892 type="button" 893 onClick={async () => { 894 const store = useAppStore.getState() 895 await store.loadTasks(true) 896 store.setTaskSheetViewOnly(true) 897 store.setEditingTaskId(taskMatch[1]) 898 store.setTaskSheetOpen(true) 899 }} 900 className="inline-flex items-center gap-1 text-purple-400 hover:text-purple-300 underline cursor-pointer bg-transparent border-none p-0 font-inherit text-inherit" 901 > 902 {children} 903 </button> 904 ) 905 } 906 // #schedule:<id> 907 const schedMatch = href.match(/^#schedule:(.+)$/) 908 if (schedMatch) { 909 return ( 910 <button 911 type="button" 912 onClick={async () => { 913 const store = useAppStore.getState() 914 await store.loadSchedules() 915 store.setEditingScheduleId(schedMatch[1]) 916 store.setScheduleSheetOpen(true) 917 }} 918 className="inline-flex items-center gap-1 text-amber-400 hover:text-amber-300 underline cursor-pointer bg-transparent border-none p-0 font-inherit text-inherit" 919 > 920 {children} 921 </button> 922 ) 923 } 924 // Upload links (agent chat has richer handling than default) 925 const isUpload = href.startsWith('/api/uploads/') 926 if (isUpload) { 927 const uploadPath = href.split('?')[0] 928 const uploadIsHtml = /\.(html?)$/i.test(uploadPath) 929 return ( 930 <span className="inline-flex items-center gap-1.5"> 931 <a href={href} download className="inline-flex items-center gap-1.5 text-sky-400 hover:text-sky-300 underline"> 932 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0"> 933 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> 934 <polyline points="7 10 12 15 17 10" /> 935 <line x1="12" y1="15" x2="12" y2="3" /> 936 </svg> 937 {children} 938 </a> 939 {uploadIsHtml && ( 940 <a href={href} target="_blank" rel="noopener noreferrer" 941 className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-accent-soft hover:bg-accent-soft/80 text-accent-bright text-[10px] font-600 no-underline transition-colors" 942 title="Preview in new tab"> 943 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 944 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> 945 <circle cx="12" cy="12" r="3" /> 946 </svg> 947 Preview 948 </a> 949 )} 950 </span> 951 ) 952 } 953 return null // fall through to MarkdownBody defaults 954 }} 955 /> 956 ) 957 })()} 958 </div> 959 ) : null 960 } 961 962 <MessageAttachments 963 imagePath={message.imagePath} 964 imageUrl={message.imageUrl} 965 attachedFiles={message.attachedFiles} 966 isUser={isUser} 967 onOpenImage={isDesktop ? handleOpenAttachmentImage : undefined} 968 /> 969 970 {trailingToolMedia && ( 971 <div className={`flex flex-col gap-2 ${hasDisplayText || hasPrimaryAttachments ? 'mt-3' : ''}`}> 972 {trailingToolMedia.map((media, i) => renderToolMediaEntry(media, `tm-${i}-${media.url}`, handleOpenToolMediaImage))} 973 </div> 974 )} 975 </div> 976 ) : null} 977 978 {!isUser && (message.citations?.length || message.retrievalTrace?.hits?.length) ? ( 979 <div className="mt-2 max-w-[85%] md:max-w-[72%]"> 980 <GroundingPanel 981 citations={message.citations} 982 retrievalTrace={message.retrievalTrace} 983 /> 984 </div> 985 ) : null} 986 987 {/* Bookmark indicator */} 988 {message.bookmarked && ( 989 <div className={`flex items-center gap-1 mt-1 px-1 ${isUser ? 'justify-end' : ''}`}> 990 <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" className="shrink-0 text-amber-400"> 991 <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" /> 992 </svg> 993 <span className="text-[10px] text-amber-400/70 font-600">Bookmarked</span> 994 </div> 995 )} 996 997 {/* Action buttons */} 998 {showActions && ( 999 <MessageActions layout="bubble" align={isUser ? 'end' : 'start'}> 1000 {canCopy && ( 1001 <ActionButton 1002 onClick={handleCopy} 1003 title="Copy message" 1004 icon={<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>} 1005 label={copied ? 'Copied' : 'Copy'} 1006 /> 1007 )} 1008 {typeof messageIndex === 'number' && onToggleBookmark && ( 1009 <ActionButton 1010 onClick={() => onToggleBookmark(messageIndex)} 1011 title={message.bookmarked ? 'Remove bookmark' : 'Bookmark message'} 1012 active={message.bookmarked} 1013 activeClassName="text-amber-400" 1014 icon={<svg width="12" height="12" viewBox="0 0 24 24" fill={message.bookmarked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" /></svg>} 1015 label={message.bookmarked ? 'Unbookmark' : 'Bookmark'} 1016 /> 1017 )} 1018 {isUser && typeof messageIndex === 'number' && onEditResend && ( 1019 <ActionButton 1020 onClick={() => { setEditText(message.text); setEditing(true) }} 1021 title="Edit and resend" 1022 icon={<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>} 1023 label="Edit" 1024 /> 1025 )} 1026 {!isUser && isLast && onRetry && ( 1027 <ActionButton 1028 onClick={onRetry} 1029 title="Retry message" 1030 icon={<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" /></svg>} 1031 label="Retry" 1032 /> 1033 )} 1034 {!isUser && typeof messageIndex === 'number' && onTransferToAgent && ( 1035 <div className="relative"> 1036 <ActionButton 1037 onClick={() => setTransferPickerOpen(!transferPickerOpen)} 1038 title="Transfer to another agent" 1039 icon={<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M8 3L4 7l4 4" /><path d="M4 7h16" /><path d="M16 21l4-4-4-4" /><path d="M20 17H4" /></svg>} 1040 label="Transfer" 1041 /> 1042 {transferPickerOpen && ( 1043 <TransferAgentPicker 1044 onSelect={(agentId) => { onTransferToAgent(messageIndex, agentId); setTransferPickerOpen(false) }} 1045 onClose={() => setTransferPickerOpen(false)} 1046 /> 1047 )} 1048 </div> 1049 )} 1050 </MessageActions> 1051 )} 1052 1053 {/* Inline edit mode */} 1054 {editing && ( 1055 <div className={`max-w-[85%] md:max-w-[72%] mt-2 ${isUser ? 'self-end' : ''}`} style={{ animation: 'fade-in 0.2s ease' }}> 1056 <textarea 1057 value={editText} 1058 onChange={(e) => setEditText(e.target.value)} 1059 className="w-full min-h-[80px] p-3 rounded-[12px] bg-surface border border-white/[0.08] text-text text-[14px] resize-y outline-none focus:border-accent-bright/30" 1060 style={{ fontFamily: 'inherit' }} 1061 /> 1062 <div className="flex gap-2 mt-2 justify-end"> 1063 <button 1064 onClick={() => setEditing(false)} 1065 className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 text-text-3 bg-white/[0.04] hover:bg-white/[0.07] border-none cursor-pointer transition-colors" 1066 > 1067 Cancel 1068 </button> 1069 <button 1070 onClick={() => { 1071 if (editText.trim() && typeof messageIndex === 'number' && onEditResend) { 1072 onEditResend(messageIndex, editText.trim()) 1073 setEditing(false) 1074 } 1075 }} 1076 className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 text-white bg-accent-bright hover:bg-accent-bright/80 border-none cursor-pointer transition-colors" 1077 > 1078 Save & Resend 1079 </button> 1080 </div> 1081 </div> 1082 )} 1083 </div> 1084 ) 1085 })