import { useState } from 'react'; import { Streamdown } from 'streamdown'; import { code } from '@streamdown/code'; import 'streamdown/styles.css'; import { Paperclip, Copy, Check, ExternalLink, Mic, Play, Pause, Laptop, Smartphone, Monitor, Globe, Volume2 } from 'lucide-react'; import AudioBubble from './AudioBubble'; import AuthedImage from './AuthedImage'; import EnvForm, { type EnvGroupData, type EnvField } from './EnvForm'; import BlobyImageCard from './BlobyImageCard'; import BlobyTextCard from './BlobyTextCard'; import NotchCard from './NotchCard'; import MorphyActionCard from './MorphyActionCard'; import type { StoredAttachment } from '../../hooks/useChat'; import type { LightboxImage } from './ImageLightbox'; interface Props { role: 'user' | 'assistant'; content: string; timestamp?: string; hasAttachments?: boolean; audioData?: string; attachments?: StoredAttachment[]; onImageClick?: (images: LightboxImage[], index: number) => void; transcribing?: boolean; } function formatTime(iso: string): string { try { const d = new Date(iso); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } } /** Parse the leading channel tag from content, e.g. "[Mac]\n..." → { tag: "Mac", body: "..." }. * Messages without an explicit tag default to "Chat" (PWA chat bubble). * Also strips the [voice] prefix that the workspace prepends to voice transcripts, * and trims any "[Mac] " / "[Alexa] " etc. that the agent echoes at the start of replies. */ function parseChannelTag(text: string): { tag: string; body: string } { // Channel tag form: "[Name]\n..." (followed by newline) — set on user messages const m = text.match(/^\[([^\]]+)\]\n([\s\S]*)$/); let body: string; let tag = 'Chat'; if (m) { body = m[2]; const raw = m[1].split('|')[0].trim(); if (raw === 'PWA') tag = 'Chat'; else if (raw.toLowerCase() === 'workspace') tag = 'Workspace'; else tag = raw; } else { body = text; } // Strip "[voice] " prefix the workspace prepends to voice transcripts if (body.startsWith('[voice] ')) body = body.slice('[voice] '.length); // Strip echoed channel name the agent sometimes prefixes to its reply, // e.g. "[Mac] Got it" or "[Alexa] sure thing" body = body.replace(/^\[(Mac|Alexa|WhatsApp|Chat|PWA|Workspace|workspace)\]\s*/i, ''); return { tag, body }; } function ChannelIcon({ tag }: { tag: string }) { const cls = 'h-3 w-3 opacity-60'; if (tag === 'Mac') return ; if (tag === 'WhatsApp') return ; if (tag === 'Workspace') return ; if (tag === 'Chat') return ; if (tag === 'Alexa') return ; return ; } /** Convert channel-pair URLs (any format) into markdown links so the buttons render */ function preprocessContent(text: string): string { let out = text; const waLink = '[pair-whatsapp](/api/channels/whatsapp/qr-page)'; if (!out.includes(waLink)) { out = out.replace( /`?(?:http:\/\/localhost:\d+)?\/api\/channels\/whatsapp\/qr-page`?/g, waLink, ); } const alexaLink = '[connect-alexa](/api/channels/alexa/pair-page)'; if (!out.includes(alexaLink)) { out = out.replace( /`?(?:http:\/\/localhost:\d+)?\/api\/channels\/alexa\/pair-page`?/g, alexaLink, ); } const telegramLink = '[connect-telegram](/api/channels/telegram/pair-page)'; if (!out.includes(telegramLink)) { out = out.replace( /`?(?:http:\/\/localhost:\d+)?\/api\/channels\/telegram\/pair-page`?/g, telegramLink, ); } return out; } /** One entry from a JSON array (the current Mac action-registry format). * `card` actions carry a preset + data (rendered as a notch card); the screen-acting * verbs (point/spotlight/…) carry coordinates we don't surface. Kept loose so unknown * future verbs still parse. */ type MacAction = { type?: string; preset?: string; data?: unknown; screen?: number; [key: string]: unknown }; type ContentSegment = { type: 'text'; value: string } | { type: 'env'; group: EnvGroupData } | { type: 'bloby-image'; src: string; alt: string } | { type: 'bloby-text'; title: string; content: string } | { type: 'notch'; format: 'html' | 'card'; content: string; cardType?: string } | { type: 'morphy-action'; actionType: string; content: string } | { type: 'mac-actions'; actions: MacAction[] }; /** Extract and tags from content, splitting into typed segments */ function extractContentSegments(text: string): ContentSegment[] { // Collect all custom tag matches with their positions const matches: { start: number; end: number; segment: ContentSegment }[] = []; // Find blocks const envRegex = /([\s\S]*?)<\/EnvGroup>/g; let match: RegExpExecArray | null; while ((match = envRegex.exec(text)) !== null) { const title = match[1]; const inner = match[2]; const fields: EnvField[] = []; const inputRegex = //g; let inputMatch: RegExpExecArray | null; while ((inputMatch = inputRegex.exec(inner)) !== null) { fields.push({ name: inputMatch[1], label: inputMatch[2], placeholder: inputMatch[3] }); } if (fields.length > 0) { matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'env', group: { title, fields } } }); } } // Find tags const imgRegex = //g; while ((match = imgRegex.exec(text)) !== null) { matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'bloby-image', src: match[1], alt: match[2] || '' } }); } // Find blocks const textRegex = /([\s\S]*?)<\/BlobyText>/g; while ((match = textRegex.exec(text)) !== null) { matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'bloby-text', title: match[1], content: match[2].trim() } }); } // Find blocks — custom Mac-notch cards (rendered as a live preview) const notchHtmlRegex = /([\s\S]*?)<\/notch_html>/gi; while ((match = notchHtmlRegex.exec(text)) !== null) { matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'notch', format: 'html', content: match[1].trim() } }); } // Find blocks — preset Mac-notch cards (shown as JSON) const notchCardRegex = /([\s\S]*?)<\/notch_card>/gi; while ((match = notchCardRegex.exec(text)) !== null) { matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'notch', format: 'card', content: match[2].trim(), cardType: match[1] } }); } // Find blocks — legacy single Mac screen action (spotlight/ // point/…), shown as a compact "Agent actions" chip rather than the raw tag + coordinates. const morphyActionRegex = /([\s\S]*?)<\/morphy_action>/gi; while ((match = morphyActionRegex.exec(text)) !== null) { matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'morphy-action', actionType: match[1], content: match[2].trim() } }); } // Find blocks — the current action-registry format: a JSON array of // actions. Each entry is fanned out at render time — `card` → a notch card, every // screen-acting verb (point/spotlight/…) → an "Agent actions" chip. A malformed/ // non-array payload yields no actions (nothing rendered) rather than raw JSON. const macActionsRegex = /([\s\S]*?)<\/mac_actions>/gi; while ((match = macActionsRegex.exec(text)) !== null) { let actions: MacAction[] = []; try { const parsed = JSON.parse(match[1].trim()); if (Array.isArray(parsed)) actions = parsed; } catch { /* malformed JSON — render nothing for this block */ } matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'mac-actions', actions } }); } // Sort by position in text matches.sort((a, b) => a.start - b.start); // Build segments with text between tags const segments: ContentSegment[] = []; let lastIndex = 0; for (const m of matches) { if (m.start > lastIndex) { const before = text.slice(lastIndex, m.start).trim(); if (before) segments.push({ type: 'text', value: before }); } segments.push(m.segment); lastIndex = m.end; } if (lastIndex < text.length) { const after = text.slice(lastIndex).trim(); if (after) segments.push({ type: 'text', value: after }); } return segments; } /** Check if content has any custom tags that need special rendering */ function hasCustomTags(text: string): boolean { return //i.test(text) || / {copied ? : } ); } /** Opens a table element in a new browser tab with matching dark styling */ function openTableInNewTab(tableEl: HTMLElement) { const html = tableEl.outerHTML; const win = window.open('', '_blank'); if (!win) return; win.document.write(`Table ${html}`); win.document.close(); } /** * Intercept Streamdown's fullscreen button click and open table in a new tab instead. * Uses capture phase so it fires before Streamdown's handler. */ function handleStreamdownClick(e: React.MouseEvent) { const target = e.target as HTMLElement; const fullscreenBtn = target.closest('[title="View fullscreen"]'); if (fullscreenBtn) { e.stopPropagation(); e.preventDefault(); // Walk up from the button to find the table wrapper, then the table inside it const wrapper = fullscreenBtn.closest('[data-streamdown]'); const tableEl = wrapper?.querySelector('table') ?? fullscreenBtn.closest('.group')?.querySelector('table'); if (tableEl) openTableInNewTab(tableEl); } } export default function MessageBubble({ role, content, timestamp, hasAttachments, audioData, attachments, onImageClick, transcribing }: Props) { const isUser = role === 'user'; const time = timestamp ? formatTime(timestamp) : ''; // Strip channel tag (and [voice] prefix / echoed tags in assistant replies) from BOTH sides const { tag: channelTag, body: displayContent } = parseChannelTag(content); // Separate image and document attachments. An attachment counts as an image whenever // its mediaType says so, its type is 'image', OR its name/path has an image extension — // Mac/channel attachments often arrive with a missing or odd mediaType. const isImageAtt = (a: StoredAttachment) => a.mediaType?.startsWith('image/') || a.type === 'image' || /\.(png|jpe?g|gif|webp|avif|bmp|svg|heic|heif)$/i.test(a.name || a.filePath || ''); const imageAtts = attachments?.filter(isImageAtt) || []; const docAtts = attachments?.filter((a) => !isImageAtt(a)) || []; // Resolve image URLs (keeping them aligned 1:1 with imageAtts so names thread through) const imageAttsWithUrl = imageAtts.filter((a) => a.filePath); const imageUrls = imageAttsWithUrl.map((a) => a.filePath.startsWith('data:') ? a.filePath : `/api/files/${a.filePath}` ); // Lightbox data model: thread the human filename alongside each URL so downloads/alt // text use the real name rather than a data:-URL or random stamp. const imageItems: LightboxImage[] = imageUrls.map((url, i) => ({ url, name: imageAttsWithUrl[i]?.name, })); if (isUser) { return (
{/* Image thumbnails */} {imageUrls.length > 0 && (
{imageUrls.map((url, i) => ( onImageClick?.(imageItems, i)} /> ))}
)} {/* Document attachments — one row per file with paperclip + filename */} {docAtts.length > 0 && (
{docAtts.map((a, i) => { const label = a.name || a.filePath?.split('/').pop() || 'file'; return ( {label} ); })}
)} {/* Fallback paperclip for legacy messages with no parsed attachments */} {!attachments?.length && hasAttachments && ( )} {/* Transcribing shimmer */} {transcribing && (
)} {/* Text content */} {displayContent} {/* Inline audio bar */} {audioData && (
)}
{/* Timestamp + channel icon (icon only on user messages — it represents the sender) */}
{channelTag} {time && {time}}
); } // Shared Streamdown renderer for text segments const renderStreamdown = (text: string) => (
Click here to pair your WhatsApp Opens QR code pairing page
); } if (href?.includes('/api/channels/alexa/pair-page')) { return (
Click here to connect Alexa Generates a one-time linking code
); } if (href?.includes('/api/channels/telegram/pair-page')) { return (
Click here to connect Telegram Opens the Telegram pairing page
); } return ( {children} ); }, }} > {preprocessContent(text)}
); // Check if content has custom tags — if so, split into segments if (hasCustomTags(displayContent)) { const segments = extractContentSegments(displayContent); return (
{segments.map((seg, i) => { if (seg.type === 'text') return
{renderStreamdown(seg.value)}
; if (seg.type === 'env') return ; if (seg.type === 'bloby-image') return ; if (seg.type === 'bloby-text') return ; if (seg.type === 'notch') return ; if (seg.type === 'morphy-action') return ; if (seg.type === 'mac-actions') { // Fan each action out: `card` → notch card (preset+data, same as a legacy // ); every screen-acting verb → an "Agent actions" chip. return seg.actions.map((action, j) => action.type === 'card' ? ( ) : ( ), ); } return null; })}
{time && {time}}
); } // Default: no env groups, render normally return (
{renderStreamdown(displayContent)}
{time && {time}}
); }