| 1 | import type { ContentBlock, StreamStatus } from "@/lib/ipc/types"; |
| 2 | import { shortModel } from "@/lib/format"; |
| 3 | |
| 4 | import { StreamingIndicator } from "./StreamingIndicator"; |
| 5 | import { TextBlock } from "./content/TextBlock"; |
| 6 | import { ThinkingBlock } from "./content/ThinkingBlock"; |
| 7 | import { ToolUseBlock } from "./content/ToolUseBlock"; |
| 8 | |
| 9 | interface AssistantCardProps { |
| 10 | at: string; |
| 11 | model: string | null; |
| 12 | blocks: ContentBlock[]; |
| 13 | stopReason?: string | null; |
| 14 | usage?: { |
| 15 | input_tokens: number; |
| 16 | output_tokens: number; |
| 17 | cache_creation_input_tokens: number; |
| 18 | cache_read_input_tokens: number; |
| 19 | } | null; |
| 20 | /** v1 chat status flag. Optional so v0 disk-read paths remain |
| 21 | * unchanged. `"streaming"` shows a pulsing indicator next to the |
| 22 | * model badge; `"error"` tints the border red. */ |
| 23 | status?: StreamStatus | null; |
| 24 | } |
| 25 | |
| 26 | export function AssistantCard({ |
| 27 | at, |
| 28 | model, |
| 29 | blocks, |
| 30 | stopReason, |
| 31 | usage, |
| 32 | status, |
| 33 | }: AssistantCardProps) { |
| 34 | const borderClass = |
| 35 | status === "error" |
| 36 | ? "border-red-900/60" |
| 37 | : "border-border"; |
| 38 | return ( |
| 39 | <div className={`rounded border ${borderClass} bg-bg-1 px-4 py-3`}> |
| 40 | <div className="mb-2 flex flex-wrap items-center gap-2 text-[10px] uppercase tracking-wide"> |
| 41 | <span className="text-fg-0">claude</span> |
| 42 | {model && ( |
| 43 | <span className="rounded bg-bg-3 px-1.5 py-0.5 font-mono normal-case text-fg-2"> |
| 44 | {shortModel(model)} |
| 45 | </span> |
| 46 | )} |
| 47 | {status === "streaming" && <StreamingIndicator />} |
| 48 | <span className="text-fg-3">•</span> |
| 49 | <span className="text-fg-3">{formatAt(at)}</span> |
| 50 | {stopReason && stopReason !== "end_turn" && ( |
| 51 | <> |
| 52 | <span className="text-fg-3">•</span> |
| 53 | <span className="text-fg-3">stop: {stopReason}</span> |
| 54 | </> |
| 55 | )} |
| 56 | {usage && ( |
| 57 | <> |
| 58 | <span className="text-fg-3">•</span> |
| 59 | <span className="text-fg-3 normal-case"> |
| 60 | in {usage.input_tokens.toLocaleString()} · out{" "} |
| 61 | {usage.output_tokens.toLocaleString()} |
| 62 | </span> |
| 63 | </> |
| 64 | )} |
| 65 | </div> |
| 66 | <div> |
| 67 | {blocks.map((block, i) => ( |
| 68 | <BlockRenderer key={i} block={block} /> |
| 69 | ))} |
| 70 | </div> |
| 71 | </div> |
| 72 | ); |
| 73 | } |
| 74 | |
| 75 | function BlockRenderer({ block }: { block: ContentBlock }) { |
| 76 | switch (block.type) { |
| 77 | case "text": |
| 78 | return <TextBlock text={block.text} />; |
| 79 | case "thinking": |
| 80 | return <ThinkingBlock text={block.text} />; |
| 81 | case "tool_use": |
| 82 | return <ToolUseBlock name={block.name} input={block.input} />; |
| 83 | case "tool_result": |
| 84 | return ( |
| 85 | <div |
| 86 | className={`my-2 rounded border px-3 py-2 font-mono text-[11px] ${ |
| 87 | block.isError |
| 88 | ? "border-red-900/60 bg-red-950/20 text-red-300" |
| 89 | : "border-border bg-bg-2 text-fg-2" |
| 90 | }`} |
| 91 | > |
| 92 | <div className="mb-1 text-[10px] uppercase tracking-wide text-fg-3"> |
| 93 | {block.isError ? "tool error" : "tool result"} |
| 94 | </div> |
| 95 | <pre className="whitespace-pre-wrap break-all">{block.content}</pre> |
| 96 | </div> |
| 97 | ); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | function formatAt(iso: string): string { |
| 102 | try { |
| 103 | return new Date(iso).toLocaleString(); |
| 104 | } catch { |
| 105 | return iso; |
| 106 | } |
| 107 | } |