/ src / components / chat / message-bubble.tsx
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 ![](url))
 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  })