'use client' import { useState } from 'react' type MessageContentPart = | { type: 'text'; text: string } | { type: 'thinking'; thinking: string } | { type: 'tool_use'; id: string; name: string; input: string } | { type: 'tool_result'; toolUseId: string; content: string; isError?: boolean } export type SessionTranscriptMessage = { role: 'user' | 'assistant' | 'system' parts: MessageContentPart[] timestamp?: string } interface SessionMessageProps { message: SessionTranscriptMessage showTimestamp: boolean } const ROLE_CONFIG = { user: { indicator: '$', indicatorClass: 'text-green-400', borderClass: 'border-l-green-500/40' }, assistant: { indicator: '\u25C6', indicatorClass: 'text-primary', borderClass: 'border-l-primary/40' }, system: { indicator: '', indicatorClass: '', borderClass: 'border-l-amber-500/20' }, } as const export function SessionMessage({ message, showTimestamp }: SessionMessageProps) { const config = ROLE_CONFIG[message.role] const timeStr = message.timestamp ? formatTime(message.timestamp) : '' return (
{/* Timestamp gutter */}
{showTimestamp && timeStr && ( {timeStr} )}
{/* Indicator */} {config.indicator && (
{config.indicator}
)} {!config.indicator &&
} {/* Content */}
{message.parts.map((part, idx) => ( ))}
) } function PartRenderer({ part }: { part: MessageContentPart }) { switch (part.type) { case 'text': return case 'thinking': return case 'tool_use': return case 'tool_result': return default: return null } } function TextPart({ text }: { text: string }) { return (
{renderSessionContent(text)}
) } function ThinkingPart({ thinking }: { thinking: string }) { const [open, setOpen] = useState(false) return (
setOpen((e.target as HTMLDetailsElement).open)}> {open ? '\u25BE' : '\u25B8'} thinking ({thinking.length} chars)
{thinking}
) } function ToolUsePart({ name, input }: { name: string; input: string }) { return (
{'\u2699'} {name} {input.length > 80 ? input.slice(0, 80) + '\u2026' : input}
) } function ToolResultPart({ content, isError }: { content: string; isError?: boolean }) { const [open, setOpen] = useState(false) const icon = isError ? '\u2717' : '\u2713' const colorClass = isError ? 'text-red-400/70' : 'text-green-400/50' return (
setOpen((e.target as HTMLDetailsElement).open)}> {icon} {isError ? 'error' : 'result'} ({content.length} chars)
          {content}
        
) } function formatTime(ts: string): string { try { return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } catch { return '' } } /** Should timestamps be shown? Only when gap > 30s from previous. */ export function shouldShowTimestamp( current: SessionTranscriptMessage, previous: SessionTranscriptMessage | undefined, ): boolean { if (!current.timestamp) return false if (!previous?.timestamp) return true const gap = new Date(current.timestamp).getTime() - new Date(previous.timestamp).getTime() return Math.abs(gap) > 30000 } // --- Enhanced content renderer --- function renderSessionContent(text: string): React.ReactNode[] { const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`)/g) return parts.map((part, i) => { // Multi-line code block if (part.startsWith('```') && part.endsWith('```')) { const inner = part.slice(3, -3) const newlineIdx = inner.indexOf('\n') const lang = newlineIdx > 0 ? inner.slice(0, newlineIdx).trim() : '' const code = newlineIdx > 0 ? inner.slice(newlineIdx + 1) : inner return (
{lang && (
{lang}
)}
            {code}
          
) } // Inline code if (part.startsWith('`') && part.endsWith('`')) { return ( {part.slice(1, -1)} ) } // Regular text with formatting return {renderInlineFormatting(part)} }) } function renderInlineFormatting(text: string): React.ReactNode[] { // Process line by line to handle headers, lists, and inline formatting const lines = text.split('\n') const result: React.ReactNode[] = [] for (let i = 0; i < lines.length; i++) { if (i > 0) result.push('\n') const line = lines[i] // Headers const headerMatch = line.match(/^(#{1,3})\s+(.+)/) if (headerMatch) { const level = headerMatch[1].length const headerClass = level === 1 ? 'text-sm font-bold' : level === 2 ? 'text-xs font-semibold' : 'text-xs font-medium' result.push({renderInlineText(headerMatch[2])}) continue } // List items const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s+(.+)/) if (listMatch) { const indent = listMatch[1].length const bullet = listMatch[2].match(/\d/) ? listMatch[2] : '\u2022' result.push( {bullet} {renderInlineText(listMatch[3])} ) continue } result.push({renderInlineText(line)}) } return result } function renderInlineText(text: string): React.ReactNode[] { // Bold, italic, links const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g) return parts.map((segment, j) => { if (segment.startsWith('**') && segment.endsWith('**')) { return {segment.slice(2, -2)} } if (segment.startsWith('*') && segment.endsWith('*') && !segment.startsWith('**')) { return {segment.slice(1, -1)} } const linkMatch = segment.match(/^\[([^\]]+)\]\(([^)]+)\)$/) if (linkMatch) { return ( {linkMatch[1]} ) } return segment }) }