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;
+  }
+}