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