| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1 | /** |
| 2 | * MessageRenderer - Class to handle rendering of timeline messages |
| 3 | */ |
| 4 | |
| 5 | import { TimelineMessage, ToolCall } from "./types"; |
| 6 | import { escapeHTML, formatNumber, generateColorFromId } from "./utils"; |
| 7 | import { renderMarkdown, processRenderedMarkdown } from "./markdown/renderer"; |
| 8 | import { createToolCallCard, updateToolCallCard } from "./toolcalls"; |
| 9 | import { createCommitsContainer } from "./commits"; |
| 10 | import { createCopyButton } from "./copybutton"; |
| 11 | import { getIconText } from "./icons"; |
| 12 | import { addCollapsibleFunctionality } from "./components/collapsible"; |
| 13 | import { checkShouldScroll, scrollToBottom } from "./scroll"; |
| 14 | |
| 15 | export class MessageRenderer { |
| 16 | // Map to store references to agent message DOM elements by tool call ID |
| 17 | private toolCallIdToMessageElement: Map< |
| 18 | string, |
| 19 | { |
| 20 | messageEl: HTMLElement; |
| 21 | toolCallContainer: HTMLElement | null; |
| 22 | toolCardId: string; |
| 23 | } |
| 24 | > = new Map(); |
| 25 | |
| 26 | // State tracking variables |
| 27 | private isFirstLoad: boolean = true; |
| 28 | private shouldScrollToBottom: boolean = true; |
| 29 | private currentFetchStartIndex: number = 0; |
| 30 | |
| 31 | constructor() {} |
| 32 | |
| 33 | /** |
| 34 | * Initialize the renderer with state from the timeline manager |
| 35 | */ |
| 36 | public initialize(isFirstLoad: boolean, currentFetchStartIndex: number) { |
| 37 | this.isFirstLoad = isFirstLoad; |
| 38 | this.currentFetchStartIndex = currentFetchStartIndex; |
| 39 | } |
| 40 | |
| 41 | /** |
| 42 | * Renders the timeline with messages |
| 43 | * @param messages The messages to render |
| 44 | * @param clearExisting Whether to clear existing content before rendering |
| 45 | */ |
| 46 | public renderTimeline( |
| 47 | messages: TimelineMessage[], |
| 48 | clearExisting: boolean = false, |
| 49 | ): void { |
| 50 | const timeline = document.getElementById("timeline"); |
| 51 | if (!timeline) return; |
| 52 | |
| 53 | // We'll keep the isFirstLoad value for this render cycle, |
| 54 | // but will set it to false afterwards in scrollToBottom |
| 55 | |
| 56 | if (clearExisting) { |
| 57 | timeline.innerHTML = ""; // Clear existing content only if this is the first load |
| 58 | // Clear our map of tool call references |
| 59 | this.toolCallIdToMessageElement.clear(); |
| 60 | } |
| 61 | |
| 62 | if (!messages || messages.length === 0) { |
| 63 | if (clearExisting) { |
| 64 | timeline.innerHTML = "<p>No messages available.</p>"; |
| 65 | timeline.classList.add("empty"); |
| 66 | } |
| 67 | return; |
| 68 | } |
| 69 | |
| 70 | // Remove empty class when there are messages |
| 71 | timeline.classList.remove("empty"); |
| 72 | |
| 73 | // Keep track of conversation groups to properly indent |
| 74 | interface ConversationGroup { |
| 75 | color: string; |
| 76 | level: number; |
| 77 | } |
| 78 | |
| 79 | const conversationGroups: Record<string, ConversationGroup> = {}; |
| 80 | |
| 81 | // Use the currentFetchStartIndex as the base index for these messages |
| 82 | const startIndex = this.currentFetchStartIndex; |
| 83 | // Group tool messages with their parent agent messages |
| 84 | const organizedMessages: (TimelineMessage & { |
| 85 | toolResponses?: TimelineMessage[]; |
| 86 | })[] = []; |
| 87 | const toolMessagesByCallId: Record<string, TimelineMessage> = {}; |
| 88 | |
| 89 | // First, process tool messages - check if any can update existing UI elements |
| 90 | const processedToolMessages = new Set<string>(); |
| 91 | |
| 92 | messages.forEach((message) => { |
| 93 | // If this is a tool message with a tool_call_id |
| 94 | if (message.type === "tool" && message.tool_call_id) { |
| 95 | // Try to find an existing agent message that's waiting for this tool response |
| 96 | const toolCallRef = this.toolCallIdToMessageElement.get( |
| 97 | message.tool_call_id, |
| 98 | ); |
| 99 | |
| 100 | if (toolCallRef) { |
| 101 | // Found an existing agent message that needs updating |
| 102 | this.updateToolCallInAgentMessage(message, toolCallRef); |
| 103 | processedToolMessages.add(message.tool_call_id); |
| 104 | } else { |
| 105 | // No existing agent message found, we'll include this in normal rendering |
| 106 | toolMessagesByCallId[message.tool_call_id] = message; |
| 107 | } |
| 108 | } |
| 109 | }); |
| 110 | |
| 111 | // Then, process messages and organize them |
| 112 | messages.forEach((message, localIndex) => { |
| 113 | const _index = startIndex + localIndex; |
| 114 | if (!message) return; // Skip if message is null/undefined |
| 115 | |
| 116 | // If it's a tool message and we're going to inline it with its parent agent message, |
| 117 | // we'll skip rendering it here - it will be included with the agent message |
| 118 | if (message.type === "tool" && message.tool_call_id) { |
| 119 | // Skip if we've already processed this tool message (updated an existing agent message) |
| 120 | if (processedToolMessages.has(message.tool_call_id)) { |
| 121 | return; |
| 122 | } |
| 123 | |
| 124 | // Skip if this tool message will be included with a new agent message |
| 125 | if (toolMessagesByCallId[message.tool_call_id]) { |
| 126 | return; |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // For agent messages with tool calls, attach their tool responses |
| 131 | if ( |
| 132 | message.type === "agent" && |
| 133 | message.tool_calls && |
| 134 | message.tool_calls.length > 0 |
| 135 | ) { |
| 136 | const toolResponses: TimelineMessage[] = []; |
| 137 | |
| 138 | // Look up tool responses for each tool call |
| 139 | message.tool_calls.forEach((toolCall) => { |
| 140 | if ( |
| 141 | toolCall.tool_call_id && |
| 142 | toolMessagesByCallId[toolCall.tool_call_id] |
| 143 | ) { |
| 144 | toolResponses.push(toolMessagesByCallId[toolCall.tool_call_id]); |
| 145 | } |
| 146 | }); |
| 147 | |
| 148 | if (toolResponses.length > 0) { |
| 149 | message = { ...message, toolResponses }; |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | organizedMessages.push(message); |
| 154 | }); |
| 155 | |
| 156 | let lastMessage:TimelineMessage|undefined; |
| 157 | if (messages && messages.length > 0 && startIndex > 0) { |
| 158 | lastMessage = messages[startIndex-1]; |
| 159 | } |
| 160 | |
| 161 | // Loop through organized messages and create timeline items |
| 162 | organizedMessages.forEach((message, localIndex) => { |
| 163 | const _index = startIndex + localIndex; |
| 164 | if (!message) return; // Skip if message is null/undefined |
| 165 | |
| 166 | if (localIndex > 0) { |
| 167 | lastMessage = organizedMessages.at(localIndex-1); |
| 168 | } |
| 169 | // Determine if this is a subconversation |
| 170 | const hasParent = !!message.parent_conversation_id; |
| 171 | const conversationId = message.conversation_id || ""; |
| 172 | const _parentId = message.parent_conversation_id || ""; |
| 173 | |
| 174 | // Track the conversation group |
| 175 | if (conversationId && !conversationGroups[conversationId]) { |
| 176 | conversationGroups[conversationId] = { |
| 177 | color: generateColorFromId(conversationId), |
| 178 | level: hasParent ? 1 : 0, // Level 0 for main conversation, 1+ for nested |
| 179 | }; |
| 180 | } |
| 181 | |
| 182 | // Get the level and color for this message |
| 183 | const group = conversationGroups[conversationId] || { |
| 184 | level: 0, |
| 185 | color: "#888888", |
| 186 | }; |
| 187 | |
| 188 | const messageEl = document.createElement("div"); |
| 189 | messageEl.className = `message ${message.type || "unknown"} ${message.end_of_turn ? "end-of-turn" : ""}`; |
| 190 | |
| 191 | // Add indentation class for subconversations |
| 192 | if (hasParent) { |
| 193 | messageEl.classList.add("subconversation"); |
| 194 | messageEl.style.marginLeft = `${group.level * 40}px`; |
| 195 | |
| 196 | // Add a colored left border to indicate the subconversation |
| 197 | messageEl.style.borderLeft = `4px solid ${group.color}`; |
| 198 | } |
| 199 | |
| 200 | // newMsgType indicates when to create a new icon and message |
| 201 | // type header. This is a primitive form of message coalescing, |
| 202 | // but it does reduce the amount of redundant information in |
| 203 | // the UI. |
| 204 | const newMsgType = !lastMessage || |
| 205 | (message.type == 'user' && lastMessage.type != 'user') || |
| 206 | (message.type != 'user' && lastMessage.type == 'user'); |
| 207 | |
| 208 | if (newMsgType) { |
| 209 | // Create message icon |
| 210 | const iconEl = document.createElement("div"); |
| 211 | iconEl.className = "message-icon"; |
| 212 | iconEl.textContent = getIconText(message.type); |
| 213 | messageEl.appendChild(iconEl); |
| 214 | } |
| 215 | |
| 216 | // Create message content container |
| 217 | const contentEl = document.createElement("div"); |
| 218 | contentEl.className = "message-content"; |
| 219 | |
| 220 | // Create message header |
| 221 | const headerEl = document.createElement("div"); |
| 222 | headerEl.className = "message-header"; |
| 223 | |
| 224 | if (newMsgType) { |
| 225 | const typeEl = document.createElement("span"); |
| 226 | typeEl.className = "message-type"; |
| 227 | typeEl.textContent = this.getTypeName(message.type); |
| 228 | headerEl.appendChild(typeEl); |
| 229 | } |
| 230 | |
| 231 | // Add timestamp and usage info combined for agent messages at the top |
| 232 | if (message.timestamp) { |
| 233 | const timestampEl = document.createElement("span"); |
| 234 | timestampEl.className = "message-timestamp"; |
| 235 | timestampEl.textContent = this.formatTimestamp(message.timestamp); |
| 236 | |
| 237 | // Add elapsed time if available |
| 238 | if (message.elapsed) { |
| 239 | timestampEl.textContent += ` (${(message.elapsed / 1e9).toFixed(2)}s)`; |
| 240 | } |
| 241 | |
| 242 | // Add turn duration for end-of-turn messages |
| 243 | if (message.turnDuration && message.end_of_turn) { |
| 244 | timestampEl.textContent += ` [Turn: ${(message.turnDuration / 1e9).toFixed(2)}s]`; |
| 245 | } |
| 246 | |
| 247 | // Add usage info inline for agent messages |
| 248 | if ( |
| 249 | message.type === "agent" && |
| 250 | message.usage && |
| 251 | (message.usage.input_tokens > 0 || |
| 252 | message.usage.output_tokens > 0 || |
| 253 | message.usage.cost_usd > 0) |
| 254 | ) { |
| 255 | try { |
| 256 | // Safe get all values |
| 257 | const inputTokens = formatNumber( |
| 258 | message.usage.input_tokens ?? 0, |
| 259 | ); |
| 260 | const cacheInput = message.usage.cache_read_input_tokens ?? 0; |
| 261 | const outputTokens = formatNumber( |
| 262 | message.usage.output_tokens ?? 0, |
| 263 | ); |
| 264 | const messageCost = this.formatCurrency( |
| 265 | message.usage.cost_usd ?? 0, |
| 266 | "$0.0000", // Default format for message costs |
| 267 | true, // Use 4 decimal places for message-level costs |
| 268 | ); |
| 269 | |
| 270 | timestampEl.textContent += ` | In: ${inputTokens}`; |
| 271 | if (cacheInput > 0) { |
| 272 | timestampEl.textContent += ` [Cache: ${formatNumber(cacheInput)}]`; |
| 273 | } |
| 274 | timestampEl.textContent += ` Out: ${outputTokens} (${messageCost})`; |
| 275 | } catch (e) { |
| 276 | console.error("Error adding usage info to timestamp:", e); |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | headerEl.appendChild(timestampEl); |
| 281 | } |
| 282 | |
| 283 | contentEl.appendChild(headerEl); |
| 284 | |
| 285 | // Add message content |
| 286 | if (message.content) { |
| 287 | const containerEl = document.createElement("div"); |
| 288 | containerEl.className = "message-text-container"; |
| 289 | |
| 290 | const textEl = document.createElement("div"); |
| 291 | textEl.className = "message-text markdown-content"; |
| 292 | |
| 293 | // Render markdown content |
| 294 | // Handle the Promise returned by renderMarkdown |
| 295 | renderMarkdown(message.content).then(html => { |
| 296 | textEl.innerHTML = html; |
| 297 | processRenderedMarkdown(textEl); |
| 298 | }); |
| 299 | |
| 300 | // Add copy button |
| 301 | const { container: copyButtonContainer, button: copyButton } = createCopyButton(message.content); |
| 302 | containerEl.appendChild(copyButtonContainer); |
| 303 | containerEl.appendChild(textEl); |
| 304 | |
| 305 | // Add collapse/expand for long content |
| 306 | addCollapsibleFunctionality(message, textEl, containerEl, contentEl); |
| 307 | } |
| 308 | |
| 309 | // If the message has tool calls, show them in an ultra-compact row of boxes |
| 310 | if (message.tool_calls && message.tool_calls.length > 0) { |
| 311 | const toolCallsContainer = document.createElement("div"); |
| 312 | toolCallsContainer.className = "tool-calls-container"; |
| 313 | |
| 314 | // Create a header row with tool count |
| 315 | const toolCallsHeaderRow = document.createElement("div"); |
| 316 | toolCallsHeaderRow.className = "tool-calls-header"; |
| 317 | // No header text - empty header |
| 318 | toolCallsContainer.appendChild(toolCallsHeaderRow); |
| 319 | |
| 320 | // Create a container for the tool call cards |
| 321 | const toolCallsCardContainer = document.createElement("div"); |
| 322 | toolCallsCardContainer.className = "tool-call-cards-container"; |
| 323 | |
| 324 | // Add each tool call as a card with response or spinner |
| 325 | message.tool_calls.forEach((toolCall: ToolCall, _index: number) => { |
| 326 | // Create a unique ID for this tool card |
| 327 | const toolCardId = `tool-card-${toolCall.tool_call_id || Math.random().toString(36).substring(2, 11)}`; |
| 328 | |
| 329 | // Find the matching tool response if it exists |
| 330 | const toolResponse = message.toolResponses?.find( |
| 331 | (resp) => resp.tool_call_id === toolCall.tool_call_id, |
| 332 | ); |
| 333 | |
| 334 | // Use the extracted utility function to create the tool card |
| 335 | const toolCard = createToolCallCard(toolCall, toolResponse, toolCardId); |
| 336 | |
| 337 | // Store reference to this element if it has a tool_call_id |
| 338 | if (toolCall.tool_call_id) { |
| 339 | this.toolCallIdToMessageElement.set(toolCall.tool_call_id, { |
| 340 | messageEl, |
| 341 | toolCallContainer: toolCallsCardContainer, |
| 342 | toolCardId, |
| 343 | }); |
| 344 | } |
| 345 | |
| 346 | // Add the card to the container |
| 347 | toolCallsCardContainer.appendChild(toolCard); |
| 348 | }); |
| 349 | |
| 350 | toolCallsContainer.appendChild(toolCallsCardContainer); |
| 351 | contentEl.appendChild(toolCallsContainer); |
| 352 | } |
| 353 | // If message is a commit message, display commits |
| 354 | if ( |
| 355 | message.type === "commit" && |
| 356 | message.commits && |
| 357 | message.commits.length > 0 |
| 358 | ) { |
| 359 | // Use the extracted utility function to create the commits container |
| 360 | const commitsContainer = createCommitsContainer( |
| 361 | message.commits, |
| 362 | (commitHash) => { |
| 363 | // This will need to be handled by the TimelineManager |
| 364 | const event = new CustomEvent('showCommitDiff', { |
| 365 | detail: { commitHash } |
| 366 | }); |
| 367 | document.dispatchEvent(event); |
| 368 | } |
| 369 | ); |
| 370 | contentEl.appendChild(commitsContainer); |
| 371 | } |
| 372 | |
| 373 | // Tool messages are now handled inline with agent messages |
| 374 | // If we still see a tool message here, it means it's not associated with an agent message |
| 375 | // (this could be legacy data or a special case) |
| 376 | if (message.type === "tool") { |
| 377 | const toolDetailsEl = document.createElement("div"); |
| 378 | toolDetailsEl.className = "tool-details standalone"; |
| 379 | |
| 380 | // Get tool input and result for display |
| 381 | let inputText = ""; |
| 382 | try { |
| 383 | if (message.input) { |
| 384 | const parsedInput = JSON.parse(message.input); |
| 385 | // Format input compactly for simple inputs |
| 386 | inputText = JSON.stringify(parsedInput); |
| 387 | } |
| 388 | } catch (e) { |
| 389 | // Not valid JSON, use as-is |
| 390 | inputText = message.input || ""; |
| 391 | } |
| 392 | |
| 393 | const resultText = message.tool_result || ""; |
| 394 | const statusEmoji = message.tool_error ? "❌" : "✅"; |
| 395 | const toolName = message.tool_name || "Unknown"; |
| 396 | |
| 397 | // Determine if we can use super compact display (e.g., for bash command results) |
| 398 | // Use compact display for short inputs/outputs without newlines |
| 399 | const isSimpleCommand = |
| 400 | toolName === "bash" && |
| 401 | inputText.length < 50 && |
| 402 | resultText.length < 200 && |
| 403 | !resultText.includes("\n"); |
| 404 | const isCompact = |
| 405 | inputText.length < 50 && |
| 406 | resultText.length < 100 && |
| 407 | !resultText.includes("\n"); |
| 408 | |
| 409 | if (isSimpleCommand) { |
| 410 | // SUPER COMPACT VIEW FOR BASH: Display everything on a single line |
| 411 | const toolLineEl = document.createElement("div"); |
| 412 | toolLineEl.className = "tool-compact-line"; |
| 413 | |
| 414 | // Create the compact bash display in format: "✅ bash({command}) → result" |
| 415 | try { |
| 416 | const parsed = JSON.parse(inputText); |
| 417 | const cmd = parsed.command || ""; |
| 418 | toolLineEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>({"command":"${cmd}"}) → <span class="tool-result-inline">${resultText}</span>`; |
| 419 | } catch { |
| 420 | toolLineEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>(${inputText}) → <span class="tool-result-inline">${resultText}</span>`; |
| 421 | } |
| 422 | |
| 423 | // Add copy button for result |
| 424 | const copyBtn = document.createElement("button"); |
| 425 | copyBtn.className = "copy-inline-button"; |
| 426 | copyBtn.textContent = "Copy"; |
| 427 | copyBtn.title = "Copy result to clipboard"; |
| 428 | |
| 429 | copyBtn.addEventListener("click", (e) => { |
| 430 | e.stopPropagation(); |
| 431 | navigator.clipboard |
| 432 | .writeText(resultText) |
| 433 | .then(() => { |
| 434 | copyBtn.textContent = "Copied!"; |
| 435 | setTimeout(() => { |
| 436 | copyBtn.textContent = "Copy"; |
| 437 | }, 2000); |
| 438 | }) |
| 439 | .catch((_err) => { |
| 440 | copyBtn.textContent = "Failed"; |
| 441 | setTimeout(() => { |
| 442 | copyBtn.textContent = "Copy"; |
| 443 | }, 2000); |
| 444 | }); |
| 445 | }); |
| 446 | |
| 447 | toolLineEl.appendChild(copyBtn); |
| 448 | toolDetailsEl.appendChild(toolLineEl); |
| 449 | } else if (isCompact && !isSimpleCommand) { |
| 450 | // COMPACT VIEW: Display everything on one or two lines for other tool types |
| 451 | const toolLineEl = document.createElement("div"); |
| 452 | toolLineEl.className = "tool-compact-line"; |
| 453 | |
| 454 | // Create the compact display in format: "✅ tool_name(input) → result" |
| 455 | let compactDisplay = `${statusEmoji} <strong>${toolName}</strong>(${inputText})`; |
| 456 | |
| 457 | if (resultText) { |
| 458 | compactDisplay += ` → <span class="tool-result-inline">${resultText}</span>`; |
| 459 | } |
| 460 | |
| 461 | toolLineEl.innerHTML = compactDisplay; |
| 462 | |
| 463 | // Add copy button for result |
| 464 | const copyBtn = document.createElement("button"); |
| 465 | copyBtn.className = "copy-inline-button"; |
| 466 | copyBtn.textContent = "Copy"; |
| 467 | copyBtn.title = "Copy result to clipboard"; |
| 468 | |
| 469 | copyBtn.addEventListener("click", (e) => { |
| 470 | e.stopPropagation(); |
| 471 | navigator.clipboard |
| 472 | .writeText(resultText) |
| 473 | .then(() => { |
| 474 | copyBtn.textContent = "Copied!"; |
| 475 | setTimeout(() => { |
| 476 | copyBtn.textContent = "Copy"; |
| 477 | }, 2000); |
| 478 | }) |
| 479 | .catch((_err) => { |
| 480 | copyBtn.textContent = "Failed"; |
| 481 | setTimeout(() => { |
| 482 | copyBtn.textContent = "Copy"; |
| 483 | }, 2000); |
| 484 | }); |
| 485 | }); |
| 486 | |
| 487 | toolLineEl.appendChild(copyBtn); |
| 488 | toolDetailsEl.appendChild(toolLineEl); |
| 489 | } else { |
| 490 | // EXPANDED VIEW: For longer inputs/results that need more space |
| 491 | // Tool name header |
| 492 | const toolNameEl = document.createElement("div"); |
| 493 | toolNameEl.className = "tool-name"; |
| 494 | toolNameEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>`; |
| 495 | toolDetailsEl.appendChild(toolNameEl); |
| 496 | |
| 497 | // Show input (simplified) |
| 498 | if (message.input) { |
| 499 | const inputContainer = document.createElement("div"); |
| 500 | inputContainer.className = "tool-input-container compact"; |
| 501 | |
| 502 | const inputEl = document.createElement("pre"); |
| 503 | inputEl.className = "tool-input compact"; |
| 504 | inputEl.textContent = inputText; |
| 505 | inputContainer.appendChild(inputEl); |
| 506 | toolDetailsEl.appendChild(inputContainer); |
| 507 | } |
| 508 | |
| 509 | // Show result (simplified) |
| 510 | if (resultText) { |
| 511 | const resultContainer = document.createElement("div"); |
| 512 | resultContainer.className = "tool-result-container compact"; |
| 513 | |
| 514 | const resultEl = document.createElement("pre"); |
| 515 | resultEl.className = "tool-result compact"; |
| 516 | resultEl.textContent = resultText; |
| 517 | resultContainer.appendChild(resultEl); |
| 518 | |
| 519 | // Add collapse/expand for longer results |
| 520 | if (resultText.length > 100) { |
| 521 | resultEl.classList.add("collapsed"); |
| 522 | |
| 523 | const toggleButton = document.createElement("button"); |
| 524 | toggleButton.className = "collapsible"; |
| 525 | toggleButton.textContent = "Show more..."; |
| 526 | toggleButton.addEventListener("click", () => { |
| 527 | resultEl.classList.toggle("collapsed"); |
| 528 | toggleButton.textContent = resultEl.classList.contains( |
| 529 | "collapsed", |
| 530 | ) |
| 531 | ? "Show more..." |
| 532 | : "Show less"; |
| 533 | }); |
| 534 | |
| 535 | toolDetailsEl.appendChild(resultContainer); |
| 536 | toolDetailsEl.appendChild(toggleButton); |
| 537 | } else { |
| 538 | toolDetailsEl.appendChild(resultContainer); |
| 539 | } |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | contentEl.appendChild(toolDetailsEl); |
| 544 | } |
| 545 | |
| 546 | // Add usage info if available with robust null handling - only for non-agent messages |
| 547 | if ( |
| 548 | message.type !== "agent" && // Skip for agent messages as we've already added usage info at the top |
| 549 | message.usage && |
| 550 | (message.usage.input_tokens > 0 || |
| 551 | message.usage.output_tokens > 0 || |
| 552 | message.usage.cost_usd > 0) |
| 553 | ) { |
| 554 | try { |
| 555 | const usageEl = document.createElement("div"); |
| 556 | usageEl.className = "usage-info"; |
| 557 | |
| 558 | // Safe get all values |
| 559 | const inputTokens = formatNumber( |
| 560 | message.usage.input_tokens ?? 0, |
| 561 | ); |
| 562 | const cacheInput = message.usage.cache_read_input_tokens ?? 0; |
| 563 | const outputTokens = formatNumber( |
| 564 | message.usage.output_tokens ?? 0, |
| 565 | ); |
| 566 | const messageCost = this.formatCurrency( |
| 567 | message.usage.cost_usd ?? 0, |
| 568 | "$0.0000", // Default format for message costs |
| 569 | true, // Use 4 decimal places for message-level costs |
| 570 | ); |
| 571 | |
| 572 | // Create usage info display |
| 573 | usageEl.innerHTML = ` |
| 574 | <span title="Input tokens">In: ${inputTokens}</span> |
| 575 | ${cacheInput > 0 ? `<span title="Cache tokens">[Cache: ${formatNumber(cacheInput)}]</span>` : ""} |
| 576 | <span title="Output tokens">Out: ${outputTokens}</span> |
| 577 | <span title="Message cost">(${messageCost})</span> |
| 578 | `; |
| 579 | |
| 580 | contentEl.appendChild(usageEl); |
| 581 | } catch (e) { |
| 582 | console.error("Error rendering usage info:", e); |
| 583 | } |
| 584 | } |
| 585 | |
| 586 | messageEl.appendChild(contentEl); |
| 587 | timeline.appendChild(messageEl); |
| 588 | }); |
| 589 | |
| 590 | // Scroll to bottom of the timeline if needed |
| 591 | this.scrollToBottom(); |
| 592 | } |
| 593 | |
| 594 | /** |
| 595 | * Check if we should scroll to the bottom |
| 596 | */ |
| 597 | private checkShouldScroll(): boolean { |
| 598 | return checkShouldScroll(this.isFirstLoad); |
| 599 | } |
| 600 | |
| 601 | /** |
| 602 | * Scroll to the bottom of the timeline |
| 603 | */ |
| 604 | private scrollToBottom(): void { |
| 605 | scrollToBottom(this.shouldScrollToBottom); |
| 606 | |
| 607 | // After first load, we'll only auto-scroll if user is already near the bottom |
| 608 | this.isFirstLoad = false; |
| 609 | } |
| 610 | |
| 611 | /** |
| 612 | * Get readable name for message type |
| 613 | */ |
| 614 | private getTypeName(type: string | null | undefined): string { |
| 615 | switch (type) { |
| 616 | case "user": |
| 617 | return "User"; |
| 618 | case "agent": |
| 619 | return "Agent"; |
| 620 | case "tool": |
| 621 | return "Tool Use"; |
| 622 | case "error": |
| 623 | return "Error"; |
| 624 | default: |
| 625 | return ( |
| 626 | (type || "Unknown").charAt(0).toUpperCase() + |
| 627 | (type || "unknown").slice(1) |
| 628 | ); |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | /** |
| 633 | * Format timestamp for display |
| 634 | */ |
| 635 | private formatTimestamp( |
| 636 | timestamp: string | number | Date | null | undefined, |
| 637 | defaultValue: string = "", |
| 638 | ): string { |
| 639 | if (!timestamp) return defaultValue; |
| 640 | try { |
| 641 | const date = new Date(timestamp); |
| 642 | if (isNaN(date.getTime())) return defaultValue; |
| 643 | |
| 644 | // Format: Mar 13, 2025 09:53:25 AM |
| 645 | return date.toLocaleString("en-US", { |
| 646 | month: "short", |
| 647 | day: "numeric", |
| 648 | year: "numeric", |
| 649 | hour: "numeric", |
| 650 | minute: "2-digit", |
| 651 | second: "2-digit", |
| 652 | hour12: true, |
| 653 | }); |
| 654 | } catch (e) { |
| 655 | return defaultValue; |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | /** |
| 660 | * Format currency values |
| 661 | */ |
| 662 | private formatCurrency( |
| 663 | num: number | string | null | undefined, |
| 664 | defaultValue: string = "$0.00", |
| 665 | isMessageLevel: boolean = false, |
| 666 | ): string { |
| 667 | if (num === undefined || num === null) return defaultValue; |
| 668 | try { |
| 669 | // Use 4 decimal places for message-level costs, 2 for totals |
| 670 | const decimalPlaces = isMessageLevel ? 4 : 2; |
| 671 | return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`; |
| 672 | } catch (e) { |
| 673 | return defaultValue; |
| 674 | } |
| 675 | } |
| 676 | |
| 677 | /** |
| 678 | * Update a tool call in an agent message with the response |
| 679 | */ |
| 680 | private updateToolCallInAgentMessage( |
| 681 | toolMessage: TimelineMessage, |
| 682 | toolCallRef: { |
| 683 | messageEl: HTMLElement; |
| 684 | toolCallContainer: HTMLElement | null; |
| 685 | toolCardId: string; |
| 686 | }, |
| 687 | ): void { |
| 688 | const { messageEl, toolCardId } = toolCallRef; |
| 689 | |
| 690 | // Find the tool card element |
| 691 | const toolCard = messageEl.querySelector(`#${toolCardId}`) as HTMLElement; |
| 692 | if (!toolCard) return; |
| 693 | |
| 694 | // Use the extracted utility function to update the tool card |
| 695 | updateToolCallCard(toolCard, toolMessage); |
| 696 | } |
| 697 | |
| 698 | /** |
| 699 | * Get the tool call id to message element map |
| 700 | * Used by the TimelineManager to access the map |
| 701 | */ |
| 702 | public getToolCallIdToMessageElement(): Map< |
| 703 | string, |
| 704 | { |
| 705 | messageEl: HTMLElement; |
| 706 | toolCallContainer: HTMLElement | null; |
| 707 | toolCardId: string; |
| 708 | } |
| 709 | > { |
| 710 | return this.toolCallIdToMessageElement; |
| 711 | } |
| 712 | |
| 713 | /** |
| 714 | * Set the tool call id to message element map |
| 715 | * Used by the TimelineManager to update the map |
| 716 | */ |
| 717 | public setToolCallIdToMessageElement( |
| 718 | map: Map< |
| 719 | string, |
| 720 | { |
| 721 | messageEl: HTMLElement; |
| 722 | toolCallContainer: HTMLElement | null; |
| 723 | toolCardId: string; |
| 724 | } |
| 725 | > |
| 726 | ): void { |
| 727 | this.toolCallIdToMessageElement = map; |
| 728 | } |
| 729 | } |