/ src / components / chat / trace-block.tsx
trace-block.tsx
 1  'use client'
 2  
 3  import type { ChatTraceBlock } from '@/types'
 4  
 5  interface Props {
 6    trace: ChatTraceBlock
 7  }
 8  
 9  export function TraceBlock({ trace }: Props) {
10    const bgColor = trace.type === 'thinking'
11      ? 'bg-purple-500/[0.04] border-purple-500/10'
12      : trace.type === 'tool-call'
13        ? 'bg-sky-500/[0.04] border-sky-500/10'
14        : 'bg-emerald-500/[0.04] border-emerald-500/10'
15  
16    const labelColor = trace.type === 'thinking'
17      ? 'text-purple-400/70'
18      : trace.type === 'tool-call'
19        ? 'text-sky-400/70'
20        : 'text-emerald-400/70'
21  
22    const icon = trace.type === 'thinking'
23      ? '...'
24      : trace.type === 'tool-call'
25        ? '>'
26        : '<'
27  
28    return (
29      <details className={`my-1 rounded-[8px] border ${bgColor} overflow-hidden`} open={trace.collapsed === false || undefined}>
30        <summary className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer select-none transition-colors hover:bg-white/[0.02] ${labelColor} [&::-webkit-details-marker]:hidden list-none`}>
31          <span className="font-mono text-[10px] shrink-0">{icon}</span>
32          <span className="text-[11px] font-600 truncate">
33            {trace.label || trace.type.replace('-', ' ')}
34          </span>
35        </summary>
36        <div className="px-3 pb-2">
37          <pre className={`text-[11px] leading-relaxed whitespace-pre-wrap break-words m-0 ${
38            trace.type === 'thinking'
39              ? 'text-text-3/60 italic'
40              : 'text-text-3/70 font-mono'
41          }`}>
42            {trace.content.length > 2000
43              ? trace.content.slice(0, 2000) + '\n... (truncated)'
44              : trace.content}
45          </pre>
46        </div>
47      </details>
48    )
49  }
50  
51  /** Parse message text with [[prefix]] markers into text and trace blocks */
52  export function parseTraceBlocks(text: string): Array<{ type: 'text'; content: string } | ChatTraceBlock> {
53    const blocks: Array<{ type: 'text'; content: string } | ChatTraceBlock> = []
54    const regex = /\[\[(thinking|tool|tool-result|trace|meta)\]\]([\s\S]*?)(?=\[\[(thinking|tool|tool-result|trace|meta)\]\]|$)/g
55  
56    let lastEnd = 0
57    let match: RegExpExecArray | null
58  
59    while ((match = regex.exec(text)) !== null) {
60      // Add any text before this match
61      if (match.index > lastEnd) {
62        const before = text.slice(lastEnd, match.index).trim()
63        if (before) blocks.push({ type: 'text', content: before })
64      }
65  
66      const prefix = match[1]
67      const content = match[2].trim()
68      if (content) {
69        if (prefix === 'thinking' || prefix === 'trace') {
70          blocks.push({ type: 'thinking', content, collapsed: true })
71        } else if (prefix === 'tool') {
72          const firstLine = content.split('\n')[0] || ''
73          blocks.push({ type: 'tool-call', content, label: firstLine.slice(0, 60), collapsed: true })
74        } else if (prefix === 'tool-result') {
75          blocks.push({ type: 'tool-result', content, collapsed: true })
76        }
77        // meta is ignored
78      }
79  
80      lastEnd = match.index + match[0].length
81    }
82  
83    // Add remaining text
84    if (lastEnd === 0) {
85      // No trace markers found
86      if (text.trim()) blocks.push({ type: 'text', content: text })
87    } else if (lastEnd < text.length) {
88      const remaining = text.slice(lastEnd).trim()
89      if (remaining) blocks.push({ type: 'text', content: remaining })
90    }
91  
92    return blocks
93  }