|
| 1 | +import { lazy, Suspense, useState } from "react"; |
| 2 | +import { CodeBlock } from "~/components/code/CodeBlock"; |
| 3 | +import type { DisplayItem, ToolUse } from "./types"; |
| 4 | + |
| 5 | +// Lazy load streamdown to avoid SSR issues |
| 6 | +const StreamdownRenderer = lazy(() => |
| 7 | + import("streamdown").then((mod) => ({ |
| 8 | + default: ({ children }: { children: string }) => ( |
| 9 | + <mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}> |
| 10 | + <mod.Streamdown isAnimating={false}>{children}</mod.Streamdown> |
| 11 | + </mod.ShikiThemeContext.Provider> |
| 12 | + ), |
| 13 | + })) |
| 14 | +); |
| 15 | + |
| 16 | +export function AIChatMessages({ items }: { items: DisplayItem[] }) { |
| 17 | + return ( |
| 18 | + <div className="flex flex-col divide-y divide-grid-bright"> |
| 19 | + {items.map((item, i) => { |
| 20 | + switch (item.type) { |
| 21 | + case "system": |
| 22 | + return <SystemSection key={i} text={item.text} />; |
| 23 | + case "user": |
| 24 | + return <UserSection key={i} text={item.text} />; |
| 25 | + case "tool-use": |
| 26 | + return <ToolUseSection key={i} tools={item.tools} />; |
| 27 | + case "assistant": |
| 28 | + return <AssistantResponse key={i} text={item.text} />; |
| 29 | + } |
| 30 | + })} |
| 31 | + </div> |
| 32 | + ); |
| 33 | +} |
| 34 | + |
| 35 | +// --------------------------------------------------------------------------- |
| 36 | +// Section header (shared across all sections) |
| 37 | +// --------------------------------------------------------------------------- |
| 38 | + |
| 39 | +function SectionHeader({ |
| 40 | + label, |
| 41 | + right, |
| 42 | +}: { |
| 43 | + label: string; |
| 44 | + right?: React.ReactNode; |
| 45 | +}) { |
| 46 | + return ( |
| 47 | + <div className="flex items-center justify-between"> |
| 48 | + <span className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{label}</span> |
| 49 | + {right && <div className="flex items-center gap-2">{right}</div>} |
| 50 | + </div> |
| 51 | + ); |
| 52 | +} |
| 53 | + |
| 54 | +// --------------------------------------------------------------------------- |
| 55 | +// System |
| 56 | +// --------------------------------------------------------------------------- |
| 57 | + |
| 58 | +function SystemSection({ text }: { text: string }) { |
| 59 | + const [expanded, setExpanded] = useState(false); |
| 60 | + const isLong = text.length > 150; |
| 61 | + const preview = isLong ? text.slice(0, 150) + "..." : text; |
| 62 | + |
| 63 | + return ( |
| 64 | + <div className="flex flex-col gap-1 py-2.5"> |
| 65 | + <SectionHeader |
| 66 | + label="System" |
| 67 | + right={ |
| 68 | + isLong ? ( |
| 69 | + <button |
| 70 | + onClick={() => setExpanded(!expanded)} |
| 71 | + className="text-[10px] text-text-link hover:underline" |
| 72 | + > |
| 73 | + {expanded ? "Collapse" : "Expand"} |
| 74 | + </button> |
| 75 | + ) : undefined |
| 76 | + } |
| 77 | + /> |
| 78 | + <pre className="whitespace-pre-wrap text-xs leading-relaxed text-text-dimmed"> |
| 79 | + {expanded || !isLong ? text : preview} |
| 80 | + </pre> |
| 81 | + </div> |
| 82 | + ); |
| 83 | +} |
| 84 | + |
| 85 | +// --------------------------------------------------------------------------- |
| 86 | +// User |
| 87 | +// --------------------------------------------------------------------------- |
| 88 | + |
| 89 | +function UserSection({ text }: { text: string }) { |
| 90 | + return ( |
| 91 | + <div className="flex flex-col gap-1 py-2.5"> |
| 92 | + <SectionHeader label="User" /> |
| 93 | + <p className="text-sm text-text-bright">{text}</p> |
| 94 | + </div> |
| 95 | + ); |
| 96 | +} |
| 97 | + |
| 98 | +// --------------------------------------------------------------------------- |
| 99 | +// Assistant response (with markdown/raw toggle) |
| 100 | +// --------------------------------------------------------------------------- |
| 101 | + |
| 102 | +export function AssistantResponse({ |
| 103 | + text, |
| 104 | + headerLabel = "Assistant", |
| 105 | +}: { |
| 106 | + text: string; |
| 107 | + headerLabel?: string; |
| 108 | +}) { |
| 109 | + const [mode, setMode] = useState<"rendered" | "raw">("rendered"); |
| 110 | + |
| 111 | + return ( |
| 112 | + <div className="flex flex-col gap-1 py-2.5"> |
| 113 | + <SectionHeader |
| 114 | + label={headerLabel} |
| 115 | + right={ |
| 116 | + <div className="flex items-center gap-2"> |
| 117 | + <button |
| 118 | + onClick={() => setMode(mode === "rendered" ? "raw" : "rendered")} |
| 119 | + className="text-[10px] text-text-link hover:underline" |
| 120 | + > |
| 121 | + {mode === "rendered" ? "Raw" : "Rendered"} |
| 122 | + </button> |
| 123 | + <button |
| 124 | + onClick={() => navigator.clipboard.writeText(text)} |
| 125 | + className="text-[10px] text-text-link hover:underline" |
| 126 | + > |
| 127 | + Copy |
| 128 | + </button> |
| 129 | + </div> |
| 130 | + } |
| 131 | + /> |
| 132 | + {mode === "rendered" ? ( |
| 133 | + <div className="streamdown-container text-sm text-text-bright"> |
| 134 | + <Suspense fallback={<pre className="whitespace-pre-wrap">{text}</pre>}> |
| 135 | + <StreamdownRenderer>{text}</StreamdownRenderer> |
| 136 | + </Suspense> |
| 137 | + </div> |
| 138 | + ) : ( |
| 139 | + <CodeBlock code={text} maxLines={20} showLineNumbers={false} showCopyButton /> |
| 140 | + )} |
| 141 | + </div> |
| 142 | + ); |
| 143 | +} |
| 144 | + |
| 145 | +// --------------------------------------------------------------------------- |
| 146 | +// Tool use (merged calls + results) |
| 147 | +// --------------------------------------------------------------------------- |
| 148 | + |
| 149 | +function ToolUseSection({ tools }: { tools: ToolUse[] }) { |
| 150 | + return ( |
| 151 | + <div className="flex flex-col gap-1.5 py-2.5"> |
| 152 | + <SectionHeader label={tools.length === 1 ? "Tool call" : `Tool calls (${tools.length})`} /> |
| 153 | + {tools.map((tool) => ( |
| 154 | + <ToolUseRow key={tool.toolCallId} tool={tool} /> |
| 155 | + ))} |
| 156 | + </div> |
| 157 | + ); |
| 158 | +} |
| 159 | + |
| 160 | +type ToolTab = "input" | "output" | "details"; |
| 161 | + |
| 162 | +function ToolUseRow({ tool }: { tool: ToolUse }) { |
| 163 | + const hasInput = tool.inputJson !== "{}"; |
| 164 | + const hasResult = !!tool.resultOutput; |
| 165 | + const hasDetails = !!tool.description || !!tool.parametersJson; |
| 166 | + |
| 167 | + const availableTabs: ToolTab[] = [ |
| 168 | + ...(hasInput ? (["input"] as const) : []), |
| 169 | + ...(hasResult ? (["output"] as const) : []), |
| 170 | + ...(hasDetails ? (["details"] as const) : []), |
| 171 | + ]; |
| 172 | + |
| 173 | + const defaultTab: ToolTab | null = hasInput ? "input" : null; |
| 174 | + const [activeTab, setActiveTab] = useState<ToolTab | null>(defaultTab); |
| 175 | + |
| 176 | + function handleTabClick(tab: ToolTab) { |
| 177 | + setActiveTab(activeTab === tab ? null : tab); |
| 178 | + } |
| 179 | + |
| 180 | + return ( |
| 181 | + <div className="rounded-sm border border-grid-bright bg-charcoal-800/40"> |
| 182 | + <div className="flex items-center gap-2 px-2.5 py-1.5"> |
| 183 | + <code className="font-mono text-xs text-text-bright">{tool.toolName}</code> |
| 184 | + {tool.resultSummary && ( |
| 185 | + <span className="ml-auto text-[10px] text-text-dimmed">{tool.resultSummary}</span> |
| 186 | + )} |
| 187 | + </div> |
| 188 | + |
| 189 | + {availableTabs.length > 0 && ( |
| 190 | + <> |
| 191 | + <div className="flex gap-0 border-t border-grid-bright"> |
| 192 | + {availableTabs.map((tab) => ( |
| 193 | + <button |
| 194 | + key={tab} |
| 195 | + onClick={() => handleTabClick(tab)} |
| 196 | + className={`px-2.5 py-1 text-[11px] capitalize transition-colors ${ |
| 197 | + activeTab === tab |
| 198 | + ? "bg-charcoal-750 text-text-bright" |
| 199 | + : "text-text-dimmed hover:text-text-bright" |
| 200 | + }`} |
| 201 | + > |
| 202 | + {tab} |
| 203 | + </button> |
| 204 | + ))} |
| 205 | + </div> |
| 206 | + |
| 207 | + {activeTab === "input" && hasInput && ( |
| 208 | + <div className="border-t border-grid-dimmed"> |
| 209 | + <CodeBlock |
| 210 | + code={tool.inputJson} |
| 211 | + maxLines={12} |
| 212 | + showLineNumbers={false} |
| 213 | + showCopyButton |
| 214 | + /> |
| 215 | + </div> |
| 216 | + )} |
| 217 | + |
| 218 | + {activeTab === "output" && hasResult && ( |
| 219 | + <div className="border-t border-grid-dimmed"> |
| 220 | + <CodeBlock |
| 221 | + code={tool.resultOutput!} |
| 222 | + maxLines={16} |
| 223 | + showLineNumbers={false} |
| 224 | + showCopyButton |
| 225 | + /> |
| 226 | + </div> |
| 227 | + )} |
| 228 | + |
| 229 | + {activeTab === "details" && hasDetails && ( |
| 230 | + <div className="border-t border-grid-dimmed px-2.5 py-2 flex flex-col gap-2"> |
| 231 | + {tool.description && ( |
| 232 | + <p className="text-xs text-text-dimmed leading-relaxed">{tool.description}</p> |
| 233 | + )} |
| 234 | + {tool.parametersJson && ( |
| 235 | + <div> |
| 236 | + <span className="text-[10px] font-medium uppercase tracking-wide text-text-dimmed"> |
| 237 | + Parameters schema |
| 238 | + </span> |
| 239 | + <CodeBlock |
| 240 | + code={tool.parametersJson} |
| 241 | + maxLines={16} |
| 242 | + showLineNumbers={false} |
| 243 | + showCopyButton |
| 244 | + /> |
| 245 | + </div> |
| 246 | + )} |
| 247 | + </div> |
| 248 | + )} |
| 249 | + </> |
| 250 | + )} |
| 251 | + </div> |
| 252 | + ); |
| 253 | +} |
0 commit comments