Initial commit
diff --git a/loop/webui/src/timeline/charts.ts b/loop/webui/src/timeline/charts.ts
new file mode 100644
index 0000000..0ed56e8
--- /dev/null
+++ b/loop/webui/src/timeline/charts.ts
@@ -0,0 +1,468 @@
+import type { TimelineMessage } from "./types";
+import vegaEmbed from "vega-embed";
+import { TopLevelSpec } from "vega-lite";
+
+/**
+ * ChartManager handles all chart-related functionality for the timeline.
+ * This includes rendering charts, calculating data, and managing chart state.
+ */
+export class ChartManager {
+  private chartData: { timestamp: Date; cost: number }[] = [];
+
+  /**
+   * Create a new ChartManager instance
+   */
+  constructor() {
+    this.chartData = [];
+  }
+
+  /**
+   * Calculate cumulative cost data from messages
+   */
+  public calculateCumulativeCostData(
+    messages: TimelineMessage[],
+  ): { timestamp: Date; cost: number }[] {
+    if (!messages || messages.length === 0) {
+      return [];
+    }
+
+    let cumulativeCost = 0;
+    const data: { timestamp: Date; cost: number }[] = [];
+
+    for (const message of messages) {
+      if (message.timestamp && message.usage && message.usage.cost_usd) {
+        const timestamp = new Date(message.timestamp);
+        cumulativeCost += message.usage.cost_usd;
+
+        data.push({
+          timestamp,
+          cost: cumulativeCost,
+        });
+      }
+    }
+
+    return data;
+  }
+
+  /**
+   * Get the current chart data
+   */
+  public getChartData(): { timestamp: Date; cost: number }[] {
+    return this.chartData;
+  }
+
+  /**
+   * Set chart data
+   */
+  public setChartData(data: { timestamp: Date; cost: number }[]): void {
+    this.chartData = data;
+  }
+
+  /**
+   * Fetch all messages to generate chart data
+   */
+  public async fetchAllMessages(): Promise<void> {
+    try {
+      // Fetch all messages in a single request
+      const response = await fetch("messages");
+      if (!response.ok) {
+        throw new Error(`Failed to fetch messages: ${response.status}`);
+      }
+
+      const allMessages = await response.json();
+      if (Array.isArray(allMessages)) {
+        // Sort messages chronologically
+        allMessages.sort((a, b) => {
+          const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
+          const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
+          return dateA - dateB;
+        });
+
+        // Calculate cumulative cost data
+        this.chartData = this.calculateCumulativeCostData(allMessages);
+      }
+    } catch (error) {
+      console.error("Error fetching messages for chart:", error);
+      this.chartData = [];
+    }
+  }
+
+  /**
+   * Render all charts in the chart view
+   */
+  public async renderCharts(): Promise<void> {
+    const chartContainer = document.getElementById("chartContainer");
+    if (!chartContainer) return;
+
+    try {
+      // Show loading state
+      chartContainer.innerHTML = "<div class='loader'></div>";
+
+      // Fetch messages if necessary
+      if (this.chartData.length === 0) {
+        await this.fetchAllMessages();
+      }
+
+      // Clear the container for multiple charts
+      chartContainer.innerHTML = "";
+
+      // Create cost chart container
+      const costChartDiv = document.createElement("div");
+      costChartDiv.className = "chart-section";
+      costChartDiv.innerHTML =
+        "<h3>Dollar Usage Over Time</h3><div id='costChart'></div>";
+      chartContainer.appendChild(costChartDiv);
+
+      // Create messages chart container
+      const messagesChartDiv = document.createElement("div");
+      messagesChartDiv.className = "chart-section";
+      messagesChartDiv.innerHTML =
+        "<h3>Message Timeline</h3><div id='messagesChart'></div>";
+      chartContainer.appendChild(messagesChartDiv);
+
+      // Render both charts
+      await this.renderDollarUsageChart();
+      await this.renderMessagesChart();
+    } catch (error) {
+      console.error("Error rendering charts:", error);
+      chartContainer.innerHTML = `<p>Error rendering charts: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
+    }
+  }
+
+  /**
+   * Render the dollar usage chart using Vega-Lite
+   */
+  private async renderDollarUsageChart(): Promise<void> {
+    const costChartContainer = document.getElementById("costChart");
+    if (!costChartContainer) return;
+
+    try {
+      // Display cost chart using Vega-Lite
+      if (this.chartData.length === 0) {
+        costChartContainer.innerHTML =
+          "<p>No cost data available to display.</p>";
+        return;
+      }
+
+      // Create a Vega-Lite spec for the line chart
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const costSpec: any = {
+        $schema: "https://vega.github.io/schema/vega-lite/v5.json",
+        description: "Cumulative cost over time",
+        width: "container",
+        height: 300,
+        data: {
+          values: this.chartData.map((d) => ({
+            timestamp: d.timestamp.toISOString(),
+            cost: d.cost,
+          })),
+        },
+        mark: {
+          type: "line",
+          point: true,
+        },
+        encoding: {
+          x: {
+            field: "timestamp",
+            type: "temporal",
+            title: "Time",
+            axis: {
+              format: "%H:%M:%S",
+              title: "Time",
+              labelAngle: -45,
+            },
+          },
+          y: {
+            field: "cost",
+            type: "quantitative",
+            title: "Cumulative Cost (USD)",
+            axis: {
+              format: "$,.4f",
+            },
+          },
+          tooltip: [
+            {
+              field: "timestamp",
+              type: "temporal",
+              title: "Time",
+              format: "%Y-%m-%d %H:%M:%S",
+            },
+            {
+              field: "cost",
+              type: "quantitative",
+              title: "Cumulative Cost",
+              format: "$,.4f",
+            },
+          ],
+        },
+      };
+
+      // Render the cost chart
+      await vegaEmbed(costChartContainer, costSpec, {
+        actions: true,
+        renderer: "svg",
+      });
+    } catch (error) {
+      console.error("Error rendering dollar usage chart:", error);
+      costChartContainer.innerHTML = `<p>Error rendering dollar usage chart: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
+    }
+  }
+
+  /**
+   * Render the messages timeline chart using Vega-Lite
+   */
+  private async renderMessagesChart(): Promise<void> {
+    const messagesChartContainer = document.getElementById("messagesChart");
+    if (!messagesChartContainer) return;
+
+    try {
+      // Get all messages
+      const response = await fetch("messages");
+      if (!response.ok) {
+        throw new Error(`Failed to fetch messages: ${response.status}`);
+      }
+
+      const allMessages = await response.json();
+      if (!Array.isArray(allMessages) || allMessages.length === 0) {
+        messagesChartContainer.innerHTML =
+          "<p>No messages available to display.</p>";
+        return;
+      }
+
+      // Sort messages chronologically
+      allMessages.sort((a, b) => {
+        const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
+        const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
+        return dateA - dateB;
+      });
+
+      // Create unique indexes for all messages
+      const messageIndexMap = new Map<string, number>();
+      allMessages.forEach((msg, index) => {
+        // Create a unique ID for each message to track its position
+        const msgId = msg.timestamp ? msg.timestamp.toString() : `msg-${index}`;
+        messageIndexMap.set(msgId, index);
+      });
+
+      // Prepare data for messages with start_time and end_time (bar marks)
+      const barData = allMessages
+        .filter((msg) => msg.start_time && msg.end_time) // Only include messages with explicit start and end times
+        .map((msg) => {
+          // Parse start and end times
+          const startTime = new Date(msg.start_time!);
+          const endTime = new Date(msg.end_time!);
+
+          // Get the index for this message
+          const msgId = msg.timestamp ? msg.timestamp.toString() : "";
+          const index = messageIndexMap.get(msgId) || 0;
+
+          // Truncate content for tooltip readability
+          const displayContent = msg.content
+            ? msg.content.length > 100
+              ? msg.content.substring(0, 100) + "..."
+              : msg.content
+            : "No content";
+
+          // Prepare tool input and output for tooltip if applicable
+          const toolInput = msg.input
+            ? msg.input.length > 100
+              ? msg.input.substring(0, 100) + "..."
+              : msg.input
+            : "";
+
+          const toolResult = msg.tool_result
+            ? msg.tool_result.length > 100
+              ? msg.tool_result.substring(0, 100) + "..."
+              : msg.tool_result
+            : "";
+
+          return {
+            index: index,
+            message_type: msg.type,
+            content: displayContent,
+            tool_name: msg.tool_name || "",
+            tool_input: toolInput,
+            tool_result: toolResult,
+            start_time: startTime.toISOString(),
+            end_time: endTime.toISOString(),
+            message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
+          };
+        });
+
+      // Prepare data for messages with timestamps only (point marks)
+      const pointData = allMessages
+        .filter((msg) => msg.timestamp && !(msg.start_time && msg.end_time)) // Only messages with timestamp but without start/end times
+        .map((msg) => {
+          // Get the timestamp
+          const timestamp = new Date(msg.timestamp!);
+
+          // Get the index for this message
+          const msgId = msg.timestamp ? msg.timestamp.toString() : "";
+          const index = messageIndexMap.get(msgId) || 0;
+
+          // Truncate content for tooltip readability
+          const displayContent = msg.content
+            ? msg.content.length > 100
+              ? msg.content.substring(0, 100) + "..."
+              : msg.content
+            : "No content";
+
+          // Prepare tool input and output for tooltip if applicable
+          const toolInput = msg.input
+            ? msg.input.length > 100
+              ? msg.input.substring(0, 100) + "..."
+              : msg.input
+            : "";
+
+          const toolResult = msg.tool_result
+            ? msg.tool_result.length > 100
+              ? msg.tool_result.substring(0, 100) + "..."
+              : msg.tool_result
+            : "";
+
+          return {
+            index: index,
+            message_type: msg.type,
+            content: displayContent,
+            tool_name: msg.tool_name || "",
+            tool_input: toolInput,
+            tool_result: toolResult,
+            time: timestamp.toISOString(),
+            message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
+          };
+        });
+
+      // Check if we have any data to display
+      if (barData.length === 0 && pointData.length === 0) {
+        messagesChartContainer.innerHTML =
+          "<p>No message timing data available to display.</p>";
+        return;
+      }
+
+      // Calculate height based on number of unique messages
+      const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
+
+      // Create a layered Vega-Lite spec combining bars and points
+      const messagesSpec: TopLevelSpec = {
+        $schema: "https://vega.github.io/schema/vega-lite/v5.json",
+        description: "Message Timeline",
+        width: "container",
+        height: chartHeight,
+        layer: [],
+      };
+
+      // Add bar layer if we have bar data
+      if (barData.length > 0) {
+        messagesSpec.layer.push({
+          data: { values: barData },
+          mark: {
+            type: "bar",
+            height: 16,
+          },
+          encoding: {
+            x: {
+              field: "start_time",
+              type: "temporal",
+              title: "Time",
+              axis: {
+                format: "%H:%M:%S",
+                title: "Time",
+                labelAngle: -45,
+              },
+            },
+            x2: { field: "end_time" },
+            y: {
+              field: "index",
+              type: "ordinal",
+              title: "Message Index",
+              axis: {
+                grid: true,
+              },
+            },
+            color: {
+              field: "message_type",
+              type: "nominal",
+              title: "Message Type",
+              legend: {},
+            },
+            tooltip: [
+              { field: "message_type", type: "nominal", title: "Type" },
+              { field: "tool_name", type: "nominal", title: "Tool" },
+              {
+                field: "start_time",
+                type: "temporal",
+                title: "Start Time",
+                format: "%H:%M:%S.%L",
+              },
+              {
+                field: "end_time",
+                type: "temporal",
+                title: "End Time",
+                format: "%H:%M:%S.%L",
+              },
+              { field: "content", type: "nominal", title: "Content" },
+              { field: "tool_input", type: "nominal", title: "Tool Input" },
+              { field: "tool_result", type: "nominal", title: "Tool Result" },
+            ],
+          },
+        });
+      }
+
+      // Add point layer if we have point data
+      if (pointData.length > 0) {
+        messagesSpec.layer.push({
+          data: { values: pointData },
+          mark: {
+            type: "point",
+            size: 100,
+            filled: true,
+          },
+          encoding: {
+            x: {
+              field: "time",
+              type: "temporal",
+              title: "Time",
+              axis: {
+                format: "%H:%M:%S",
+                title: "Time",
+                labelAngle: -45,
+              },
+            },
+            y: {
+              field: "index",
+              type: "ordinal",
+              title: "Message Index",
+            },
+            color: {
+              field: "message_type",
+              type: "nominal",
+              title: "Message Type",
+            },
+            tooltip: [
+              { field: "message_type", type: "nominal", title: "Type" },
+              { field: "tool_name", type: "nominal", title: "Tool" },
+              {
+                field: "time",
+                type: "temporal",
+                title: "Timestamp",
+                format: "%H:%M:%S.%L",
+              },
+              { field: "content", type: "nominal", title: "Content" },
+              { field: "tool_input", type: "nominal", title: "Tool Input" },
+              { field: "tool_result", type: "nominal", title: "Tool Result" },
+            ],
+          },
+        });
+      }
+
+      // Render the messages timeline chart
+      await vegaEmbed(messagesChartContainer, messagesSpec, {
+        actions: true,
+        renderer: "svg",
+      });
+    } catch (error) {
+      console.error("Error rendering messages chart:", error);
+      messagesChartContainer.innerHTML = `<p>Error rendering messages chart: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
+    }
+  }
+}
diff --git a/loop/webui/src/timeline/commits.ts b/loop/webui/src/timeline/commits.ts
new file mode 100644
index 0000000..f4303f2
--- /dev/null
+++ b/loop/webui/src/timeline/commits.ts
@@ -0,0 +1,90 @@
+/**
+ * Utility functions for rendering commit messages in the timeline
+ */
+
+import { escapeHTML } from "./utils";
+
+interface Commit {
+  hash: string;
+  subject: string;
+  body: string;
+  pushed_branch?: string;
+}
+
+/**
+ * Create HTML elements to display commits in the timeline
+ * @param commits List of commit information to display
+ * @param diffViewerCallback Callback function to show commit diff when requested
+ * @returns The created HTML container element with commit information
+ */
+export function createCommitsContainer(
+  commits: Commit[],
+  diffViewerCallback: (commitHash: string) => void
+): HTMLElement {
+  const commitsContainer = document.createElement("div");
+  commitsContainer.className = "commits-container";
+
+  // Create a header for commits
+  const commitsHeaderRow = document.createElement("div");
+  commitsHeaderRow.className = "commits-header";
+  commitsHeaderRow.textContent = `${commits.length} new commit${commits.length > 1 ? "s" : ""} detected`;
+  commitsContainer.appendChild(commitsHeaderRow);
+
+  // Create a row for commit boxes
+  const commitBoxesRow = document.createElement("div");
+  commitBoxesRow.className = "commit-boxes-row";
+
+  // Add each commit as a box
+  commits.forEach((commit) => {
+    // Create the commit box
+    const commitBox = document.createElement("div");
+    commitBox.className = "commit-box";
+
+    // Show commit hash and subject line as the preview
+    const commitPreview = document.createElement("div");
+    commitPreview.className = "commit-preview";
+
+    // Include pushed branch information if available
+    let previewHTML = `<span class="commit-hash">${commit.hash.substring(0, 8)}</span> ${escapeHTML(commit.subject)}`;
+    if (commit.pushed_branch) {
+      previewHTML += ` <span class="pushed-branch">→ pushed to ${escapeHTML(commit.pushed_branch)}</span>`;
+    }
+
+    commitPreview.innerHTML = previewHTML;
+    commitBox.appendChild(commitPreview);
+
+    // Create expandable view for commit details
+    const expandedView = document.createElement("div");
+    expandedView.className = "commit-details is-hidden";
+    expandedView.innerHTML = `<pre>${escapeHTML(commit.body)}</pre>`;
+    commitBox.appendChild(expandedView);
+
+    // Toggle visibility of expanded view when clicking the preview
+    commitPreview.addEventListener("click", (event) => {
+      // If holding Ctrl/Cmd key, show diff for this commit
+      if (event.ctrlKey || event.metaKey) {
+        // Call the diff viewer callback with the commit hash
+        diffViewerCallback(commit.hash);
+      } else {
+        // Normal behavior - toggle expanded view
+        expandedView.classList.toggle("is-hidden");
+      }
+    });
+    
+    // Add a diff button to view commit changes
+    const diffButton = document.createElement("button");
+    diffButton.className = "commit-diff-button";
+    diffButton.textContent = "View Changes";
+    diffButton.addEventListener("click", (event) => {
+      event.stopPropagation(); // Prevent triggering the parent click event
+      diffViewerCallback(commit.hash);
+    });
+    // Add the button directly to the commit box
+    commitBox.appendChild(diffButton);
+
+    commitBoxesRow.appendChild(commitBox);
+  });
+
+  commitsContainer.appendChild(commitBoxesRow);
+  return commitsContainer;
+}
diff --git a/loop/webui/src/timeline/components/collapsible.ts b/loop/webui/src/timeline/components/collapsible.ts
new file mode 100644
index 0000000..12f90ec
--- /dev/null
+++ b/loop/webui/src/timeline/components/collapsible.ts
@@ -0,0 +1,37 @@
+import { TimelineMessage } from "../types";
+
+/**
+ * Adds collapsible functionality to long content elements.
+ * This creates a toggle button that allows users to expand/collapse long text content.
+ *
+ * @param message - The timeline message containing the content
+ * @param textEl - The DOM element containing the text content
+ * @param containerEl - The container element for the text and copy button
+ * @param contentEl - The outer content element that will contain everything
+ */
+export function addCollapsibleFunctionality(
+  message: TimelineMessage,
+  textEl: HTMLElement,
+  containerEl: HTMLElement,
+  contentEl: HTMLElement
+): void {
+  // Don't collapse end_of_turn messages (final output) regardless of length
+  if (message.content.length > 1000 && !message.end_of_turn) {
+    textEl.classList.add("collapsed");
+
+    const toggleButton = document.createElement("button");
+    toggleButton.className = "collapsible";
+    toggleButton.textContent = "Show more...";
+    toggleButton.addEventListener("click", () => {
+      textEl.classList.toggle("collapsed");
+      toggleButton.textContent = textEl.classList.contains("collapsed")
+        ? "Show more..."
+        : "Show less";
+    });
+
+    contentEl.appendChild(containerEl);
+    contentEl.appendChild(toggleButton);
+  } else {
+    contentEl.appendChild(containerEl);
+  }
+}
diff --git a/loop/webui/src/timeline/copybutton.ts b/loop/webui/src/timeline/copybutton.ts
new file mode 100644
index 0000000..d9b994b
--- /dev/null
+++ b/loop/webui/src/timeline/copybutton.ts
@@ -0,0 +1,44 @@
+/**
+ * Creates a copy button container with a functioning copy button
+ */
+export function createCopyButton(textToCopy: string): {
+  container: HTMLDivElement;
+  button: HTMLButtonElement;
+} {
+  // Create container for the copy button
+  const copyButtonContainer = document.createElement("div");
+  copyButtonContainer.className = "message-actions";
+
+  // Create the copy button itself
+  const copyButton = document.createElement("button");
+  copyButton.className = "copy-button";
+  copyButton.textContent = "Copy";
+  copyButton.title = "Copy text to clipboard";
+  
+  // Add click event listener to handle copying
+  copyButton.addEventListener("click", (e) => {
+    e.stopPropagation();
+    navigator.clipboard
+      .writeText(textToCopy)
+      .then(() => {
+        copyButton.textContent = "Copied!";
+        setTimeout(() => {
+          copyButton.textContent = "Copy";
+        }, 2000);
+      })
+      .catch((err) => {
+        console.error("Failed to copy text: ", err);
+        copyButton.textContent = "Failed";
+        setTimeout(() => {
+          copyButton.textContent = "Copy";
+        }, 2000);
+      });
+  });
+
+  copyButtonContainer.appendChild(copyButton);
+  
+  return {
+    container: copyButtonContainer,
+    button: copyButton
+  };
+}
diff --git a/loop/webui/src/timeline/data.ts b/loop/webui/src/timeline/data.ts
new file mode 100644
index 0000000..2130c21
--- /dev/null
+++ b/loop/webui/src/timeline/data.ts
@@ -0,0 +1,379 @@
+import { TimelineMessage } from "./types";
+import { formatNumber } from "./utils";
+
+/**
+ * Event types for data manager
+ */
+export type DataManagerEventType = 'dataChanged' | 'connectionStatusChanged';
+
+/**
+ * Connection status types
+ */
+export type ConnectionStatus = 'connected' | 'disconnected' | 'disabled';
+
+/**
+ * State interface
+ */
+export interface TimelineState {
+  hostname?: string;
+  working_dir?: string;
+  initial_commit?: string;
+  message_count?: number;
+  title?: string;
+  total_usage?: {
+    input_tokens: number;
+    output_tokens: number;
+    cache_read_input_tokens: number;
+    cache_creation_input_tokens: number;
+    total_cost_usd: number;
+  };
+}
+
+/**
+ * DataManager - Class to manage timeline data, fetching, and polling
+ */
+export class DataManager {
+  // State variables
+  private lastMessageCount: number = 0;
+  private nextFetchIndex: number = 0;
+  private currentFetchStartIndex: number = 0;
+  private currentPollController: AbortController | null = null;
+  private isFetchingMessages: boolean = false;
+  private isPollingEnabled: boolean = true;
+  private isFirstLoad: boolean = true;
+  private connectionStatus: ConnectionStatus = "disabled";
+  private messages: TimelineMessage[] = [];
+  private timelineState: TimelineState | null = null;
+  
+  // Event listeners
+  private eventListeners: Map<DataManagerEventType, Array<(...args: any[]) => void>> = new Map();
+
+  constructor() {
+    // Initialize empty arrays for each event type
+    this.eventListeners.set('dataChanged', []);
+    this.eventListeners.set('connectionStatusChanged', []);
+  }
+
+  /**
+   * Initialize the data manager and fetch initial data
+   */
+  public async initialize(): Promise<void> {
+    try {
+      // Initial data fetch
+      await this.fetchData();
+      // Start polling for updates only if initial fetch succeeds
+      this.startPolling();
+    } catch (error) {
+      console.error("Initial data fetch failed, will retry via polling", error);
+      // Still start polling to recover
+      this.startPolling();
+    }
+  }
+
+  /**
+   * Get all messages
+   */
+  public getMessages(): TimelineMessage[] {
+    return this.messages;
+  }
+
+  /**
+   * Get the current state
+   */
+  public getState(): TimelineState | null {
+    return this.timelineState;
+  }
+
+  /**
+   * Get the connection status
+   */
+  public getConnectionStatus(): ConnectionStatus {
+    return this.connectionStatus;
+  }
+
+  /**
+   * Get the isFirstLoad flag
+   */
+  public getIsFirstLoad(): boolean {
+    return this.isFirstLoad;
+  }
+
+  /**
+   * Get the currentFetchStartIndex
+   */
+  public getCurrentFetchStartIndex(): number {
+    return this.currentFetchStartIndex;
+  }
+
+  /**
+   * Add an event listener
+   */
+  public addEventListener(event: DataManagerEventType, callback: (...args: any[]) => void): void {
+    const listeners = this.eventListeners.get(event) || [];
+    listeners.push(callback);
+    this.eventListeners.set(event, listeners);
+  }
+
+  /**
+   * Remove an event listener
+   */
+  public removeEventListener(event: DataManagerEventType, callback: (...args: any[]) => void): void {
+    const listeners = this.eventListeners.get(event) || [];
+    const index = listeners.indexOf(callback);
+    if (index !== -1) {
+      listeners.splice(index, 1);
+      this.eventListeners.set(event, listeners);
+    }
+  }
+
+  /**
+   * Emit an event
+   */
+  private emitEvent(event: DataManagerEventType, ...args: any[]): void {
+    const listeners = this.eventListeners.get(event) || [];
+    listeners.forEach(callback => callback(...args));
+  }
+
+  /**
+   * Set polling enabled/disabled state
+   */
+  public setPollingEnabled(enabled: boolean): void {
+    this.isPollingEnabled = enabled;
+    
+    if (enabled) {
+      this.startPolling();
+    } else {
+      this.stopPolling();
+    }
+  }
+
+  /**
+   * Start polling for updates
+   */
+  public startPolling(): void {
+    this.stopPolling(); // Stop any existing polling
+    
+    // Start long polling
+    this.longPoll();
+  }
+
+  /**
+   * Stop polling for updates
+   */
+  public stopPolling(): void {
+    // Abort any ongoing long poll request
+    if (this.currentPollController) {
+      this.currentPollController.abort();
+      this.currentPollController = null;
+    }
+    
+    // If polling is disabled by user, set connection status to disabled
+    if (!this.isPollingEnabled) {
+      this.updateConnectionStatus("disabled");
+    }
+  }
+
+  /**
+   * Update the connection status
+   */
+  private updateConnectionStatus(status: ConnectionStatus): void {
+    if (this.connectionStatus !== status) {
+      this.connectionStatus = status;
+      this.emitEvent('connectionStatusChanged', status);
+    }
+  }
+
+  /**
+   * Long poll for updates
+   */
+  private async longPoll(): Promise<void> {
+    // Abort any existing poll request
+    if (this.currentPollController) {
+      this.currentPollController.abort();
+      this.currentPollController = null;
+    }
+
+    // If polling is disabled, don't start a new poll
+    if (!this.isPollingEnabled) {
+      return;
+    }
+
+    let timeoutId: number | undefined;
+
+    try {
+      // Create a new abort controller for this request
+      this.currentPollController = new AbortController();
+      const signal = this.currentPollController.signal;
+
+      // Get the URL with the current message count
+      const pollUrl = `state?poll=true&seen=${this.lastMessageCount}`;
+
+      // Make the long poll request
+      // Use explicit timeout to handle stalled connections (120s)
+      const controller = new AbortController();
+      timeoutId = window.setTimeout(() => controller.abort(), 120000);
+
+      interface CustomFetchOptions extends RequestInit {
+        [Symbol.toStringTag]?: unknown;
+      }
+
+      const fetchOptions: CustomFetchOptions = {
+        signal: controller.signal,
+        // Use the original signal to allow manual cancellation too
+        get [Symbol.toStringTag]() {
+          if (signal.aborted) controller.abort();
+          return "";
+        },
+      };
+
+      try {
+        const response = await fetch(pollUrl, fetchOptions);
+        // Clear the timeout since we got a response
+        clearTimeout(timeoutId);
+
+        // Parse the JSON response
+        const _data = await response.json();
+
+        // If we got here, data has changed, so fetch the latest data
+        await this.fetchData();
+
+        // Start a new long poll (if polling is still enabled)
+        if (this.isPollingEnabled) {
+          this.longPoll();
+        }
+      } catch (error) {
+        // Handle fetch errors inside the inner try block
+        clearTimeout(timeoutId);
+        throw error; // Re-throw to be caught by the outer catch block
+      }
+    } catch (error: unknown) {
+      // Clean up timeout if we're handling an error
+      if (timeoutId) clearTimeout(timeoutId);
+
+      // Don't log or treat manual cancellations as errors
+      const isErrorWithName = (
+        err: unknown,
+      ): err is { name: string; message?: string } =>
+        typeof err === "object" && err !== null && "name" in err;
+
+      if (
+        isErrorWithName(error) &&
+        error.name === "AbortError" &&
+        this.currentPollController?.signal.aborted
+      ) {
+        console.log("Polling cancelled by user");
+        return;
+      }
+
+      // Handle different types of errors with specific messages
+      let errorMessage = "Not connected";
+
+      if (isErrorWithName(error)) {
+        if (error.name === "AbortError") {
+          // This was our timeout abort
+          errorMessage = "Connection timeout - not connected";
+          console.error("Long polling timeout");
+        } else if (error.name === "SyntaxError") {
+          // JSON parsing error
+          errorMessage = "Invalid response from server - not connected";
+          console.error("JSON parsing error:", error);
+        } else if (
+          error.name === "TypeError" &&
+          error.message?.includes("NetworkError")
+        ) {
+          // Network connectivity issues
+          errorMessage = "Network connection lost - not connected";
+          console.error("Network error during polling:", error);
+        } else {
+          // Generic error
+          console.error("Long polling error:", error);
+        }
+      }
+
+      // Disable polling on error
+      this.isPollingEnabled = false;
+
+      // Update connection status to disconnected
+      this.updateConnectionStatus("disconnected");
+
+      // Emit an event that we're disconnected with the error message
+      this.emitEvent('connectionStatusChanged', this.connectionStatus, errorMessage);
+    }
+  }
+
+  /**
+   * Fetch timeline data
+   */
+  public async fetchData(): Promise<void> {    
+    // If we're already fetching messages, don't start another fetch
+    if (this.isFetchingMessages) {
+      console.log("Already fetching messages, skipping request");
+      return;
+    }
+
+    this.isFetchingMessages = true;
+
+    try {
+      // Fetch state first
+      const stateResponse = await fetch("state");
+      const state = await stateResponse.json();
+      this.timelineState = state;
+
+      // Check if new messages are available
+      if (
+        state.message_count === this.lastMessageCount &&
+        this.lastMessageCount > 0
+      ) {
+        // No new messages, early return
+        this.isFetchingMessages = false;
+        this.emitEvent('dataChanged', { state, newMessages: [] });
+        return;
+      }
+
+      // Fetch messages with a start parameter
+      this.currentFetchStartIndex = this.nextFetchIndex;
+      const messagesResponse = await fetch(
+        `messages?start=${this.nextFetchIndex}`,
+      );
+      const newMessages = await messagesResponse.json() || [];
+
+      // Store messages in our array
+      if (this.nextFetchIndex === 0) {
+        // If this is the first fetch, replace the entire array
+        this.messages = [...newMessages];
+      } else {
+        // Otherwise append the new messages
+        this.messages = [...this.messages, ...newMessages];
+      }
+
+      // Update connection status to connected
+      this.updateConnectionStatus("connected");
+
+      // Update the last message index for next fetch
+      if (newMessages && newMessages.length > 0) {
+        this.nextFetchIndex += newMessages.length;
+      }
+
+      // Update the message count
+      this.lastMessageCount = state?.message_count ?? 0;
+
+      // Mark that we've completed first load
+      if (this.isFirstLoad) {
+        this.isFirstLoad = false;
+      }
+
+      // Emit an event that data has changed
+      this.emitEvent('dataChanged', { state, newMessages, isFirstFetch: this.nextFetchIndex === newMessages.length });
+    } catch (error) {
+      console.error("Error fetching data:", error);
+
+      // Update connection status to disconnected
+      this.updateConnectionStatus("disconnected");
+
+      // Emit an event that we're disconnected
+      this.emitEvent('connectionStatusChanged', this.connectionStatus, "Not connected");
+    } finally {
+      this.isFetchingMessages = false;
+    }
+  }
+}
diff --git a/loop/webui/src/timeline/diffviewer.ts b/loop/webui/src/timeline/diffviewer.ts
new file mode 100644
index 0000000..1460dc3
--- /dev/null
+++ b/loop/webui/src/timeline/diffviewer.ts
@@ -0,0 +1,384 @@
+import * as Diff2Html from "diff2html";
+
+/**
+ * Class to handle diff and commit viewing functionality in the timeline UI.
+ */
+export class DiffViewer {
+  // Current commit hash being viewed
+  private currentCommitHash: string = "";
+  // Selected line in the diff for commenting
+  private selectedDiffLine: string | null = null;
+  // Current view mode (needed for integration with TimelineManager)
+  private viewMode: string = "chat";
+
+  /**
+   * Constructor for DiffViewer
+   */
+  constructor() {}
+
+  /**
+   * Sets the current view mode
+   * @param mode The current view mode
+   */
+  public setViewMode(mode: string): void {
+    this.viewMode = mode;
+  }
+
+  /**
+   * Gets the current commit hash
+   * @returns The current commit hash
+   */
+  public getCurrentCommitHash(): string {
+    return this.currentCommitHash;
+  }
+
+  /**
+   * Sets the current commit hash
+   * @param hash The commit hash to set
+   */
+  public setCurrentCommitHash(hash: string): void {
+    this.currentCommitHash = hash;
+  }
+
+  /**
+   * Clears the current commit hash
+   */
+  public clearCurrentCommitHash(): void {
+    this.currentCommitHash = "";
+  }
+
+  /**
+   * Loads diff content and renders it using diff2html
+   * @param commitHash Optional commit hash to load diff for
+   */
+  public async loadDiff2HtmlContent(commitHash?: string): Promise<void> {
+    const diff2htmlContent = document.getElementById("diff2htmlContent");
+    const container = document.querySelector(".timeline-container");
+    if (!diff2htmlContent || !container) return;
+
+    try {
+      // Show loading state
+      diff2htmlContent.innerHTML = "Loading enhanced diff...";
+
+      // Add classes to container to allow full-width rendering
+      container.classList.add("diff2-active");
+      container.classList.add("diff-active");
+      
+      // Use currentCommitHash if provided or passed from parameter
+      const hash = commitHash || this.currentCommitHash;
+      
+      // Build the diff URL - include commit hash if specified
+      const diffUrl = hash ? `diff?commit=${hash}` : "diff";
+      
+      // Fetch the diff from the server
+      const response = await fetch(diffUrl);
+
+      if (!response.ok) {
+        throw new Error(
+          `Server returned ${response.status}: ${response.statusText}`,
+        );
+      }
+
+      const diffText = await response.text();
+
+      if (!diffText || diffText.trim() === "") {
+        diff2htmlContent.innerHTML =
+          "<span style='color: #666; font-style: italic;'>No changes detected since conversation started.</span>";
+        return;
+      }
+
+      // Get the selected view format
+      const formatRadios = document.getElementsByName("diffViewFormat") as NodeListOf<HTMLInputElement>;
+      let outputFormat = "side-by-side"; // default
+      
+      // Convert NodeListOf to Array to ensure [Symbol.iterator]() is available
+      Array.from(formatRadios).forEach(radio => {
+        if (radio.checked) {
+          outputFormat = radio.value as "side-by-side" | "line-by-line";
+        }
+      })
+      
+      // Render the diff using diff2html
+      const diffHtml = Diff2Html.html(diffText, {
+        outputFormat: outputFormat as "side-by-side" | "line-by-line",
+        drawFileList: true,
+        matching: "lines",
+        // Make sure no unnecessary scrollbars in the nested containers
+        renderNothingWhenEmpty: false,
+        colorScheme: "light" as any, // Force light mode to match the rest of the UI
+      });
+
+      // Insert the generated HTML
+      diff2htmlContent.innerHTML = diffHtml;
+
+      // Add CSS styles to ensure we don't have double scrollbars
+      const d2hFiles = diff2htmlContent.querySelectorAll(".d2h-file-wrapper");
+      d2hFiles.forEach((file) => {
+        const contentElem = file.querySelector(".d2h-files-diff");
+        if (contentElem) {
+          // Remove internal scrollbar - the outer container will handle scrolling
+          (contentElem as HTMLElement).style.overflow = "visible";
+          (contentElem as HTMLElement).style.maxHeight = "none";
+        }
+      });
+
+      // Add click event handlers to each code line for commenting
+      this.setupDiff2LineComments();
+      
+      // Setup event listeners for diff view format radio buttons
+      this.setupDiffViewFormatListeners();
+    } catch (error) {
+      console.error("Error loading diff2html content:", error);
+      const errorMessage =
+        error instanceof Error ? error.message : "Unknown error";
+      diff2htmlContent.innerHTML = `<span style='color: #dc3545;'>Error loading enhanced diff: ${errorMessage}</span>`;
+    }
+  }
+
+  /**
+   * Setup event listeners for diff view format radio buttons
+   */
+  private setupDiffViewFormatListeners(): void {
+    const formatRadios = document.getElementsByName("diffViewFormat") as NodeListOf<HTMLInputElement>;
+    
+    // Convert NodeListOf to Array to ensure [Symbol.iterator]() is available
+    Array.from(formatRadios).forEach(radio => {
+      radio.addEventListener("change", () => {
+        // Reload the diff with the new format when radio selection changes
+        this.loadDiff2HtmlContent(this.currentCommitHash);
+      });
+    })
+  }
+  
+  /**
+   * Setup handlers for diff2 code lines to enable commenting
+   */
+  private setupDiff2LineComments(): void {
+    const diff2htmlContent = document.getElementById("diff2htmlContent");
+    if (!diff2htmlContent) return;
+
+    console.log("Setting up diff2 line comments");
+
+    // Add plus buttons to each code line
+    this.addCommentButtonsToCodeLines();
+
+    // Use event delegation for handling clicks on plus buttons
+    diff2htmlContent.addEventListener("click", (event) => {
+      const target = event.target as HTMLElement;
+      
+      // Only respond to clicks on the plus button
+      if (target.classList.contains("d2h-gutter-comment-button")) {
+        // Find the parent row first
+        const row = target.closest("tr");
+        if (!row) return;
+        
+        // Then find the code line in that row
+        const codeLine = row.querySelector(".d2h-code-side-line") || row.querySelector(".d2h-code-line");
+        if (!codeLine) return;
+
+        // Get the line text content
+        const lineContent = codeLine.querySelector(".d2h-code-line-ctn");
+        if (!lineContent) return;
+
+        const lineText = lineContent.textContent?.trim() || "";
+
+        // Get file name to add context
+        const fileHeader = codeLine
+          .closest(".d2h-file-wrapper")
+          ?.querySelector(".d2h-file-name");
+        const fileName = fileHeader
+          ? fileHeader.textContent?.trim()
+          : "Unknown file";
+
+        // Get line number if available
+        const lineNumElem = codeLine
+          .closest("tr")
+          ?.querySelector(".d2h-code-side-linenumber");
+        const lineNum = lineNumElem ? lineNumElem.textContent?.trim() : "";
+        const lineInfo = lineNum ? `Line ${lineNum}: ` : "";
+
+        // Format the line for the comment box with file context and line number
+        const formattedLine = `${fileName} ${lineInfo}${lineText}`;
+
+        console.log("Comment button clicked for line: ", formattedLine);
+
+        // Open the comment box with this line
+        this.openDiffCommentBox(formattedLine, 0);
+
+        // Prevent event from bubbling up
+        event.stopPropagation();
+      }
+    });
+
+    // Handle text selection
+    let isSelecting = false;
+    
+    diff2htmlContent.addEventListener("mousedown", () => {
+      isSelecting = false;
+    });
+    
+    diff2htmlContent.addEventListener("mousemove", (event) => {
+      // If mouse is moving with button pressed, user is selecting text
+      if (event.buttons === 1) { // Primary button (usually left) is pressed
+        isSelecting = true;
+      }
+    });
+  }
+
+  /**
+   * Add plus buttons to each table row in the diff for commenting
+   */
+  private addCommentButtonsToCodeLines(): void {
+    const diff2htmlContent = document.getElementById("diff2htmlContent");
+    if (!diff2htmlContent) return;
+    
+    // Target code lines first, then find their parent rows
+    const codeLines = diff2htmlContent.querySelectorAll(
+      ".d2h-code-side-line, .d2h-code-line"
+    );
+    
+    // Create a Set to store unique rows to avoid duplicates
+    const rowsSet = new Set<HTMLElement>();
+    
+    // Get all rows that contain code lines
+    codeLines.forEach(line => {
+      const row = line.closest('tr');
+      if (row) rowsSet.add(row as HTMLElement);
+    });
+    
+    // Convert Set back to array for processing
+    const codeRows = Array.from(rowsSet);
+    
+    codeRows.forEach((row) => {
+      const rowElem = row as HTMLElement;
+      
+      // Skip info lines without actual code (e.g., "file added")
+      if (rowElem.querySelector(".d2h-info")) {
+        return;
+      }
+      
+      // Find the code line number element (first TD in the row)
+      const lineNumberCell = rowElem.querySelector(
+        ".d2h-code-side-linenumber, .d2h-code-linenumber"
+      );
+      
+      if (!lineNumberCell) return;
+      
+      // Create the plus button
+      const plusButton = document.createElement("span");
+      plusButton.className = "d2h-gutter-comment-button";
+      plusButton.innerHTML = "+";
+      plusButton.title = "Add a comment on this line";
+      
+      // Add button to the line number cell for proper positioning
+      (lineNumberCell as HTMLElement).style.position = "relative"; // Ensure positioning context
+      lineNumberCell.appendChild(plusButton);
+    });
+  }
+
+  /**
+   * Open the comment box for a selected diff line
+   */
+  private openDiffCommentBox(lineText: string, _lineNumber: number): void {
+    const commentBox = document.getElementById("diffCommentBox");
+    const selectedLine = document.getElementById("selectedLine");
+    const commentInput = document.getElementById(
+      "diffCommentInput",
+    ) as HTMLTextAreaElement;
+
+    if (!commentBox || !selectedLine || !commentInput) return;
+
+    // Store the selected line
+    this.selectedDiffLine = lineText;
+
+    // Display the line in the comment box
+    selectedLine.textContent = lineText;
+
+    // Reset the comment input
+    commentInput.value = "";
+
+    // Show the comment box
+    commentBox.style.display = "block";
+
+    // Focus on the comment input
+    commentInput.focus();
+
+    // Add event listeners for submit and cancel buttons
+    const submitButton = document.getElementById("submitDiffComment");
+    if (submitButton) {
+      submitButton.onclick = () => this.submitDiffComment();
+    }
+
+    const cancelButton = document.getElementById("cancelDiffComment");
+    if (cancelButton) {
+      cancelButton.onclick = () => this.closeDiffCommentBox();
+    }
+  }
+
+  /**
+   * Close the diff comment box without submitting
+   */
+  private closeDiffCommentBox(): void {
+    const commentBox = document.getElementById("diffCommentBox");
+    if (commentBox) {
+      commentBox.style.display = "none";
+    }
+    this.selectedDiffLine = null;
+  }
+
+  /**
+   * Submit a comment on a diff line
+   */
+  private submitDiffComment(): void {
+    const commentInput = document.getElementById(
+      "diffCommentInput",
+    ) as HTMLTextAreaElement;
+    const chatInput = document.getElementById(
+      "chatInput",
+    ) as HTMLTextAreaElement;
+
+    if (!commentInput || !chatInput) return;
+
+    const comment = commentInput.value.trim();
+
+    // Validate inputs
+    if (!this.selectedDiffLine || !comment) {
+      alert("Please select a line and enter a comment.");
+      return;
+    }
+
+    // Format the comment in a readable way
+    const formattedComment = `\`\`\`\n${this.selectedDiffLine}\n\`\`\`\n\n${comment}`;
+
+    // Append the formatted comment to the chat textarea
+    if (chatInput.value.trim() !== "") {
+      chatInput.value += "\n\n"; // Add two line breaks before the new comment
+    }
+    chatInput.value += formattedComment;
+    chatInput.focus();
+
+    // Close only the comment box but keep the diff view open
+    this.closeDiffCommentBox();
+  }
+
+  /**
+   * Show diff for a specific commit
+   * @param commitHash The commit hash to show diff for
+   * @param toggleViewModeCallback Callback to toggle view mode to diff
+   */
+  public showCommitDiff(commitHash: string, toggleViewModeCallback: (mode: string) => void): void {
+    // Store the commit hash
+    this.currentCommitHash = commitHash;
+    
+    // Switch to diff2 view (side-by-side)
+    toggleViewModeCallback("diff2");
+  }
+
+  /**
+   * Clean up resources when component is destroyed
+   */
+  public dispose(): void {
+    // Clean up any resources or event listeners here
+    // Currently there are no specific resources to clean up
+  }
+}
diff --git a/loop/webui/src/timeline/icons/index.ts b/loop/webui/src/timeline/icons/index.ts
new file mode 100644
index 0000000..d9480c5
--- /dev/null
+++ b/loop/webui/src/timeline/icons/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Get the icon text to display for a message type
+ * @param type - The message type
+ * @returns The single character to represent this message type
+ */
+export function getIconText(type: string | null | undefined): string {
+  switch (type) {
+    case "user":
+      return "U";
+    case "agent":
+      return "A";
+    case "tool":
+      return "T";
+    case "error":
+      return "E";
+    default:
+      return "?";
+  }
+}
diff --git a/loop/webui/src/timeline/index.ts b/loop/webui/src/timeline/index.ts
new file mode 100644
index 0000000..a3d24b7
--- /dev/null
+++ b/loop/webui/src/timeline/index.ts
@@ -0,0 +1,24 @@
+// Export types
+export * from './types';
+
+// Export utility functions
+export * from './utils';
+
+// Export terminal handler
+export * from './terminal';
+
+// Export diff viewer
+export * from './diffviewer';
+
+// Export chart manager
+export * from './charts';
+
+// Export tool call utilities
+export * from './toolcalls';
+
+// Export copy button utilities
+export * from './copybutton';
+
+// Re-export the timeline manager (will be implemented later)
+// For now, we'll maintain backward compatibility by importing from the original file
+import '../timeline';
diff --git a/loop/webui/src/timeline/markdown/renderer.ts b/loop/webui/src/timeline/markdown/renderer.ts
new file mode 100644
index 0000000..8199b69
--- /dev/null
+++ b/loop/webui/src/timeline/markdown/renderer.ts
@@ -0,0 +1,40 @@
+import { marked } from "marked";
+
+/**
+ * Renders markdown content as HTML with proper security handling.
+ *
+ * @param markdownContent - The markdown string to render
+ * @returns The rendered HTML content as a string
+ */
+export async function renderMarkdown(markdownContent: string): Promise<string> {
+  try {
+    // Set markdown options for proper code block highlighting and safety
+    const markedOptions = {
+      gfm: true, // GitHub Flavored Markdown
+      breaks: true, // Convert newlines to <br>
+      headerIds: false, // Disable header IDs for safety
+      mangle: false, // Don't mangle email addresses
+      // DOMPurify is recommended for production, but not included in this implementation
+    };
+
+    return await marked.parse(markdownContent, markedOptions);
+  } catch (error) {
+    console.error("Error rendering markdown:", error);
+    // Fallback to plain text if markdown parsing fails
+    return markdownContent;
+  }
+}
+
+/**
+ * Process rendered markdown HTML element, adding security attributes to links.
+ *
+ * @param element - The HTML element containing rendered markdown
+ */
+export function processRenderedMarkdown(element: HTMLElement): void {
+  // Make sure links open in a new tab and have proper security attributes
+  const links = element.querySelectorAll("a");
+  links.forEach((link) => {
+    link.setAttribute("target", "_blank");
+    link.setAttribute("rel", "noopener noreferrer");
+  });
+}
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;
+  }
+}
diff --git a/loop/webui/src/timeline/scroll.ts b/loop/webui/src/timeline/scroll.ts
new file mode 100644
index 0000000..df3b8f9
--- /dev/null
+++ b/loop/webui/src/timeline/scroll.ts
@@ -0,0 +1,40 @@
+/**
+ * Check if the page should scroll to the bottom based on current view position
+ * @param isFirstLoad If this is the first load of the timeline
+ * @returns Boolean indicating if we should scroll to the bottom
+ */
+export function checkShouldScroll(isFirstLoad: boolean): boolean {
+  // Always scroll on first load
+  if (isFirstLoad) {
+    return true;
+  }
+
+  // Check if user is already near the bottom of the page
+  // Account for the fixed top bar and chat bar
+  return (
+    window.innerHeight + window.scrollY >= document.body.offsetHeight - 200
+  );
+}
+
+/**
+ * Scroll to the bottom of the timeline if shouldScrollToBottom is true
+ * @param shouldScrollToBottom Flag indicating if we should scroll
+ */
+export function scrollToBottom(shouldScrollToBottom: boolean): void {
+  // Find the timeline container
+  const timeline = document.getElementById("timeline");
+
+  // Scroll the window to the bottom based on our pre-determined value
+  if (timeline && shouldScrollToBottom) {
+    // Get the last message or element in the timeline
+    const lastElement = timeline.lastElementChild;
+
+    if (lastElement) {
+      // Scroll to the bottom of the page
+      window.scrollTo({
+        top: document.body.scrollHeight,
+        behavior: "smooth",
+      });
+    }
+  }
+}
diff --git a/loop/webui/src/timeline/terminal.ts b/loop/webui/src/timeline/terminal.ts
new file mode 100644
index 0000000..fbe9a7d
--- /dev/null
+++ b/loop/webui/src/timeline/terminal.ts
@@ -0,0 +1,269 @@
+import { Terminal } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+
+/**
+ * Class to handle terminal functionality in the timeline UI.
+ */
+export class TerminalHandler {
+  // Terminal instance
+  private terminal: Terminal | null = null;
+  // Terminal fit addon for handling resize
+  private fitAddon: FitAddon | null = null;
+  // Terminal EventSource for SSE
+  private terminalEventSource: EventSource | null = null;
+  // Terminal ID (always 1 for now, will support 1-9 later)
+  private terminalId: string = "1";
+  // Queue for serializing terminal inputs
+  private terminalInputQueue: string[] = [];
+  // Flag to track if we're currently processing a terminal input
+  private processingTerminalInput: boolean = false;
+  // Current view mode (needed for resize handling)
+  private viewMode: string = "chat";
+
+  /**
+   * Constructor for TerminalHandler
+   */
+  constructor() {}
+
+  /**
+   * Sets the current view mode
+   * @param mode The current view mode
+   */
+  public setViewMode(mode: string): void {
+    this.viewMode = mode;
+  }
+
+  /**
+   * Initialize the terminal component
+   * @param terminalContainer The DOM element to contain the terminal
+   */
+  public async initializeTerminal(): Promise<void> {
+    const terminalContainer = document.getElementById("terminalContainer");
+
+    if (!terminalContainer) {
+      console.error("Terminal container not found");
+      return;
+    }
+
+    // If terminal is already initialized, just focus it
+    if (this.terminal) {
+      this.terminal.focus();
+      if (this.fitAddon) {
+        this.fitAddon.fit();
+      }
+      return;
+    }
+
+    // Clear the terminal container
+    terminalContainer.innerHTML = "";
+
+    // Create new terminal instance
+    this.terminal = new Terminal({
+      cursorBlink: true,
+      theme: {
+        background: "#f5f5f5",
+        foreground: "#333333",
+        cursor: "#0078d7",
+        selectionBackground: "rgba(0, 120, 215, 0.4)",
+      },
+    });
+
+    // Add fit addon to handle terminal resizing
+    this.fitAddon = new FitAddon();
+    this.terminal.loadAddon(this.fitAddon);
+
+    // Open the terminal in the container
+    this.terminal.open(terminalContainer);
+
+    // Connect to WebSocket
+    await this.connectTerminal();
+
+    // Fit the terminal to the container
+    this.fitAddon.fit();
+
+    // Setup resize handler
+    window.addEventListener("resize", () => {
+      if (this.viewMode === "terminal" && this.fitAddon) {
+        this.fitAddon.fit();
+        // Send resize information to server
+        this.sendTerminalResize();
+      }
+    });
+
+    // Focus the terminal
+    this.terminal.focus();
+  }
+
+  /**
+   * Connect to terminal events stream
+   */
+  private async connectTerminal(): Promise<void> {
+    if (!this.terminal) {
+      return;
+    }
+
+    // Close existing connections if any
+    this.closeTerminalConnections();
+
+    try {
+      // Connect directly to the SSE endpoint for terminal 1
+      // Use relative URL based on current location
+      const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+      const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
+      this.terminalEventSource = new EventSource(eventsUrl);
+      
+      // Handle SSE events
+      this.terminalEventSource.onopen = () => {
+        console.log("Terminal SSE connection opened");
+        this.sendTerminalResize();
+      };
+      
+      this.terminalEventSource.onmessage = (event) => {
+        if (this.terminal) {
+          // Decode base64 data before writing to terminal
+          try {
+            const decoded = atob(event.data);
+            this.terminal.write(decoded);
+          } catch (e) {
+            console.error('Error decoding terminal data:', e);
+            // Fallback to raw data if decoding fails
+            this.terminal.write(event.data);
+          }
+        }
+      };
+      
+      this.terminalEventSource.onerror = (error) => {
+        console.error("Terminal SSE error:", error);
+        if (this.terminal) {
+          this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
+        }
+        // Attempt to reconnect if the connection was lost
+        if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
+          this.closeTerminalConnections();
+        }
+      };
+      
+      // Send key inputs to the server via POST requests
+      if (this.terminal) {
+        this.terminal.onData((data) => {
+          this.sendTerminalInput(data);
+        });
+      }
+    } catch (error) {
+      console.error("Failed to connect to terminal:", error);
+      if (this.terminal) {
+        this.terminal.write(`\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`);
+      }
+    }
+  }
+
+  /**
+   * Close any active terminal connections
+   */
+  private closeTerminalConnections(): void {
+    if (this.terminalEventSource) {
+      this.terminalEventSource.close();
+      this.terminalEventSource = null;
+    }
+  }
+
+  /**
+   * Send input to the terminal
+   * @param data The input data to send
+   */
+  private async sendTerminalInput(data: string): Promise<void> {
+    // Add the data to the queue
+    this.terminalInputQueue.push(data);
+    
+    // If we're not already processing inputs, start processing
+    if (!this.processingTerminalInput) {
+      await this.processTerminalInputQueue();
+    }
+  }
+
+  /**
+   * Process the terminal input queue in order
+   */
+  private async processTerminalInputQueue(): Promise<void> {
+    if (this.terminalInputQueue.length === 0) {
+      this.processingTerminalInput = false;
+      return;
+    }
+    
+    this.processingTerminalInput = true;
+    
+    // Concatenate all available inputs from the queue into a single request
+    let combinedData = '';
+    
+    // Take all currently available items from the queue
+    while (this.terminalInputQueue.length > 0) {
+      combinedData += this.terminalInputQueue.shift()!;
+    }
+    
+    try {
+      // Use relative URL based on current location
+      const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+      const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
+        method: 'POST',
+        body: combinedData,
+        headers: {
+          'Content-Type': 'text/plain'
+        }
+      });
+      
+      if (!response.ok) {
+        console.error(`Failed to send terminal input: ${response.status} ${response.statusText}`);
+      }
+    } catch (error) {
+      console.error("Error sending terminal input:", error);
+    }
+    
+    // Continue processing the queue (for any new items that may have been added)
+    await this.processTerminalInputQueue();
+  }
+
+  /**
+   * Send terminal resize information to the server
+   */
+  private async sendTerminalResize(): Promise<void> {
+    if (!this.terminal || !this.fitAddon) {
+      return;
+    }
+
+    // Get terminal dimensions
+    try {
+      // Send resize message in a format the server can understand
+      // Use relative URL based on current location
+      const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+      const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
+        method: 'POST',
+        body: JSON.stringify({
+          type: "resize",
+          cols: this.terminal.cols || 80, // Default to 80 if undefined
+          rows: this.terminal.rows || 24, // Default to 24 if undefined
+        }),
+        headers: {
+          'Content-Type': 'application/json'
+        }
+      });
+      
+      if (!response.ok) {
+        console.error(`Failed to send terminal resize: ${response.status} ${response.statusText}`);
+      }
+    } catch (error) {
+      console.error("Error sending terminal resize:", error);
+    }
+  }
+
+  /**
+   * Clean up resources when component is destroyed
+   */
+  public dispose(): void {
+    this.closeTerminalConnections();
+    if (this.terminal) {
+      this.terminal.dispose();
+      this.terminal = null;
+    }
+    this.fitAddon = null;
+  }
+}
diff --git a/loop/webui/src/timeline/toolcalls.ts b/loop/webui/src/timeline/toolcalls.ts
new file mode 100644
index 0000000..5df88bd
--- /dev/null
+++ b/loop/webui/src/timeline/toolcalls.ts
@@ -0,0 +1,259 @@
+/**
+ * Utility functions for rendering tool calls in the timeline
+ */
+
+import { ToolCall, TimelineMessage } from "./types";
+import { html, render } from "lit-html";
+
+/**
+ * Create a tool call card element for display in the timeline
+ * @param toolCall The tool call data to render
+ * @param toolResponse Optional tool response message if available
+ * @param toolCardId Unique ID for this tool card
+ * @returns The created tool card element
+ */
+export function createToolCallCard(
+  toolCall: ToolCall,
+  toolResponse?: TimelineMessage | null,
+  toolCardId?: string
+): HTMLElement {
+  // Create a unique ID for this tool card if not provided
+  const cardId =
+    toolCardId ||
+    `tool-card-${
+      toolCall.tool_call_id || Math.random().toString(36).substring(2, 11)
+    }`;
+
+  // Get input as compact string
+  let inputText = "";
+  try {
+    if (toolCall.input) {
+      const parsedInput = JSON.parse(toolCall.input);
+
+      // For bash commands, use a special format
+      if (toolCall.name === "bash" && parsedInput.command) {
+        inputText = parsedInput.command;
+      } else {
+        // For other tools, use the stringified JSON
+        inputText = JSON.stringify(parsedInput);
+      }
+    }
+  } catch (e) {
+    // Not valid JSON, use as-is
+    inputText = toolCall.input || "";
+  }
+
+  // Truncate input text for display
+  const displayInput =
+    inputText.length > 80 ? inputText.substring(0, 78) + "..." : inputText;
+
+  // Truncate for compact display
+  const shortInput =
+    displayInput.length > 30
+      ? displayInput.substring(0, 28) + "..."
+      : displayInput;
+
+  // Format input for expanded view
+  let formattedInput = displayInput;
+  try {
+    const parsedInput = JSON.parse(toolCall.input || "");
+    formattedInput = JSON.stringify(parsedInput, null, 2);
+  } catch (e) {
+    // Not valid JSON, use display input as-is
+  }
+
+  // Truncate result for compact display if available
+  let shortResult = "";
+  if (toolResponse && toolResponse.tool_result) {
+    shortResult =
+      toolResponse.tool_result.length > 40
+        ? toolResponse.tool_result.substring(0, 38) + "..."
+        : toolResponse.tool_result;
+  }
+
+  // State for collapsed/expanded view
+  let isCollapsed = true;
+
+  // Handler to copy text to clipboard
+  const copyToClipboard = (text: string, button: HTMLElement) => {
+    navigator.clipboard
+      .writeText(text)
+      .then(() => {
+        button.textContent = "Copied!";
+        setTimeout(() => {
+          button.textContent = "Copy";
+        }, 2000);
+      })
+      .catch((err) => {
+        console.error("Failed to copy text:", err);
+        button.textContent = "Failed";
+        setTimeout(() => {
+          button.textContent = "Copy";
+        }, 2000);
+      });
+  };
+
+  const cancelToolCall = async(tool_call_id: string, button: HTMLButtonElement) => {
+    console.log('cancelToolCall', tool_call_id, button);
+    button.innerText = 'Cancelling';
+    button.disabled = true;
+    try {
+      const response = await fetch("cancel", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({tool_call_id: tool_call_id, reason: "user requested cancellation" }),
+      });
+      console.log('cancel', tool_call_id, response);
+      button.parentElement.removeChild(button);
+    } catch (e) {
+      console.error('cancel', tool_call_id,e);
+    }
+  };
+
+  // Create the container element
+  const container = document.createElement("div");
+  container.id = cardId;
+  container.className = "tool-call-card collapsed";
+
+  // Function to render the component
+  const renderComponent = () => {
+    const template = html`
+      <div
+        class="tool-call-compact-view"
+        @click=${() => {
+          isCollapsed = !isCollapsed;
+          container.classList.toggle("collapsed");
+          renderComponent();
+        }}
+      >
+        <span class="tool-call-status ${toolResponse ? "" : "spinner"}">
+          ${toolResponse ? (toolResponse.tool_error ? "❌" : "✅") : "⏳"}
+        </span>
+        <span class="tool-call-name">${toolCall.name}</span>
+        <code class="tool-call-input-preview">${shortInput}</code>
+        ${toolResponse && toolResponse.tool_result
+          ? html`<code class="tool-call-result-preview">${shortResult}</code>`
+          : ""}
+        ${toolResponse && toolResponse.elapsed !== undefined
+          ? html`<span class="tool-call-time"
+              >${(toolResponse.elapsed / 1e9).toFixed(2)}s</span
+            >`
+          : ""}
+          ${toolResponse ? "" : 
+            html`<button class="refresh-button stop-button" title="Cancel this operation" @click=${(e: Event) => {
+                e.stopPropagation(); // Don't toggle expansion when clicking cancel
+                const button = e.target as HTMLButtonElement;
+                cancelToolCall(toolCall.tool_call_id, button);
+              }}>Cancel</button>`}
+        <span class="tool-call-expand-icon">${isCollapsed ? "▼" : "▲"}</span>
+      </div>
+
+      <div class="tool-call-expanded-view">
+        <div class="tool-call-section">
+          <div class="tool-call-section-label">
+            Input:
+            <button
+              class="tool-call-copy-btn"
+              title="Copy input to clipboard"
+              @click=${(e: Event) => {
+                e.stopPropagation(); // Don't toggle expansion when clicking copy
+                const button = e.target as HTMLElement;
+                copyToClipboard(toolCall.input || displayInput, button);
+              }}
+            >
+              Copy
+            </button>
+          </div>
+          <div class="tool-call-section-content">
+            <pre class="tool-call-input">${formattedInput}</pre>
+          </div>
+        </div>
+
+        ${toolResponse && toolResponse.tool_result
+          ? html`
+              <div class="tool-call-section">
+                <div class="tool-call-section-label">
+                  Result:
+                  <button
+                    class="tool-call-copy-btn"
+                    title="Copy result to clipboard"
+                    @click=${(e: Event) => {
+                      e.stopPropagation(); // Don't toggle expansion when clicking copy
+                      const button = e.target as HTMLElement;
+                      copyToClipboard(toolResponse.tool_result || "", button);
+                    }}
+                  >
+                    Copy
+                  </button>
+                </div>
+                <div class="tool-call-section-content">
+                  <div class="tool-call-result">
+                    ${toolResponse.tool_result.includes("\n")
+                      ? html`<pre><code>${toolResponse.tool_result}</code></pre>`
+                      : toolResponse.tool_result}
+                  </div>
+                </div>
+              </div>
+            `
+          : ""}
+      </div>
+    `;
+
+    render(template, container);
+  };
+
+  // Initial render
+  renderComponent();
+
+  return container;
+}
+
+/**
+ * Update a tool call card with response data
+ * @param toolCard The tool card element to update
+ * @param toolMessage The tool response message
+ */
+export function updateToolCallCard(
+  toolCard: HTMLElement,
+  toolMessage: TimelineMessage
+): void {
+  if (!toolCard) return;
+
+  // Find the original tool call data to reconstruct the card
+  const toolName = toolCard.querySelector(".tool-call-name")?.textContent || "";
+  const inputPreview =
+    toolCard.querySelector(".tool-call-input-preview")?.textContent || "";
+
+  // Extract the original input from the expanded view
+  let originalInput = "";
+  const inputEl = toolCard.querySelector(".tool-call-input");
+  if (inputEl) {
+    originalInput = inputEl.textContent || "";
+  }
+
+  // Create a minimal ToolCall object from the existing data
+  const toolCall: Partial<ToolCall> = {
+    name: toolName,
+    // Try to reconstruct the original input if possible
+    input: originalInput,
+  };
+
+  // Replace the existing card with a new one
+  const newCard = createToolCallCard(
+    toolCall as ToolCall,
+    toolMessage,
+    toolCard.id
+  );
+
+  // Preserve the collapse state
+  if (!toolCard.classList.contains("collapsed")) {
+    newCard.classList.remove("collapsed");
+  }
+
+  // Replace the old card with the new one
+  if (toolCard.parentNode) {
+    toolCard.parentNode.replaceChild(newCard, toolCard);
+  }
+}
diff --git a/loop/webui/src/timeline/types.ts b/loop/webui/src/timeline/types.ts
new file mode 100644
index 0000000..81d47d0
--- /dev/null
+++ b/loop/webui/src/timeline/types.ts
@@ -0,0 +1,49 @@
+/**
+ * Interface for a Git commit
+ */
+export interface GitCommit {
+  hash: string; // Full commit hash
+  subject: string; // Commit subject line
+  body: string; // Full commit message body
+  pushed_branch?: string; // If set, this commit was pushed to this branch
+}
+
+/**
+ * Interface for a tool call
+ */
+export interface ToolCall {
+  name: string;
+  args?: string;
+  result?: string;
+  input?: string; // Input property for TypeScript compatibility
+  tool_call_id?: string;
+}
+
+/**
+ * Interface for a timeline message
+ */
+export interface TimelineMessage {
+  type: string;
+  content?: string;
+  timestamp?: string | number | Date;
+  elapsed?: number;
+  turnDuration?: number; // Turn duration field
+  end_of_turn?: boolean;
+  conversation_id?: string;
+  parent_conversation_id?: string;
+  tool_calls?: ToolCall[];
+  tool_name?: string;
+  tool_error?: boolean;
+  tool_call_id?: string;
+  commits?: GitCommit[]; // For commit messages
+  input?: string; // Input property
+  tool_result?: string; // Tool result property
+  toolResponses?: any[]; // Tool responses array
+  usage?: {
+    input_tokens?: number;
+    output_tokens?: number;
+    cache_read_input_tokens?: number;
+    cache_creation_input_tokens?: number;
+    cost_usd?: number;
+  };
+}
diff --git a/loop/webui/src/timeline/utils.ts b/loop/webui/src/timeline/utils.ts
new file mode 100644
index 0000000..ff505f9
--- /dev/null
+++ b/loop/webui/src/timeline/utils.ts
@@ -0,0 +1,50 @@
+/**
+ * Escapes HTML special characters in a string
+ */
+export function escapeHTML(str: string): string {
+  return str
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#039;");
+}
+
+/**
+ * Formats a number with locale-specific formatting
+ */
+export function formatNumber(
+  num: number | null | undefined,
+  defaultValue: string = "0",
+): string {
+  if (num === undefined || num === null) return defaultValue;
+  try {
+    return num.toLocaleString();
+  } catch (e) {
+    return String(num);
+  }
+}
+
+/**
+ * Generates a consistent color based on an ID string
+ */
+export function generateColorFromId(id: string | null | undefined): string {
+  if (!id) return "#7c7c7c"; // Default color for null/undefined
+
+  // Generate a hash from the ID
+  let hash = 0;
+  for (let i = 0; i < id.length; i++) {
+    hash = id.charCodeAt(i) + ((hash << 5) - hash);
+  }
+
+  // Convert hash to a hex color
+  let color = "#";
+  for (let i = 0; i < 3; i++) {
+    // Generate more muted colors by using only part of the range
+    // and adding a base value to avoid very dark colors
+    const value = ((hash >> (i * 8)) & 0xff);
+    const scaledValue = Math.floor(100 + (value * 100) / 255); // Range 100-200 for more muted colors
+    color += scaledValue.toString(16).padStart(2, "0");
+  }
+  return color;
+}