Initial commit
diff --git a/loop/webui/src/timeline/renderer.ts b/loop/webui/src/timeline/renderer.ts
new file mode 100644
index 0000000..f2770ee
--- /dev/null
+++ b/loop/webui/src/timeline/renderer.ts
@@ -0,0 +1,729 @@
+/**
+ * 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;
+ }
+}