blob: f2770eea0d58e0eddeb61050b8487bf8d83c10cd [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001/**
2 * MessageRenderer - Class to handle rendering of timeline messages
3 */
4
5import { TimelineMessage, ToolCall } from "./types";
6import { escapeHTML, formatNumber, generateColorFromId } from "./utils";
7import { renderMarkdown, processRenderedMarkdown } from "./markdown/renderer";
8import { createToolCallCard, updateToolCallCard } from "./toolcalls";
9import { createCommitsContainer } from "./commits";
10import { createCopyButton } from "./copybutton";
11import { getIconText } from "./icons";
12import { addCollapsibleFunctionality } from "./components/collapsible";
13import { checkShouldScroll, scrollToBottom } from "./scroll";
14
15export 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}