TranscriptViewer.tsx (19205B)
1 import { useState, type ReactNode } from "react"; 2 3 interface TranscriptViewerProps { 4 lines: string[]; 5 } 6 7 // -- Theme tokens (inline, no CSS file needed) -- 8 9 const theme = { 10 bg: "#0f1117", 11 bgCard: "#1a1d27", 12 border: "#2d3045", 13 text: "#e5e7eb", 14 textMuted: "#9ca3af", 15 accent: "#6366f1", 16 green: "#22c55e", 17 red: "#ef4444", 18 yellow: "#eab308", 19 fontMono: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", 20 }; 21 22 // -- Types for parsed transcript events -- 23 24 interface ContentBlockThinking { 25 type: "thinking"; 26 thinking: string; 27 } 28 29 interface ContentBlockText { 30 type: "text"; 31 text: string; 32 } 33 34 interface ContentBlockToolUse { 35 type: "tool_use"; 36 id?: string; 37 name: string; 38 input: Record<string, unknown>; 39 } 40 41 type ContentBlock = ContentBlockThinking | ContentBlockText | ContentBlockToolUse; 42 43 interface AssistantMessage { 44 content: ContentBlock[]; 45 } 46 47 interface ToolUseResult { 48 stdout?: string; 49 stderr?: string; 50 [key: string]: unknown; 51 } 52 53 interface TranscriptEvent { 54 type?: string; 55 subtype?: string; 56 // system init 57 model?: string; 58 tools?: unknown[]; 59 // assistant 60 message?: AssistantMessage; 61 // user (tool results) 62 tool_use_result?: ToolUseResult | string; 63 // result 64 result?: string; 65 total_cost_usd?: number; 66 num_turns?: number; 67 usage?: Record<string, unknown>; 68 [key: string]: unknown; 69 } 70 71 // -- Minimal markdown renderer -- 72 73 function renderMarkdown(text: string): ReactNode[] { 74 // Split by code blocks first 75 const parts = text.split(/(```[\s\S]*?```)/g); 76 const nodes: ReactNode[] = []; 77 78 parts.forEach((part, i) => { 79 if (part.startsWith("```")) { 80 // Fenced code block 81 const match = part.match(/^```(\w*)\n?([\s\S]*?)```$/); 82 const code = match ? match[2] : part.slice(3, -3); 83 nodes.push( 84 <pre 85 key={i} 86 style={{ 87 background: "#12141c", 88 padding: "10px 12px", 89 borderRadius: 4, 90 fontSize: "0.8rem", 91 fontFamily: theme.fontMono, 92 overflow: "auto", 93 margin: "6px 0", 94 color: theme.text, 95 border: `1px solid ${theme.border}`, 96 }} 97 > 98 {code} 99 </pre> 100 ); 101 } else { 102 // Inline formatting 103 nodes.push(...renderInlineMarkdown(part, i)); 104 } 105 }); 106 107 return nodes; 108 } 109 110 function renderInlineMarkdown(text: string, keyBase: number): ReactNode[] { 111 // Handle bold, inline code 112 const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g); 113 return parts.map((part, j) => { 114 const key = `${keyBase}-${j}`; 115 if (part.startsWith("**") && part.endsWith("**")) { 116 return ( 117 <strong key={key} style={{ color: theme.text }}> 118 {part.slice(2, -2)} 119 </strong> 120 ); 121 } 122 if (part.startsWith("`") && part.endsWith("`")) { 123 return ( 124 <code 125 key={key} 126 style={{ 127 background: "#12141c", 128 padding: "1px 5px", 129 borderRadius: 3, 130 fontSize: "0.8rem", 131 fontFamily: theme.fontMono, 132 color: "#c4b5fd", 133 }} 134 > 135 {part.slice(1, -1)} 136 </code> 137 ); 138 } 139 return <span key={key}>{part}</span>; 140 }); 141 } 142 143 // -- Collapsible wrapper -- 144 145 function Collapsible({ 146 label, 147 defaultOpen = false, 148 children, 149 labelColor, 150 }: { 151 label: string; 152 defaultOpen?: boolean; 153 children: ReactNode; 154 labelColor?: string; 155 muted?: boolean; 156 }) { 157 const [open, setOpen] = useState(defaultOpen); 158 return ( 159 <div> 160 <button 161 onClick={() => setOpen(!open)} 162 style={{ 163 background: "none", 164 border: "none", 165 color: labelColor || theme.textMuted, 166 cursor: "pointer", 167 fontSize: "0.75rem", 168 fontFamily: theme.fontMono, 169 padding: "2px 0", 170 display: "flex", 171 alignItems: "center", 172 gap: 4, 173 }} 174 > 175 <span style={{ display: "inline-block", width: 12, textAlign: "center", fontSize: "0.65rem" }}> 176 {open ? "\u25BC" : "\u25B6"} 177 </span> 178 {label} 179 </button> 180 {open && <div style={{ marginTop: 4 }}>{children}</div>} 181 </div> 182 ); 183 } 184 185 // -- Code block display -- 186 187 function CodeBlock({ 188 children, 189 maxHeight, 190 color, 191 }: { 192 children: string; 193 maxHeight?: string; 194 color?: string; 195 }) { 196 return ( 197 <pre 198 style={{ 199 background: "#12141c", 200 padding: "8px 12px", 201 borderRadius: 4, 202 fontSize: "0.78rem", 203 fontFamily: theme.fontMono, 204 overflow: "auto", 205 maxHeight: maxHeight || "300px", 206 margin: "4px 0 0", 207 color: color || theme.textMuted, 208 border: `1px solid ${theme.border}`, 209 whiteSpace: "pre-wrap", 210 wordBreak: "break-word", 211 }} 212 > 213 {children} 214 </pre> 215 ); 216 } 217 218 // -- Tool header (terminal-style bar) -- 219 220 function ToolHeader({ name, detail }: { name: string; detail?: string }) { 221 return ( 222 <div 223 style={{ 224 display: "flex", 225 alignItems: "center", 226 gap: 8, 227 marginBottom: 4, 228 }} 229 > 230 <span 231 style={{ 232 fontSize: "0.7rem", 233 fontFamily: theme.fontMono, 234 color: theme.yellow, 235 fontWeight: 600, 236 textTransform: "uppercase", 237 letterSpacing: "0.05em", 238 }} 239 > 240 {name} 241 </span> 242 {detail && ( 243 <span 244 style={{ 245 fontSize: "0.75rem", 246 fontFamily: theme.fontMono, 247 color: theme.textMuted, 248 overflow: "hidden", 249 textOverflow: "ellipsis", 250 whiteSpace: "nowrap", 251 }} 252 > 253 {detail} 254 </span> 255 )} 256 </div> 257 ); 258 } 259 260 // -- Event card wrapper -- 261 262 function EventCard({ 263 borderColor, 264 bgTint, 265 children, 266 compact, 267 }: { 268 borderColor: string; 269 bgTint?: string; 270 children: ReactNode; 271 compact?: boolean; 272 }) { 273 return ( 274 <div 275 style={{ 276 padding: compact ? "6px 12px" : "10px 14px", 277 borderLeft: `3px solid ${borderColor}`, 278 marginBottom: 4, 279 background: bgTint || "transparent", 280 borderRadius: "0 4px 4px 0", 281 }} 282 > 283 {children} 284 </div> 285 ); 286 } 287 288 // -- Detect file path from heredoc or cat > writes -- 289 290 function extractFileWriteTarget(command: string): string | null { 291 // cat > filename, cat << ... > filename 292 const catWrite = command.match(/cat\s+>+\s*([^\s<]+)/); 293 if (catWrite) return catWrite[1]; 294 const heredoc = command.match(/cat\s*<<['"\\]*(\w+)['"]*\s*>\s*([^\s]+)/); 295 if (heredoc) return heredoc[2]; 296 return null; 297 } 298 299 // -- Render individual content blocks -- 300 301 function renderContentBlock(block: ContentBlock, index: number): ReactNode { 302 switch (block.type) { 303 case "thinking": 304 return ( 305 <EventCard key={index} borderColor="#4b5563" bgTint="rgba(75, 85, 99, 0.05)"> 306 <Collapsible label="thinking..." labelColor="#6b7280"> 307 <div 308 style={{ 309 fontStyle: "italic", 310 color: theme.textMuted, 311 fontSize: "0.82rem", 312 whiteSpace: "pre-wrap", 313 lineHeight: 1.5, 314 maxHeight: "300px", 315 overflow: "auto", 316 }} 317 > 318 {block.thinking} 319 </div> 320 </Collapsible> 321 </EventCard> 322 ); 323 324 case "text": 325 return ( 326 <EventCard key={index} borderColor={theme.accent} bgTint="rgba(99, 102, 241, 0.04)"> 327 <div style={{ fontSize: "0.875rem", lineHeight: 1.6, color: theme.text, whiteSpace: "pre-wrap" }}> 328 {renderMarkdown(block.text)} 329 </div> 330 </EventCard> 331 ); 332 333 case "tool_use": 334 return renderToolUse(block, index); 335 336 default: 337 return null; 338 } 339 } 340 341 function renderToolUse(block: ContentBlockToolUse, index: number): ReactNode { 342 const name = block.name; 343 const input = block.input || {}; 344 345 if (name === "Bash") { 346 const command = (input.command as string) || JSON.stringify(input, null, 2); 347 const fileTarget = extractFileWriteTarget(command); 348 return ( 349 <EventCard key={index} borderColor={theme.yellow} bgTint="rgba(234, 179, 8, 0.04)"> 350 <ToolHeader name="Bash" detail={fileTarget ? `writing to ${fileTarget}` : undefined} /> 351 <CodeBlock>{command}</CodeBlock> 352 </EventCard> 353 ); 354 } 355 356 if (name === "Read") { 357 const filePath = (input.file_path as string) || ""; 358 return ( 359 <EventCard key={index} borderColor={theme.yellow} bgTint="rgba(234, 179, 8, 0.04)"> 360 <ToolHeader name="Read" detail={filePath} /> 361 </EventCard> 362 ); 363 } 364 365 if (name === "Write") { 366 const filePath = (input.file_path as string) || ""; 367 const content = (input.content as string) || ""; 368 return ( 369 <EventCard key={index} borderColor={theme.yellow} bgTint="rgba(234, 179, 8, 0.04)"> 370 <ToolHeader name="Write" detail={filePath} /> 371 {content && <CodeBlock maxHeight="250px">{content}</CodeBlock>} 372 </EventCard> 373 ); 374 } 375 376 if (name === "Edit") { 377 const filePath = (input.file_path as string) || ""; 378 const oldStr = (input.old_string as string) || ""; 379 const newStr = (input.new_string as string) || ""; 380 return ( 381 <EventCard key={index} borderColor={theme.yellow} bgTint="rgba(234, 179, 8, 0.04)"> 382 <ToolHeader name="Edit" detail={filePath} /> 383 {oldStr && ( 384 <div style={{ marginTop: 4 }}> 385 <span style={{ fontSize: "0.7rem", color: theme.red, fontFamily: theme.fontMono }}>- old</span> 386 <CodeBlock maxHeight="150px" color={theme.red}>{oldStr}</CodeBlock> 387 </div> 388 )} 389 {newStr && ( 390 <div style={{ marginTop: 4 }}> 391 <span style={{ fontSize: "0.7rem", color: theme.green, fontFamily: theme.fontMono }}>+ new</span> 392 <CodeBlock maxHeight="150px" color={theme.green}>{newStr}</CodeBlock> 393 </div> 394 )} 395 </EventCard> 396 ); 397 } 398 399 // Generic tool 400 return ( 401 <EventCard key={index} borderColor={theme.yellow} bgTint="rgba(234, 179, 8, 0.04)"> 402 <ToolHeader name={name} /> 403 <CodeBlock maxHeight="200px">{JSON.stringify(input, null, 2)}</CodeBlock> 404 </EventCard> 405 ); 406 } 407 408 // -- Render a full event (top-level JSON line) -- 409 410 function renderEvent(event: TranscriptEvent, index: number): ReactNode { 411 const type = event.type; 412 413 // System init 414 if (type === "system" && event.subtype === "init") { 415 const model = (event.model as string) || "unknown model"; 416 const toolCount = Array.isArray(event.tools) ? event.tools.length : 0; 417 return ( 418 <div 419 key={index} 420 style={{ 421 display: "inline-flex", 422 alignItems: "center", 423 gap: 8, 424 background: "#252836", 425 borderRadius: 999, 426 padding: "4px 14px", 427 marginBottom: 6, 428 fontSize: "0.75rem", 429 color: theme.textMuted, 430 fontFamily: theme.fontMono, 431 }} 432 > 433 <span>{model}</span> 434 {toolCount > 0 && ( 435 <> 436 <span style={{ color: theme.border }}>|</span> 437 <span>{toolCount} tools</span> 438 </> 439 )} 440 </div> 441 ); 442 } 443 444 // API retry / rate limit 445 if (type === "system" && event.subtype === "api_retry") { 446 const attempt = (event.attempt as number) || 0; 447 const maxRetries = (event.max_retries as number) || 10; 448 const delay = (event.retry_delay_ms as number) || 0; 449 const errorStatus = (event.error_status as number) || 0; 450 const error = (event.error as string) || ""; 451 return ( 452 <EventCard key={index} borderColor={theme.red} bgTint="rgba(239, 68, 68, 0.06)" compact> 453 <div style={{ display: "flex", alignItems: "center", gap: 10, fontSize: "0.75rem", fontFamily: theme.fontMono }}> 454 <span style={{ color: theme.red, fontWeight: 700, fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em" }}> 455 RATE LIMITED 456 </span> 457 <span style={{ color: theme.textMuted }}> 458 {errorStatus} {error} 459 </span> 460 <span style={{ color: theme.textMuted }}> 461 attempt {attempt}/{maxRetries} 462 </span> 463 <span style={{ color: theme.textMuted }}> 464 retry in {(delay / 1000).toFixed(1)}s 465 </span> 466 </div> 467 </EventCard> 468 ); 469 } 470 471 // Harness config event 472 if (type === "harness" && event.subtype === "config") { 473 const tools = Array.isArray(event.tools) ? (event.tools as string[]).join(", ") : ""; 474 return ( 475 <EventCard key={index} borderColor="#4b5563" bgTint="rgba(75, 85, 99, 0.05)" compact> 476 <div style={{ fontSize: "0.7rem", fontFamily: theme.fontMono, color: theme.textMuted, display: "flex", flexWrap: "wrap", gap: "12px" }}> 477 <span>model: <span style={{ color: theme.text }}>{event.model as string}</span></span> 478 <span>effort: <span style={{ color: theme.text }}>{event.effort as string}</span></span> 479 <span>lang: <span style={{ color: theme.text }}>{event.language as string}</span></span> 480 <span>budget: <span style={{ color: theme.text }}>${event.max_budget_usd as number}</span></span> 481 <span>tools: <span style={{ color: theme.text }}>{tools}</span></span> 482 </div> 483 </EventCard> 484 ); 485 } 486 487 // Assistant message - contains content blocks 488 if (type === "assistant" && event.message?.content) { 489 const blocks = event.message.content; 490 return ( 491 <div key={index} style={{ marginBottom: 2 }}> 492 {blocks.map((block, bi) => renderContentBlock(block, bi))} 493 </div> 494 ); 495 } 496 497 // User prompt (injected by harness) 498 if (type === "user" && event.subtype === "prompt") { 499 const content = event.message?.content as string || ""; 500 return ( 501 <EventCard key={index} borderColor="#818cf8" bgTint="rgba(129, 140, 248, 0.08)"> 502 <div style={{ fontSize: "0.7rem", fontFamily: theme.fontMono, color: "#818cf8", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 6 }}> 503 PROMPT 504 </div> 505 <div style={{ fontSize: "0.875rem", lineHeight: 1.6, color: theme.text, whiteSpace: "pre-wrap" }}> 506 {renderMarkdown(content)} 507 </div> 508 </EventCard> 509 ); 510 } 511 512 // Context file (injected by harness) 513 if (type === "user" && event.subtype === "context") { 514 const content = event.message?.content as string || ""; 515 return ( 516 <EventCard key={index} borderColor="#4b5563" bgTint="rgba(75, 85, 99, 0.05)"> 517 <Collapsible label="context file" labelColor="#6b7280" defaultOpen={false}> 518 <div style={{ fontSize: "0.82rem", color: theme.textMuted, whiteSpace: "pre-wrap" }}> 519 {renderMarkdown(content)} 520 </div> 521 </Collapsible> 522 </EventCard> 523 ); 524 } 525 526 // User event (tool results) 527 if (type === "user") { 528 const result = event.tool_use_result; 529 if (!result) return null; 530 531 let stdout = ""; 532 let stderr = ""; 533 534 if (typeof result === "string") { 535 stdout = result; 536 } else { 537 stdout = (result.stdout as string) || ""; 538 stderr = (result.stderr as string) || ""; 539 } 540 541 const content = stdout || stderr; 542 if (!content) return null; 543 544 const lineCount = content.split("\n").length; 545 const shouldCollapse = lineCount > 10; 546 547 return ( 548 <EventCard key={index} borderColor={theme.green} bgTint="rgba(34, 197, 94, 0.03)" compact> 549 {shouldCollapse ? ( 550 <Collapsible label={`result (${lineCount} lines)`} labelColor={theme.green}> 551 {stderr && <CodeBlock maxHeight="200px" color={theme.red}>{stderr}</CodeBlock>} 552 {stdout && <CodeBlock maxHeight="300px">{stdout}</CodeBlock>} 553 </Collapsible> 554 ) : ( 555 <> 556 {stderr && <CodeBlock maxHeight="200px" color={theme.red}>{stderr}</CodeBlock>} 557 {stdout && <CodeBlock maxHeight="200px">{stdout}</CodeBlock>} 558 </> 559 )} 560 </EventCard> 561 ); 562 } 563 564 // Final result 565 if (type === "result") { 566 return ( 567 <div 568 key={index} 569 style={{ 570 background: "rgba(99, 102, 241, 0.08)", 571 border: `1px solid ${theme.accent}`, 572 borderRadius: 6, 573 padding: "14px 16px", 574 marginTop: 8, 575 }} 576 > 577 <div 578 style={{ 579 fontSize: "0.7rem", 580 color: theme.accent, 581 fontFamily: theme.fontMono, 582 fontWeight: 600, 583 marginBottom: 8, 584 textTransform: "uppercase", 585 letterSpacing: "0.05em", 586 }} 587 > 588 Result 589 </div> 590 {event.result && ( 591 <div style={{ fontSize: "0.875rem", color: theme.text, whiteSpace: "pre-wrap", lineHeight: 1.6 }}> 592 {renderMarkdown(event.result)} 593 </div> 594 )} 595 <div 596 style={{ 597 display: "flex", 598 gap: 16, 599 marginTop: 10, 600 fontSize: "0.75rem", 601 fontFamily: theme.fontMono, 602 color: theme.textMuted, 603 flexWrap: "wrap", 604 }} 605 > 606 {event.total_cost_usd != null && <span>cost: ${event.total_cost_usd.toFixed(4)}</span>} 607 {event.num_turns != null && <span>turns: {event.num_turns}</span>} 608 </div> 609 </div> 610 ); 611 } 612 613 // Fallback: render compact raw JSON 614 return ( 615 <EventCard key={index} borderColor={theme.border} compact> 616 <pre 617 style={{ 618 fontSize: "0.7rem", 619 color: theme.textMuted, 620 overflow: "auto", 621 maxHeight: "80px", 622 margin: 0, 623 fontFamily: theme.fontMono, 624 }} 625 > 626 {JSON.stringify(event, null, 2)} 627 </pre> 628 </EventCard> 629 ); 630 } 631 632 // -- Main component -- 633 634 export default function TranscriptViewer({ lines }: TranscriptViewerProps) { 635 if (lines.length === 0) { 636 return ( 637 <div 638 style={{ 639 textAlign: "center", 640 padding: "40px", 641 color: theme.textMuted, 642 background: theme.bgCard, 643 borderRadius: 8, 644 border: `1px solid ${theme.border}`, 645 }} 646 > 647 No transcript available for this run. 648 </div> 649 ); 650 } 651 652 const events: TranscriptEvent[] = lines 653 .map((line) => { 654 try { 655 return JSON.parse(line); 656 } catch { 657 return null; 658 } 659 }) 660 .filter(Boolean); 661 662 return ( 663 <div 664 style={{ 665 maxHeight: "80vh", 666 overflow: "auto", 667 background: theme.bgCard, 668 borderRadius: 8, 669 border: `1px solid ${theme.border}`, 670 padding: "14px 16px", 671 }} 672 > 673 <div 674 style={{ 675 position: "sticky", 676 top: 0, 677 background: theme.bgCard, 678 paddingBottom: 8, 679 zIndex: 1, 680 borderBottom: `1px solid ${theme.border}`, 681 marginBottom: 10, 682 }} 683 > 684 <h3 style={{ margin: 0, fontSize: "0.95rem", color: theme.text }}> 685 Transcript 686 <span style={{ color: theme.textMuted, fontWeight: 400, fontSize: "0.8rem", marginLeft: 8 }}> 687 {events.length} events 688 </span> 689 </h3> 690 </div> 691 {events.map((event, i) => renderEvent(event, i))} 692 </div> 693 ); 694 }