blob: f2770eea0d58e0eddeb61050b8487bf8d83c10cd [file] [log] [blame]
/**
* MessageRenderer - Class to handle rendering of timeline messages
*/
import { TimelineMessage, ToolCall } from "./types";
import { escapeHTML, formatNumber, generateColorFromId } from "./utils";
import { renderMarkdown, processRenderedMarkdown } from "./markdown/renderer";
import { createToolCallCard, updateToolCallCard } from "./toolcalls";
import { createCommitsContainer } from "./commits";
import { createCopyButton } from "./copybutton";
import { getIconText } from "./icons";
import { addCollapsibleFunctionality } from "./components/collapsible";
import { checkShouldScroll, scrollToBottom } from "./scroll";
export class MessageRenderer {
// Map to store references to agent message DOM elements by tool call ID
private toolCallIdToMessageElement: Map<
string,
{
messageEl: HTMLElement;
toolCallContainer: HTMLElement | null;
toolCardId: string;
}
> = new Map();
// State tracking variables
private isFirstLoad: boolean = true;
private shouldScrollToBottom: boolean = true;
private currentFetchStartIndex: number = 0;
constructor() {}
/**
* Initialize the renderer with state from the timeline manager
*/
public initialize(isFirstLoad: boolean, currentFetchStartIndex: number) {
this.isFirstLoad = isFirstLoad;
this.currentFetchStartIndex = currentFetchStartIndex;
}
/**
* Renders the timeline with messages
* @param messages The messages to render
* @param clearExisting Whether to clear existing content before rendering
*/
public renderTimeline(
messages: TimelineMessage[],
clearExisting: boolean = false,
): void {
const timeline = document.getElementById("timeline");
if (!timeline) return;
// We'll keep the isFirstLoad value for this render cycle,
// but will set it to false afterwards in scrollToBottom
if (clearExisting) {
timeline.innerHTML = ""; // Clear existing content only if this is the first load
// Clear our map of tool call references
this.toolCallIdToMessageElement.clear();
}
if (!messages || messages.length === 0) {
if (clearExisting) {
timeline.innerHTML = "<p>No messages available.</p>";
timeline.classList.add("empty");
}
return;
}
// Remove empty class when there are messages
timeline.classList.remove("empty");
// Keep track of conversation groups to properly indent
interface ConversationGroup {
color: string;
level: number;
}
const conversationGroups: Record<string, ConversationGroup> = {};
// Use the currentFetchStartIndex as the base index for these messages
const startIndex = this.currentFetchStartIndex;
// Group tool messages with their parent agent messages
const organizedMessages: (TimelineMessage & {
toolResponses?: TimelineMessage[];
})[] = [];
const toolMessagesByCallId: Record<string, TimelineMessage> = {};
// First, process tool messages - check if any can update existing UI elements
const processedToolMessages = new Set<string>();
messages.forEach((message) => {
// If this is a tool message with a tool_call_id
if (message.type === "tool" && message.tool_call_id) {
// Try to find an existing agent message that's waiting for this tool response
const toolCallRef = this.toolCallIdToMessageElement.get(
message.tool_call_id,
);
if (toolCallRef) {
// Found an existing agent message that needs updating
this.updateToolCallInAgentMessage(message, toolCallRef);
processedToolMessages.add(message.tool_call_id);
} else {
// No existing agent message found, we'll include this in normal rendering
toolMessagesByCallId[message.tool_call_id] = message;
}
}
});
// Then, process messages and organize them
messages.forEach((message, localIndex) => {
const _index = startIndex + localIndex;
if (!message) return; // Skip if message is null/undefined
// If it's a tool message and we're going to inline it with its parent agent message,
// we'll skip rendering it here - it will be included with the agent message
if (message.type === "tool" && message.tool_call_id) {
// Skip if we've already processed this tool message (updated an existing agent message)
if (processedToolMessages.has(message.tool_call_id)) {
return;
}
// Skip if this tool message will be included with a new agent message
if (toolMessagesByCallId[message.tool_call_id]) {
return;
}
}
// For agent messages with tool calls, attach their tool responses
if (
message.type === "agent" &&
message.tool_calls &&
message.tool_calls.length > 0
) {
const toolResponses: TimelineMessage[] = [];
// Look up tool responses for each tool call
message.tool_calls.forEach((toolCall) => {
if (
toolCall.tool_call_id &&
toolMessagesByCallId[toolCall.tool_call_id]
) {
toolResponses.push(toolMessagesByCallId[toolCall.tool_call_id]);
}
});
if (toolResponses.length > 0) {
message = { ...message, toolResponses };
}
}
organizedMessages.push(message);
});
let lastMessage:TimelineMessage|undefined;
if (messages && messages.length > 0 && startIndex > 0) {
lastMessage = messages[startIndex-1];
}
// Loop through organized messages and create timeline items
organizedMessages.forEach((message, localIndex) => {
const _index = startIndex + localIndex;
if (!message) return; // Skip if message is null/undefined
if (localIndex > 0) {
lastMessage = organizedMessages.at(localIndex-1);
}
// Determine if this is a subconversation
const hasParent = !!message.parent_conversation_id;
const conversationId = message.conversation_id || "";
const _parentId = message.parent_conversation_id || "";
// Track the conversation group
if (conversationId && !conversationGroups[conversationId]) {
conversationGroups[conversationId] = {
color: generateColorFromId(conversationId),
level: hasParent ? 1 : 0, // Level 0 for main conversation, 1+ for nested
};
}
// Get the level and color for this message
const group = conversationGroups[conversationId] || {
level: 0,
color: "#888888",
};
const messageEl = document.createElement("div");
messageEl.className = `message ${message.type || "unknown"} ${message.end_of_turn ? "end-of-turn" : ""}`;
// Add indentation class for subconversations
if (hasParent) {
messageEl.classList.add("subconversation");
messageEl.style.marginLeft = `${group.level * 40}px`;
// Add a colored left border to indicate the subconversation
messageEl.style.borderLeft = `4px solid ${group.color}`;
}
// newMsgType indicates when to create a new icon and message
// type header. This is a primitive form of message coalescing,
// but it does reduce the amount of redundant information in
// the UI.
const newMsgType = !lastMessage ||
(message.type == 'user' && lastMessage.type != 'user') ||
(message.type != 'user' && lastMessage.type == 'user');
if (newMsgType) {
// Create message icon
const iconEl = document.createElement("div");
iconEl.className = "message-icon";
iconEl.textContent = getIconText(message.type);
messageEl.appendChild(iconEl);
}
// Create message content container
const contentEl = document.createElement("div");
contentEl.className = "message-content";
// Create message header
const headerEl = document.createElement("div");
headerEl.className = "message-header";
if (newMsgType) {
const typeEl = document.createElement("span");
typeEl.className = "message-type";
typeEl.textContent = this.getTypeName(message.type);
headerEl.appendChild(typeEl);
}
// Add timestamp and usage info combined for agent messages at the top
if (message.timestamp) {
const timestampEl = document.createElement("span");
timestampEl.className = "message-timestamp";
timestampEl.textContent = this.formatTimestamp(message.timestamp);
// Add elapsed time if available
if (message.elapsed) {
timestampEl.textContent += ` (${(message.elapsed / 1e9).toFixed(2)}s)`;
}
// Add turn duration for end-of-turn messages
if (message.turnDuration && message.end_of_turn) {
timestampEl.textContent += ` [Turn: ${(message.turnDuration / 1e9).toFixed(2)}s]`;
}
// Add usage info inline for agent messages
if (
message.type === "agent" &&
message.usage &&
(message.usage.input_tokens > 0 ||
message.usage.output_tokens > 0 ||
message.usage.cost_usd > 0)
) {
try {
// Safe get all values
const inputTokens = formatNumber(
message.usage.input_tokens ?? 0,
);
const cacheInput = message.usage.cache_read_input_tokens ?? 0;
const outputTokens = formatNumber(
message.usage.output_tokens ?? 0,
);
const messageCost = this.formatCurrency(
message.usage.cost_usd ?? 0,
"$0.0000", // Default format for message costs
true, // Use 4 decimal places for message-level costs
);
timestampEl.textContent += ` | In: ${inputTokens}`;
if (cacheInput > 0) {
timestampEl.textContent += ` [Cache: ${formatNumber(cacheInput)}]`;
}
timestampEl.textContent += ` Out: ${outputTokens} (${messageCost})`;
} catch (e) {
console.error("Error adding usage info to timestamp:", e);
}
}
headerEl.appendChild(timestampEl);
}
contentEl.appendChild(headerEl);
// Add message content
if (message.content) {
const containerEl = document.createElement("div");
containerEl.className = "message-text-container";
const textEl = document.createElement("div");
textEl.className = "message-text markdown-content";
// Render markdown content
// Handle the Promise returned by renderMarkdown
renderMarkdown(message.content).then(html => {
textEl.innerHTML = html;
processRenderedMarkdown(textEl);
});
// Add copy button
const { container: copyButtonContainer, button: copyButton } = createCopyButton(message.content);
containerEl.appendChild(copyButtonContainer);
containerEl.appendChild(textEl);
// Add collapse/expand for long content
addCollapsibleFunctionality(message, textEl, containerEl, contentEl);
}
// If the message has tool calls, show them in an ultra-compact row of boxes
if (message.tool_calls && message.tool_calls.length > 0) {
const toolCallsContainer = document.createElement("div");
toolCallsContainer.className = "tool-calls-container";
// Create a header row with tool count
const toolCallsHeaderRow = document.createElement("div");
toolCallsHeaderRow.className = "tool-calls-header";
// No header text - empty header
toolCallsContainer.appendChild(toolCallsHeaderRow);
// Create a container for the tool call cards
const toolCallsCardContainer = document.createElement("div");
toolCallsCardContainer.className = "tool-call-cards-container";
// Add each tool call as a card with response or spinner
message.tool_calls.forEach((toolCall: ToolCall, _index: number) => {
// Create a unique ID for this tool card
const toolCardId = `tool-card-${toolCall.tool_call_id || Math.random().toString(36).substring(2, 11)}`;
// Find the matching tool response if it exists
const toolResponse = message.toolResponses?.find(
(resp) => resp.tool_call_id === toolCall.tool_call_id,
);
// Use the extracted utility function to create the tool card
const toolCard = createToolCallCard(toolCall, toolResponse, toolCardId);
// Store reference to this element if it has a tool_call_id
if (toolCall.tool_call_id) {
this.toolCallIdToMessageElement.set(toolCall.tool_call_id, {
messageEl,
toolCallContainer: toolCallsCardContainer,
toolCardId,
});
}
// Add the card to the container
toolCallsCardContainer.appendChild(toolCard);
});
toolCallsContainer.appendChild(toolCallsCardContainer);
contentEl.appendChild(toolCallsContainer);
}
// If message is a commit message, display commits
if (
message.type === "commit" &&
message.commits &&
message.commits.length > 0
) {
// Use the extracted utility function to create the commits container
const commitsContainer = createCommitsContainer(
message.commits,
(commitHash) => {
// This will need to be handled by the TimelineManager
const event = new CustomEvent('showCommitDiff', {
detail: { commitHash }
});
document.dispatchEvent(event);
}
);
contentEl.appendChild(commitsContainer);
}
// Tool messages are now handled inline with agent messages
// If we still see a tool message here, it means it's not associated with an agent message
// (this could be legacy data or a special case)
if (message.type === "tool") {
const toolDetailsEl = document.createElement("div");
toolDetailsEl.className = "tool-details standalone";
// Get tool input and result for display
let inputText = "";
try {
if (message.input) {
const parsedInput = JSON.parse(message.input);
// Format input compactly for simple inputs
inputText = JSON.stringify(parsedInput);
}
} catch (e) {
// Not valid JSON, use as-is
inputText = message.input || "";
}
const resultText = message.tool_result || "";
const statusEmoji = message.tool_error ? "❌" : "✅";
const toolName = message.tool_name || "Unknown";
// Determine if we can use super compact display (e.g., for bash command results)
// Use compact display for short inputs/outputs without newlines
const isSimpleCommand =
toolName === "bash" &&
inputText.length < 50 &&
resultText.length < 200 &&
!resultText.includes("\n");
const isCompact =
inputText.length < 50 &&
resultText.length < 100 &&
!resultText.includes("\n");
if (isSimpleCommand) {
// SUPER COMPACT VIEW FOR BASH: Display everything on a single line
const toolLineEl = document.createElement("div");
toolLineEl.className = "tool-compact-line";
// Create the compact bash display in format: "✅ bash({command}) → result"
try {
const parsed = JSON.parse(inputText);
const cmd = parsed.command || "";
toolLineEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>({"command":"${cmd}"}) → <span class="tool-result-inline">${resultText}</span>`;
} catch {
toolLineEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>(${inputText}) → <span class="tool-result-inline">${resultText}</span>`;
}
// Add copy button for result
const copyBtn = document.createElement("button");
copyBtn.className = "copy-inline-button";
copyBtn.textContent = "Copy";
copyBtn.title = "Copy result to clipboard";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
navigator.clipboard
.writeText(resultText)
.then(() => {
copyBtn.textContent = "Copied!";
setTimeout(() => {
copyBtn.textContent = "Copy";
}, 2000);
})
.catch((_err) => {
copyBtn.textContent = "Failed";
setTimeout(() => {
copyBtn.textContent = "Copy";
}, 2000);
});
});
toolLineEl.appendChild(copyBtn);
toolDetailsEl.appendChild(toolLineEl);
} else if (isCompact && !isSimpleCommand) {
// COMPACT VIEW: Display everything on one or two lines for other tool types
const toolLineEl = document.createElement("div");
toolLineEl.className = "tool-compact-line";
// Create the compact display in format: "✅ tool_name(input) → result"
let compactDisplay = `${statusEmoji} <strong>${toolName}</strong>(${inputText})`;
if (resultText) {
compactDisplay += ` → <span class="tool-result-inline">${resultText}</span>`;
}
toolLineEl.innerHTML = compactDisplay;
// Add copy button for result
const copyBtn = document.createElement("button");
copyBtn.className = "copy-inline-button";
copyBtn.textContent = "Copy";
copyBtn.title = "Copy result to clipboard";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
navigator.clipboard
.writeText(resultText)
.then(() => {
copyBtn.textContent = "Copied!";
setTimeout(() => {
copyBtn.textContent = "Copy";
}, 2000);
})
.catch((_err) => {
copyBtn.textContent = "Failed";
setTimeout(() => {
copyBtn.textContent = "Copy";
}, 2000);
});
});
toolLineEl.appendChild(copyBtn);
toolDetailsEl.appendChild(toolLineEl);
} else {
// EXPANDED VIEW: For longer inputs/results that need more space
// Tool name header
const toolNameEl = document.createElement("div");
toolNameEl.className = "tool-name";
toolNameEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>`;
toolDetailsEl.appendChild(toolNameEl);
// Show input (simplified)
if (message.input) {
const inputContainer = document.createElement("div");
inputContainer.className = "tool-input-container compact";
const inputEl = document.createElement("pre");
inputEl.className = "tool-input compact";
inputEl.textContent = inputText;
inputContainer.appendChild(inputEl);
toolDetailsEl.appendChild(inputContainer);
}
// Show result (simplified)
if (resultText) {
const resultContainer = document.createElement("div");
resultContainer.className = "tool-result-container compact";
const resultEl = document.createElement("pre");
resultEl.className = "tool-result compact";
resultEl.textContent = resultText;
resultContainer.appendChild(resultEl);
// Add collapse/expand for longer results
if (resultText.length > 100) {
resultEl.classList.add("collapsed");
const toggleButton = document.createElement("button");
toggleButton.className = "collapsible";
toggleButton.textContent = "Show more...";
toggleButton.addEventListener("click", () => {
resultEl.classList.toggle("collapsed");
toggleButton.textContent = resultEl.classList.contains(
"collapsed",
)
? "Show more..."
: "Show less";
});
toolDetailsEl.appendChild(resultContainer);
toolDetailsEl.appendChild(toggleButton);
} else {
toolDetailsEl.appendChild(resultContainer);
}
}
}
contentEl.appendChild(toolDetailsEl);
}
// Add usage info if available with robust null handling - only for non-agent messages
if (
message.type !== "agent" && // Skip for agent messages as we've already added usage info at the top
message.usage &&
(message.usage.input_tokens > 0 ||
message.usage.output_tokens > 0 ||
message.usage.cost_usd > 0)
) {
try {
const usageEl = document.createElement("div");
usageEl.className = "usage-info";
// Safe get all values
const inputTokens = formatNumber(
message.usage.input_tokens ?? 0,
);
const cacheInput = message.usage.cache_read_input_tokens ?? 0;
const outputTokens = formatNumber(
message.usage.output_tokens ?? 0,
);
const messageCost = this.formatCurrency(
message.usage.cost_usd ?? 0,
"$0.0000", // Default format for message costs
true, // Use 4 decimal places for message-level costs
);
// Create usage info display
usageEl.innerHTML = `
<span title="Input tokens">In: ${inputTokens}</span>
${cacheInput > 0 ? `<span title="Cache tokens">[Cache: ${formatNumber(cacheInput)}]</span>` : ""}
<span title="Output tokens">Out: ${outputTokens}</span>
<span title="Message cost">(${messageCost})</span>
`;
contentEl.appendChild(usageEl);
} catch (e) {
console.error("Error rendering usage info:", e);
}
}
messageEl.appendChild(contentEl);
timeline.appendChild(messageEl);
});
// Scroll to bottom of the timeline if needed
this.scrollToBottom();
}
/**
* Check if we should scroll to the bottom
*/
private checkShouldScroll(): boolean {
return checkShouldScroll(this.isFirstLoad);
}
/**
* Scroll to the bottom of the timeline
*/
private scrollToBottom(): void {
scrollToBottom(this.shouldScrollToBottom);
// After first load, we'll only auto-scroll if user is already near the bottom
this.isFirstLoad = false;
}
/**
* Get readable name for message type
*/
private getTypeName(type: string | null | undefined): string {
switch (type) {
case "user":
return "User";
case "agent":
return "Agent";
case "tool":
return "Tool Use";
case "error":
return "Error";
default:
return (
(type || "Unknown").charAt(0).toUpperCase() +
(type || "unknown").slice(1)
);
}
}
/**
* Format timestamp for display
*/
private formatTimestamp(
timestamp: string | number | Date | null | undefined,
defaultValue: string = "",
): string {
if (!timestamp) return defaultValue;
try {
const date = new Date(timestamp);
if (isNaN(date.getTime())) return defaultValue;
// Format: Mar 13, 2025 09:53:25 AM
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
} catch (e) {
return defaultValue;
}
}
/**
* Format currency values
*/
private formatCurrency(
num: number | string | null | undefined,
defaultValue: string = "$0.00",
isMessageLevel: boolean = false,
): string {
if (num === undefined || num === null) return defaultValue;
try {
// Use 4 decimal places for message-level costs, 2 for totals
const decimalPlaces = isMessageLevel ? 4 : 2;
return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
} catch (e) {
return defaultValue;
}
}
/**
* Update a tool call in an agent message with the response
*/
private updateToolCallInAgentMessage(
toolMessage: TimelineMessage,
toolCallRef: {
messageEl: HTMLElement;
toolCallContainer: HTMLElement | null;
toolCardId: string;
},
): void {
const { messageEl, toolCardId } = toolCallRef;
// Find the tool card element
const toolCard = messageEl.querySelector(`#${toolCardId}`) as HTMLElement;
if (!toolCard) return;
// Use the extracted utility function to update the tool card
updateToolCallCard(toolCard, toolMessage);
}
/**
* Get the tool call id to message element map
* Used by the TimelineManager to access the map
*/
public getToolCallIdToMessageElement(): Map<
string,
{
messageEl: HTMLElement;
toolCallContainer: HTMLElement | null;
toolCardId: string;
}
> {
return this.toolCallIdToMessageElement;
}
/**
* Set the tool call id to message element map
* Used by the TimelineManager to update the map
*/
public setToolCallIdToMessageElement(
map: Map<
string,
{
messageEl: HTMLElement;
toolCallContainer: HTMLElement | null;
toolCardId: string;
}
>
): void {
this.toolCallIdToMessageElement = map;
}
}