Move webui from /loop/webui to /webui

Thanks, perl (and git mv):

	perl -pi -e s,loop/webui,webui,g $(git grep -l loop/webui)
diff --git a/webui/src/data.ts b/webui/src/data.ts
new file mode 100644
index 0000000..9b5aca9
--- /dev/null
+++ b/webui/src/data.ts
@@ -0,0 +1,400 @@
+import { AgentMessage } 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: AgentMessage[] = [];
+  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(): AgentMessage[] {
+    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/webui/src/diff2.css b/webui/src/diff2.css
new file mode 100644
index 0000000..f716a01
--- /dev/null
+++ b/webui/src/diff2.css
@@ -0,0 +1,142 @@
+/* Custom styles for diff2 view */
+
+/* Override container max-width for diff2 view */
+#diff2View .diff-container {
+  max-width: 100%;
+  width: 100%;
+}
+
+/* When diff2 view is active, allow container to expand to full width */
+.container.diff2-active,
+.timeline-container.diff-active {
+  max-width: 100%;
+  padding-left: 20px;
+  padding-right: 20px;
+}
+
+/* Fix line-height inheritance issue */
+.d2h-code-line,
+.d2h-code-line-ctn,
+.d2h-code-linenumber {
+  line-height: 1.4 !important;
+}
+
+/* Make diff2 file container use the full width */
+.d2h-file-wrapper {
+  width: 100%;
+  margin-bottom: 20px;
+}
+
+/* Make side-by-side view use the full width */
+.d2h-file-side-diff {
+  width: 50% !important;
+}
+
+/* Style for diff lines - for both side-by-side and unified views */
+.d2h-code-line,
+.d2h-code-side-line {
+  transition: background-color 0.2s;
+  position: relative;
+}
+
+.d2h-code-line:hover,
+.d2h-code-side-line:hover {
+  background-color: #e6f7ff !important;
+}
+
+/* Plus button styles for commenting */
+.d2h-gutter-comment-button {
+  display: none;
+  position: absolute;
+  right: 0; /* Adjusted from -11px to prevent layout shifts */
+  top: 50%;
+  transform: translateY(-50%);
+  width: 22px;
+  height: 22px;
+  background-color: #0366d6;
+  color: white;
+  border-radius: 50%;
+  text-align: center;
+  line-height: 20px;
+  font-size: 16px;
+  font-weight: bold;
+  cursor: pointer;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+  opacity: 0.9;
+  z-index: 100;
+  user-select: none;
+}
+
+.d2h-gutter-comment-button:hover {
+  background-color: #0256bd;
+  opacity: 1;
+}
+
+/* Show the plus button on row hover (including line number and code) and when hovering over the button itself */
+tr:hover .d2h-gutter-comment-button,
+.d2h-gutter-comment-button:hover {
+  display: block;
+}
+
+/* Ensure diff2html content uses all available space */
+.diff2html-content {
+  width: 100%;
+  overflow-x: auto;
+}
+
+/* Diff view controls */
+#diff-view-controls {
+  display: flex;
+  justify-content: flex-end;
+  padding: 10px;
+  background-color: #f5f5f5;
+  border-bottom: 1px solid #ddd;
+}
+
+.diff-view-format {
+  display: flex;
+  gap: 15px;
+}
+
+.diff-view-format label {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  cursor: pointer;
+  font-size: 14px;
+  user-select: none;
+}
+
+.diff-view-format input[type="radio"] {
+  margin: 0;
+  cursor: pointer;
+}
+
+/* Adjust code line padding to make room for the gutter button */
+.d2h-code-line-ctn {
+  position: relative;
+  padding-left: 14px !important;
+}
+
+/* Ensure gutter is wide enough for the plus button */
+.d2h-code-linenumber,
+.d2h-code-side-linenumber {
+  position: relative;
+  min-width: 60px !important; /* Increased from 45px to accommodate 3-digit line numbers plus button */
+  padding-right: 15px !important; /* Ensure space for the button */
+  overflow: visible !important; /* Prevent button from being clipped */
+  text-align: right; /* Ensure consistent text alignment */
+  box-sizing: border-box; /* Ensure padding is included in width calculation */
+}
+
+/* Ensure table rows and cells don't clip the button */
+.d2h-diff-table tr,
+.d2h-diff-table td {
+  overflow: visible !important;
+}
+
+/* Add a bit of padding between line number and code content for visual separation */
+.d2h-code-line-ctn,
+.d2h-code-side-line-ctn {
+  padding-left: 8px !important;
+}
diff --git a/webui/src/diff2html.min.css b/webui/src/diff2html.min.css
new file mode 100644
index 0000000..8014a13
--- /dev/null
+++ b/webui/src/diff2html.min.css
@@ -0,0 +1 @@
+:host,:root{--d2h-bg-color:#fff;--d2h-border-color:#ddd;--d2h-dim-color:rgba(0,0,0,.3);--d2h-line-border-color:#eee;--d2h-file-header-bg-color:#f7f7f7;--d2h-file-header-border-color:#d8d8d8;--d2h-empty-placeholder-bg-color:#f1f1f1;--d2h-empty-placeholder-border-color:#e1e1e1;--d2h-selected-color:#c8e1ff;--d2h-ins-bg-color:#dfd;--d2h-ins-border-color:#b4e2b4;--d2h-ins-highlight-bg-color:#97f295;--d2h-ins-label-color:#399839;--d2h-del-bg-color:#fee8e9;--d2h-del-border-color:#e9aeae;--d2h-del-highlight-bg-color:#ffb6ba;--d2h-del-label-color:#c33;--d2h-change-del-color:#fdf2d0;--d2h-change-ins-color:#ded;--d2h-info-bg-color:#f8fafd;--d2h-info-border-color:#d5e4f2;--d2h-change-label-color:#d0b44c;--d2h-moved-label-color:#3572b0;--d2h-dark-color:#e6edf3;--d2h-dark-bg-color:#0d1117;--d2h-dark-border-color:#30363d;--d2h-dark-dim-color:#6e7681;--d2h-dark-line-border-color:#21262d;--d2h-dark-file-header-bg-color:#161b22;--d2h-dark-file-header-border-color:#30363d;--d2h-dark-empty-placeholder-bg-color:hsla(215,8%,47%,.1);--d2h-dark-empty-placeholder-border-color:#30363d;--d2h-dark-selected-color:rgba(56,139,253,.1);--d2h-dark-ins-bg-color:rgba(46,160,67,.15);--d2h-dark-ins-border-color:rgba(46,160,67,.4);--d2h-dark-ins-highlight-bg-color:rgba(46,160,67,.4);--d2h-dark-ins-label-color:#3fb950;--d2h-dark-del-bg-color:rgba(248,81,73,.1);--d2h-dark-del-border-color:rgba(248,81,73,.4);--d2h-dark-del-highlight-bg-color:rgba(248,81,73,.4);--d2h-dark-del-label-color:#f85149;--d2h-dark-change-del-color:rgba(210,153,34,.2);--d2h-dark-change-ins-color:rgba(46,160,67,.25);--d2h-dark-info-bg-color:rgba(56,139,253,.1);--d2h-dark-info-border-color:rgba(56,139,253,.4);--d2h-dark-change-label-color:#d29922;--d2h-dark-moved-label-color:#3572b0}.d2h-wrapper{text-align:left}.d2h-file-header{background-color:#f7f7f7;background-color:var(--d2h-file-header-bg-color);border-bottom:1px solid #d8d8d8;border-bottom:1px solid var(--d2h-file-header-border-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-family:Source Sans Pro,Helvetica Neue,Helvetica,Arial,sans-serif;height:35px;padding:5px 10px}.d2h-file-header.d2h-sticky-header{position:sticky;top:0;z-index:1}.d2h-file-stats{display:-webkit-box;display:-ms-flexbox;display:flex;font-size:14px;margin-left:auto}.d2h-lines-added{border:1px solid #b4e2b4;border:1px solid var(--d2h-ins-border-color);border-radius:5px 0 0 5px;color:#399839;color:var(--d2h-ins-label-color);padding:2px;text-align:right;vertical-align:middle}.d2h-lines-deleted{border:1px solid #e9aeae;border:1px solid var(--d2h-del-border-color);border-radius:0 5px 5px 0;color:#c33;color:var(--d2h-del-label-color);margin-left:1px;padding:2px;text-align:left;vertical-align:middle}.d2h-file-name-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;font-size:15px;width:100%}.d2h-file-name{overflow-x:hidden;text-overflow:ellipsis;white-space:nowrap}.d2h-file-wrapper{border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;margin-bottom:1em}.d2h-file-collapse{-webkit-box-pack:end;-ms-flex-pack:end;cursor:pointer;display:none;font-size:12px;justify-content:flex-end;-webkit-box-align:center;-ms-flex-align:center;align-items:center;border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;padding:4px 8px}.d2h-file-collapse.d2h-selected{background-color:#c8e1ff;background-color:var(--d2h-selected-color)}.d2h-file-collapse-input{margin:0 4px 0 0}.d2h-diff-table{border-collapse:collapse;font-family:Menlo,Consolas,monospace;font-size:13px;width:100%}.d2h-files-diff{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%}.d2h-file-diff{overflow-y:hidden}.d2h-file-diff.d2h-d-none,.d2h-files-diff.d2h-d-none{display:none}.d2h-file-side-diff{display:inline-block;overflow-x:scroll;overflow-y:hidden;width:50%}.d2h-code-line{padding:0 8em;width:calc(100% - 16em)}.d2h-code-line,.d2h-code-side-line{display:inline-block;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;white-space:nowrap}.d2h-code-side-line{padding:0 4.5em;width:calc(100% - 9em)}.d2h-code-line-ctn{background:none;display:inline-block;padding:0;word-wrap:normal;-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text;vertical-align:middle;white-space:pre;width:100%}.d2h-code-line del,.d2h-code-side-line del{background-color:#ffb6ba;background-color:var(--d2h-del-highlight-bg-color)}.d2h-code-line del,.d2h-code-line ins,.d2h-code-side-line del,.d2h-code-side-line ins{border-radius:.2em;display:inline-block;margin-top:-1px;-webkit-text-decoration:none;text-decoration:none}.d2h-code-line ins,.d2h-code-side-line ins{background-color:#97f295;background-color:var(--d2h-ins-highlight-bg-color);text-align:left}.d2h-code-line-prefix{background:none;display:inline;padding:0;word-wrap:normal;white-space:pre}.line-num1{float:left}.line-num1,.line-num2{-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;padding:0 .5em;text-overflow:ellipsis;width:3.5em}.line-num2{float:right}.d2h-code-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:rgba(0,0,0,.3);color:var(--d2h-dim-color);cursor:pointer;display:inline-block;position:absolute;text-align:right;width:7.5em}.d2h-code-linenumber:after{content:"\200b"}.d2h-code-side-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:rgba(0,0,0,.3);color:var(--d2h-dim-color);cursor:pointer;display:inline-block;overflow:hidden;padding:0 .5em;position:absolute;text-align:right;text-overflow:ellipsis;width:4em}.d2h-code-side-linenumber:after{content:"\200b"}.d2h-code-side-emptyplaceholder,.d2h-emptyplaceholder{background-color:#f1f1f1;background-color:var(--d2h-empty-placeholder-bg-color);border-color:#e1e1e1;border-color:var(--d2h-empty-placeholder-border-color)}.d2h-code-line-prefix,.d2h-code-linenumber,.d2h-code-side-linenumber,.d2h-emptyplaceholder{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.d2h-code-linenumber,.d2h-code-side-linenumber{direction:rtl}.d2h-del{background-color:#fee8e9;background-color:var(--d2h-del-bg-color);border-color:#e9aeae;border-color:var(--d2h-del-border-color)}.d2h-ins{background-color:#dfd;background-color:var(--d2h-ins-bg-color);border-color:#b4e2b4;border-color:var(--d2h-ins-border-color)}.d2h-info{background-color:#f8fafd;background-color:var(--d2h-info-bg-color);border-color:#d5e4f2;border-color:var(--d2h-info-border-color);color:rgba(0,0,0,.3);color:var(--d2h-dim-color)}.d2h-file-diff .d2h-del.d2h-change{background-color:#fdf2d0;background-color:var(--d2h-change-del-color)}.d2h-file-diff .d2h-ins.d2h-change{background-color:#ded;background-color:var(--d2h-change-ins-color)}.d2h-file-list-wrapper{margin-bottom:10px}.d2h-file-list-wrapper a{-webkit-text-decoration:none;text-decoration:none}.d2h-file-list-wrapper a,.d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-file-list-header{text-align:left}.d2h-file-list-title{font-weight:700}.d2h-file-list-line{display:-webkit-box;display:-ms-flexbox;display:flex;text-align:left}.d2h-file-list{display:block;list-style:none;margin:0;padding:0}.d2h-file-list>li{border-bottom:1px solid #ddd;border-bottom:1px solid var(--d2h-border-color);margin:0;padding:5px 10px}.d2h-file-list>li:last-child{border-bottom:none}.d2h-file-switch{cursor:pointer;display:none;font-size:10px}.d2h-icon{margin-right:10px;vertical-align:middle;fill:currentColor}.d2h-deleted{color:#c33;color:var(--d2h-del-label-color)}.d2h-added{color:#399839;color:var(--d2h-ins-label-color)}.d2h-changed{color:#d0b44c;color:var(--d2h-change-label-color)}.d2h-moved{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-tag{background-color:#fff;background-color:var(--d2h-bg-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-size:10px;margin-left:5px;padding:0 2px}.d2h-deleted-tag{border:1px solid #c33;border:1px solid var(--d2h-del-label-color)}.d2h-added-tag{border:1px solid #399839;border:1px solid var(--d2h-ins-label-color)}.d2h-changed-tag{border:1px solid #d0b44c;border:1px solid var(--d2h-change-label-color)}.d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-moved-label-color)}.d2h-dark-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-dark-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-dark-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-code-line del,.d2h-dark-color-scheme .d2h-code-side-line del{background-color:rgba(248,81,73,.4);background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-dark-color-scheme .d2h-code-line ins,.d2h-dark-color-scheme .d2h-code-side-line ins{background-color:rgba(46,160,67,.4);background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-dark-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:hsla(215,8%,47%,.1);background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-dark-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-del{background-color:rgba(248,81,73,.1);background-color:var(--d2h-dark-del-bg-color);border-color:rgba(248,81,73,.4);border-color:var(--d2h-dark-del-border-color)}.d2h-dark-color-scheme .d2h-ins{background-color:rgba(46,160,67,.15);background-color:var(--d2h-dark-ins-bg-color);border-color:rgba(46,160,67,.4);border-color:var(--d2h-dark-ins-border-color)}.d2h-dark-color-scheme .d2h-info{background-color:rgba(56,139,253,.1);background-color:var(--d2h-dark-info-bg-color);border-color:rgba(56,139,253,.4);border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:rgba(210,153,34,.2);background-color:var(--d2h-dark-change-del-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:rgba(46,160,67,.25);background-color:var(--d2h-dark-change-ins-color)}.d2h-dark-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected{background-color:rgba(56,139,253,.1);background-color:var(--d2h-dark-selected-color)}.d2h-dark-color-scheme .d2h-file-list-wrapper a,.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}@media (prefers-color-scheme:dark){.d2h-auto-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-auto-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-auto-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-code-line del,.d2h-auto-color-scheme .d2h-code-side-line del{background-color:rgba(248,81,73,.4);background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-auto-color-scheme .d2h-code-line ins,.d2h-auto-color-scheme .d2h-code-side-line ins{background-color:rgba(46,160,67,.4);background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-auto-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:hsla(215,8%,47%,.1);background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-auto-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-del{background-color:rgba(248,81,73,.1);background-color:var(--d2h-dark-del-bg-color);border-color:rgba(248,81,73,.4);border-color:var(--d2h-dark-del-border-color)}.d2h-auto-color-scheme .d2h-ins{background-color:rgba(46,160,67,.15);background-color:var(--d2h-dark-ins-bg-color);border-color:rgba(46,160,67,.4);border-color:var(--d2h-dark-ins-border-color)}.d2h-auto-color-scheme .d2h-info{background-color:rgba(56,139,253,.1);background-color:var(--d2h-dark-info-bg-color);border-color:rgba(56,139,253,.4);border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:rgba(210,153,34,.2);background-color:var(--d2h-dark-change-del-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:rgba(46,160,67,.25);background-color:var(--d2h-dark-change-ins-color)}.d2h-auto-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected{background-color:rgba(56,139,253,.1);background-color:var(--d2h-dark-selected-color)}.d2h-auto-color-scheme .d2h-file-list-wrapper a,.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}}
\ No newline at end of file
diff --git a/webui/src/sketch-app-shell.css b/webui/src/sketch-app-shell.css
new file mode 100644
index 0000000..57c96df
--- /dev/null
+++ b/webui/src/sketch-app-shell.css
@@ -0,0 +1,22 @@
+html,
+body {
+  height: 100%;
+  overflow-y: auto;
+}
+
+body {
+  font-family:
+    system-ui,
+    -apple-system,
+    BlinkMacSystemFont,
+    "Segoe UI",
+    Roboto,
+    sans-serif;
+  margin: 0;
+  padding: 0;
+  color: #333;
+  line-height: 1.4;
+  overflow-x: hidden; /* Prevent horizontal scrolling */
+  display: flex;
+  flex-direction: column;
+}
diff --git a/webui/src/sketch-app-shell.html b/webui/src/sketch-app-shell.html
new file mode 100644
index 0000000..c12ce8c
--- /dev/null
+++ b/webui/src/sketch-app-shell.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>sketch coding assistant</title>
+    <link rel="stylesheet" href="sketch-app-shell.css" />
+    <script src="static/sketch-app-shell.js" async type="module"></script>
+  </head>
+  <body>
+    <sketch-app-shell></sketch-app-shell>
+  </body>
+</html>
diff --git a/webui/src/types.ts b/webui/src/types.ts
new file mode 100644
index 0000000..7874a3b
--- /dev/null
+++ b/webui/src/types.ts
@@ -0,0 +1,81 @@
+// Auto-generated by sketch.dev/cmd/go2ts.go
+// DO NOT EDIT. This file is automatically generated.
+
+export interface ToolCall {
+	name: string;
+	input: string;
+	tool_call_id: string;
+	result_message?: AgentMessage | null;
+	args?: string;
+	result?: string;
+}
+
+export interface GitCommit {
+	hash: string;
+	subject: string;
+	body: string;
+	pushed_branch?: string;
+}
+
+export interface Usage {
+	input_tokens: number;
+	cache_creation_input_tokens: number;
+	cache_read_input_tokens: number;
+	output_tokens: number;
+	cost_usd: number;
+}
+
+export interface AgentMessage {
+	type: CodingAgentMessageType;
+	end_of_turn: boolean;
+	content: string;
+	tool_name?: string;
+	input?: string;
+	tool_result?: string;
+	tool_error?: boolean;
+	tool_call_id?: string;
+	tool_calls?: ToolCall[] | null;
+	toolResponses?: AgentMessage[] | null;
+	commits?: (GitCommit | null)[] | null;
+	timestamp: string;
+	conversation_id: string;
+	parent_conversation_id?: string | null;
+	usage?: Usage | null;
+	start_time?: string | null;
+	end_time?: string | null;
+	elapsed?: Duration | null;
+	turnDuration?: Duration | null;
+	idx: number;
+}
+
+export interface CumulativeUsage {
+	start_time: string;
+	messages: number;
+	input_tokens: number;
+	output_tokens: number;
+	cache_read_input_tokens: number;
+	cache_creation_input_tokens: number;
+	total_cost_usd: number;
+	tool_uses: { [key: string]: number } | null;
+}
+
+export interface State {
+	message_count: number;
+	total_usage?: CumulativeUsage | null;
+	hostname: string;
+	working_dir: string;
+	initial_commit: string;
+	title: string;
+	os: string;
+	outside_hostname?: string;
+	inside_hostname?: string;
+	outside_os?: string;
+	inside_os?: string;
+	outside_working_dir?: string;
+	inside_working_dir?: string;
+	git_origin?: string;
+}
+
+export type CodingAgentMessageType = 'user' | 'agent' | 'error' | 'budget' | 'tool' | 'commit' | 'auto';
+
+export type Duration = number;
diff --git a/webui/src/utils.ts b/webui/src/utils.ts
new file mode 100644
index 0000000..b60a0fa
--- /dev/null
+++ b/webui/src/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;
+}
diff --git a/webui/src/vega-types.d.ts b/webui/src/vega-types.d.ts
new file mode 100644
index 0000000..97a4655
--- /dev/null
+++ b/webui/src/vega-types.d.ts
@@ -0,0 +1,34 @@
+// Type definitions for Vega-Lite and related modules
+declare module "fast-json-patch/index.mjs";
+
+// Add any interface augmentations for TimelineMessage and ToolCall
+interface ToolCall {
+  name: string;
+  args?: string;
+  result?: string;
+  input?: string; // Add missing property
+}
+
+interface TimelineMessage {
+  type: string;
+  content?: string;
+  timestamp?: string | number | Date;
+  elapsed?: number;
+  end_of_turn?: boolean;
+  conversation_id?: string;
+  parent_conversation_id?: string;
+  tool_calls?: ToolCall[];
+  tool_name?: string;
+  tool_error?: boolean;
+  tool_result?: string;
+  input?: string;
+  start_time?: string | number | Date; // Add start time
+  end_time?: string | number | Date; // Add end time
+  usage?: {
+    input_tokens?: number;
+    output_tokens?: number;
+    cache_read_input_tokens?: number;
+    cache_creation_input_tokens?: number;
+    cost_usd?: number;
+  };
+}
diff --git a/webui/src/web-components/aggregateAgentMessages.ts b/webui/src/web-components/aggregateAgentMessages.ts
new file mode 100644
index 0000000..3dc11f8
--- /dev/null
+++ b/webui/src/web-components/aggregateAgentMessages.ts
@@ -0,0 +1,35 @@
+import { AgentMessage } from "../types";
+
+export function aggregateAgentMessages(
+  arr1: AgentMessage[],
+  arr2: AgentMessage[],
+): AgentMessage[] {
+  const mergedArray = [...arr1, ...arr2];
+  const seenIds = new Set<number>();
+  const toolCallResults = new Map<string, AgentMessage>();
+
+  let ret: AgentMessage[] = mergedArray
+    .filter((msg) => {
+      if (msg.type == "tool") {
+        toolCallResults.set(msg.tool_call_id, msg);
+        return false;
+      }
+      if (seenIds.has(msg.idx)) {
+        return false; // Skip if idx is already seen
+      }
+
+      seenIds.add(msg.idx);
+      return true;
+    })
+    .sort((a: AgentMessage, b: AgentMessage) => a.idx - b.idx);
+
+  // Attach any tool_call result messages to the original message's tool_call object.
+  ret.forEach((msg) => {
+    msg.tool_calls?.forEach((toolCall) => {
+      if (toolCallResults.has(toolCall.tool_call_id)) {
+        toolCall.result_message = toolCallResults.get(toolCall.tool_call_id);
+      }
+    });
+  });
+  return ret;
+}
diff --git a/webui/src/web-components/demo/demo.css b/webui/src/web-components/demo/demo.css
new file mode 100644
index 0000000..08e02a2
--- /dev/null
+++ b/webui/src/web-components/demo/demo.css
@@ -0,0 +1,18 @@
+body {
+  font-family:
+    system-ui,
+    -apple-system,
+    BlinkMacSystemFont,
+    "Segoe UI",
+    Roboto,
+    sans-serif;
+  margin: 0;
+  padding: 20px;
+  padding-bottom: 100px; /* Adjusted padding for chat container */
+  color: #333;
+  line-height: 1.4; /* Reduced line height for more compact text */
+}
+
+pre {
+  white-space: normal;
+}
diff --git a/webui/src/web-components/demo/index.html b/webui/src/web-components/demo/index.html
new file mode 100644
index 0000000..77df51e
--- /dev/null
+++ b/webui/src/web-components/demo/index.html
@@ -0,0 +1,29 @@
+<html>
+  <head>
+    <link rel="stylesheet" href="demo.css" />
+  </head>
+  <body>
+    sketch web-components demo index
+    <ul>
+      <li><a href="sketch-app-shell.demo.html">sketch-app-shell</a></li>
+      <li><a href="sketch-charts.demo.html">sketch-charts</a></li>
+      <li><a href="sketch-chat-input.demo.html">sketch-chat-input</a></li>
+      <li><a href="sketch-diff-view.demo.html">sketch-diff-view</a></li>
+      <li>
+        <a href="sketch-container-status.demo.html">sketch-container-status</a>
+      </li>
+      <li>
+        <a href="sketch-network-status.demo.html">sketch-network-status</a>
+      </li>
+      <li>
+        <a href="sketch-timeline-message.demo.html">sketch-timeline-message</a>
+      </li>
+      <li><a href="sketch-timeline.demo.html">sketch-timeline</a></li>
+      <li><a href="sketch-tool-calls.demo.html">sketch-tool-calls</a></li>
+      <li><a href="sketch-tool-card.demo.html">sketch-tool-card</a></li>
+      <li>
+        <a href="sketch-view-mode-select.demo.html">sketch-view-mode-select</a>
+      </li>
+    </ul>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/readme.md b/webui/src/web-components/demo/readme.md
new file mode 100644
index 0000000..324d077
--- /dev/null
+++ b/webui/src/web-components/demo/readme.md
@@ -0,0 +1,5 @@
+# Stand-alone demo pages for sketch web components
+
+These are handy for iterating on specific component UI issues in isolation from the rest of the sketch application, and without having to start a full backend to serve the full frontend app UI.
+
+See [README](../../../readme.md#development-mode) for more information on how to run the demo pages.
diff --git a/webui/src/web-components/demo/sketch-app-shell.demo.html b/webui/src/web-components/demo/sketch-app-shell.demo.html
new file mode 100644
index 0000000..48fc100
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-app-shell.demo.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>sketch coding assistant</title>
+    <link rel="stylesheet" href="sketch-app-shell.css" />
+    <script src="../sketch-app-shell.ts" type="module"></script>
+  </head>
+  <body>
+    <sketch-app-shell></sketch-app-shell>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-charts.demo.html b/webui/src/web-components/demo/sketch-charts.demo.html
new file mode 100644
index 0000000..64a9bd2
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-charts.demo.html
@@ -0,0 +1,134 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <title>Sketch Charts Demo</title>
+    <script type="module" src="../sketch-charts.ts"></script>
+    <link rel="stylesheet" href="demo.css" />
+    <style>
+      sketch-charts {
+        margin: 20px;
+        max-width: 1000px;
+      }
+
+      body {
+        font-family:
+          system-ui,
+          -apple-system,
+          BlinkMacSystemFont,
+          "Segoe UI",
+          Roboto,
+          sans-serif;
+      }
+    </style>
+  </head>
+  <body>
+    <h1>Sketch Charts Demo</h1>
+    <sketch-charts id="charts"></sketch-charts>
+
+    <script>
+      // Sample data for testing
+      const sampleMessages = [
+        {
+          idx: 1,
+          type: "human",
+          content: "Hello, can you help me with a coding task?",
+          timestamp: new Date(Date.now() - 3600000).toISOString(),
+          usage: { cost_usd: 0.0001 },
+        },
+        {
+          idx: 2,
+          type: "assistant",
+          content:
+            "I'd be happy to help! What kind of coding task are you working on?",
+          timestamp: new Date(Date.now() - 3500000).toISOString(),
+          usage: { cost_usd: 0.0005 },
+        },
+        {
+          idx: 3,
+          type: "human",
+          content: "I need to create a web component using lit-element",
+          timestamp: new Date(Date.now() - 3400000).toISOString(),
+          usage: { cost_usd: 0.0001 },
+        },
+        {
+          idx: 4,
+          type: "assistant",
+          content:
+            "I can definitely help with that. Lit Element is a great library for building web components.",
+          timestamp: new Date(Date.now() - 3300000).toISOString(),
+          usage: { cost_usd: 0.0008 },
+        },
+        {
+          idx: 5,
+          type: "assistant",
+          tool_name: "bash",
+          input: "ls -la",
+          tool_result:
+            "total 16\ndrwxr-xr-x  4 user  staff  128 Jan 10 12:34 .\ndrwxr-xr-x 10 user  staff  320 Jan 10 12:34 ..\n-rw-r--r--  1 user  staff  123 Jan 10 12:34 file1.txt\n-rw-r--r--  1 user  staff  456 Jan 10 12:34 file2.txt",
+          start_time: new Date(Date.now() - 3200000).toISOString(),
+          end_time: new Date(Date.now() - 3190000).toISOString(),
+          timestamp: new Date(Date.now() - 3190000).toISOString(),
+          usage: { cost_usd: 0.0002 },
+        },
+        {
+          idx: 6,
+          type: "assistant",
+          content: "Let me create a basic web component for you.",
+          timestamp: new Date(Date.now() - 3100000).toISOString(),
+          usage: { cost_usd: 0.0015 },
+        },
+        {
+          idx: 7,
+          type: "human",
+          content: "Can you show me how to handle events in the web component?",
+          timestamp: new Date(Date.now() - 3000000).toISOString(),
+          usage: { cost_usd: 0.0001 },
+        },
+        {
+          idx: 8,
+          type: "assistant",
+          tool_name: "bash",
+          input: "cat example.ts",
+          tool_result:
+            "import { LitElement, html } from 'lit';\nimport { customElement } from 'lit/decorators.js';\n\n@customElement('my-element')\nexport class MyElement extends LitElement {\n  render() {\n    return html`<div>Hello World</div>`;\n  }\n}",
+          start_time: new Date(Date.now() - 2900000).toISOString(),
+          end_time: new Date(Date.now() - 2800000).toISOString(),
+          timestamp: new Date(Date.now() - 2800000).toISOString(),
+          usage: { cost_usd: 0.0003 },
+        },
+        {
+          idx: 9,
+          type: "assistant",
+          content:
+            "Here's how you can handle events in a web component using Lit.",
+          timestamp: new Date(Date.now() - 2700000).toISOString(),
+          usage: { cost_usd: 0.002 },
+        },
+        {
+          idx: 10,
+          type: "human",
+          content: "Thank you! How about adding properties and attributes?",
+          timestamp: new Date(Date.now() - 2600000).toISOString(),
+          usage: { cost_usd: 0.0001 },
+        },
+        {
+          idx: 11,
+          type: "assistant",
+          content:
+            "You can use the @property decorator to define properties in your Lit Element component.",
+          timestamp: new Date(Date.now() - 2500000).toISOString(),
+          usage: { cost_usd: 0.0025 },
+        },
+      ];
+
+      // Set sample data as soon as the component is defined
+      document.addEventListener("DOMContentLoaded", () => {
+        console.time("chart-demo-load");
+        const chartsComponent = document.getElementById("charts");
+        chartsComponent.messages = sampleMessages;
+        console.timeEnd("chart-demo-load");
+      });
+    </script>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-chat-input.demo.html b/webui/src/web-components/demo/sketch-chat-input.demo.html
new file mode 100644
index 0000000..afc79fb
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-chat-input.demo.html
@@ -0,0 +1,30 @@
+<html>
+  <head>
+    <title>sketch-chat-input demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script type="module" src="../sketch-chat-input.ts"></script>
+
+    <script>
+      document.addEventListener("DOMContentLoaded", () => {
+        const chatInput = document.querySelector("sketch-chat-input");
+        console.log("chatInput: ", chatInput);
+        chatInput.content = "hi";
+        chatInput.addEventListener("send-chat", (evt) => {
+          console.log("send chat event: ", evt);
+          const msgDiv = document.querySelector("#chat-messages");
+          const newDiv = document.createElement("div");
+          newDiv.innerText = evt.detail.message;
+          msgDiv.append(newDiv);
+          chatInput.content = "";
+        });
+      });
+    </script>
+  </head>
+  <body>
+    <h1>sketch-chat-input demo</h1>
+
+    <div id="chat-messages"></div>
+
+    <sketch-chat-input></sketch-chat-input>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-container-status.demo.html b/webui/src/web-components/demo/sketch-container-status.demo.html
new file mode 100644
index 0000000..0945d70
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-container-status.demo.html
@@ -0,0 +1,38 @@
+<html>
+  <head>
+    <title>sketch-container-status demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script type="module" src="../sketch-container-status.ts"></script>
+
+    <script>
+      document.addEventListener("DOMContentLoaded", () => {
+        const containerStatus = document.querySelector("#status-2");
+        containerStatus.state = {
+          hostname: "example.hostname",
+          initial_commit: "decafbad",
+          message_count: 27,
+          os: "linux",
+          total_usage: {
+            start_time: "around lunch",
+            messages: 1337,
+            input_tokens: 3,
+            output_tokens: 1000,
+            cache_read_input_tokens: 28,
+            cache_creation_input_tokens: 12354,
+            total_cost_usd: 2.03,
+          },
+          working_dir: "/app",
+        };
+      });
+    </script>
+  </head>
+  <body>
+    <h1>sketch-container-status demo</h1>
+
+    Empty:
+    <sketch-container-status id="status-1"></sketch-container-status>
+
+    With state fields set:
+    <sketch-container-status id="status-2"></sketch-container-status>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-diff-view.demo.html b/webui/src/web-components/demo/sketch-diff-view.demo.html
new file mode 100644
index 0000000..6ab6e62
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-diff-view.demo.html
@@ -0,0 +1,109 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Sketch Diff Viewer Demo</title>
+    <link
+      rel="stylesheet"
+      href="../../../node_modules/diff2html/bundles/css/diff2html.min.css"
+    />
+    <script type="module" src="../sketch-diff-view.ts"></script>
+    <style>
+      body {
+        font-family:
+          -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
+          Arial, sans-serif;
+        max-width: 1200px;
+        margin: 0 auto;
+        padding: 2rem;
+      }
+
+      h1 {
+        color: #333;
+        margin-bottom: 2rem;
+      }
+
+      .control-panel {
+        margin-bottom: 2rem;
+        padding: 1rem;
+        background-color: #f0f0f0;
+        border-radius: 4px;
+      }
+
+      input {
+        padding: 0.5rem;
+        border-radius: 4px;
+        border: 1px solid #ccc;
+        width: 300px;
+      }
+
+      button {
+        padding: 0.5rem 1rem;
+        background-color: #2196f3;
+        color: white;
+        border: none;
+        border-radius: 4px;
+        cursor: pointer;
+        margin-left: 1rem;
+      }
+
+      button:hover {
+        background-color: #0d8bf2;
+      }
+    </style>
+
+    <script>
+      document.addEventListener("DOMContentLoaded", () => {
+        const diffViewer = document.getElementById("diffViewer");
+        const commitHashInput = document.getElementById("commitHash");
+        const viewDiffButton = document.getElementById("viewDiff");
+        let commit = false;
+        viewDiffButton.addEventListener("click", () => {
+          let diffContent = `diff --git a/sample.txt b/sample.txt
+index 1111111..2222222 100644
+--- a/sample.txt
++++ b/sample.txt
+@@ -1,5 +1,5 @@
+ This is a sample file
+-This line will be removed
++This line is added as a replacement
+ This line stays the same
+-Another line to remove
++A completely new line
+ The last line is unchanged`;
+          if (commit) {
+            // For demo purposes, generate fake diff based on commit hash
+            diffContent = `diff --git a/file-${commit.substring(0, 5)}.txt b/file-${commit.substring(0, 5)}.txt
+index 3333333..4444444 100644
+--- a/file-${commit.substring(0, 5)}.txt
++++ b/file-${commit.substring(0, 5)}.txt
+@@ -1,4 +1,6 @@
+ File with commit: ${commit}
++This line was added in commit ${commit}
+ This line exists in both versions
+-This line was removed in commit ${commit}
++This line replaced the removed line
++Another new line added in this commit
+ Last line of the file`;
+          }
+          diffViewer.diffText = diffContent;
+          diffViewer.commitHash = commitHashInput.value.trim();
+        });
+      });
+    </script>
+  </head>
+  <body>
+    <h1>Sketch Diff Viewer Demo</h1>
+
+    <div class="control-panel">
+      <label for="commitHash"
+        >Commit Hash (leave empty for unstaged changes):</label
+      >
+      <input type="text" id="commitHash" placeholder="Enter commit hash" />
+      <button id="viewDiff">View Diff</button>
+    </div>
+
+    <sketch-diff-view id="diffViewer"></sketch-diff-view>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-network-status.demo.html b/webui/src/web-components/demo/sketch-network-status.demo.html
new file mode 100644
index 0000000..f248a5d
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-network-status.demo.html
@@ -0,0 +1,22 @@
+<html>
+  <head>
+    <title>sketch-network-status demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script type="module" src="../sketch-network-status.ts"></script>
+  </head>
+  <body>
+    <h1>sketch-network-status demo</h1>
+
+    Connected:
+    <sketch-network-status
+      connection="connected"
+      message="connected"
+    ></sketch-network-status>
+
+    Error:
+    <sketch-network-status
+      connection="error"
+      error="error"
+    ></sketch-network-status>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-timeline-message.demo.html b/webui/src/web-components/demo/sketch-timeline-message.demo.html
new file mode 100644
index 0000000..3c5d77e
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-timeline-message.demo.html
@@ -0,0 +1,62 @@
+<html>
+  <head>
+    <title>sketch-timeline-message demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script type="module" src="../sketch-timeline-message.ts"></script>
+
+    <script>
+      const messages = [
+        {
+          type: "agent",
+          content: "an agent message",
+        },
+        {
+          type: "user",
+          content: "a user message",
+        },
+        {
+          type: "tool",
+          content: "a tool use message",
+        },
+        {
+          type: "commit",
+          end_of_turn: false,
+          content: "",
+          commits: [
+            {
+              hash: "ece101c103ec231da87f4df05c1b5e6a24e13add",
+              subject: "Add README.md for web components directory",
+              body: "This adds documentation for the web components used in the Loop UI,\nincluding a description of each component, usage examples, and\ndevelopment guidelines.\n\nCo-Authored-By: sketch\nadd README.md for webui/src/web-components",
+              pushed_branch:
+                "sketch/create-readmemd-for-web-components-directory",
+            },
+          ],
+          timestamp: "2025-04-14T16:39:33.639533919Z",
+          conversation_id: "",
+          idx: 17,
+        },
+        {
+          type: "agent",
+          content: "an end-of-turn agent message",
+          end_of_turn: true,
+        },
+      ];
+      document.addEventListener("DOMContentLoaded", () => {
+        messages.forEach((msg, idx) => {
+          const jsonEl = document.createElement("pre");
+          jsonEl.innerText = `.message property: ${JSON.stringify(msg)}`;
+          document.body.append(jsonEl);
+          const messageEl = document.createElement("sketch-timeline-message");
+          messageEl.message = msg;
+          document.body.appendChild(messageEl);
+        });
+        window.addEventListener("show-commit-diff", (evt) => {
+          console.log("show-commit-diff", evt);
+        });
+      });
+    </script>
+  </head>
+  <body>
+    <h1>sketch-timeline-message demo</h1>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-timeline.demo.html b/webui/src/web-components/demo/sketch-timeline.demo.html
new file mode 100644
index 0000000..58ff5d9
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-timeline.demo.html
@@ -0,0 +1,149 @@
+<html>
+  <head>
+    <title>sketch-timeline demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script type="module" src="../sketch-timeline.ts"></script>
+    <script>
+      const messages = [
+        {
+          type: "user",
+          content: "a user message",
+        },
+        {
+          type: "agent",
+          content: "an agent message",
+        },
+        {
+          type: "agent",
+          content: "an agent message",
+        },
+        {
+          type: "agent",
+          content: "an agent message",
+        },
+        {
+          type: "user",
+          content: "a user message",
+        },
+        {
+          type: "user",
+          content: "a user message",
+        },
+        {
+          type: "agent",
+          content: "an agent message",
+        },
+        {
+          type: "user",
+          content: "a user message",
+        },
+        {
+          type: "tool",
+          content: "a tool use message",
+        },
+        {
+          type: "commit",
+          end_of_turn: false,
+          content: "",
+          commits: [
+            {
+              hash: "ece101c103ec231da87f4df05c1b5e6a24e13add",
+              subject: "Add README.md for web components directory",
+              body: "This adds documentation for the web components used in the Loop UI,\nincluding a description of each component, usage examples, and\ndevelopment guidelines.\n\nCo-Authored-By: sketch\nadd README.md for webui/src/web-components",
+              pushed_branch:
+                "sketch/create-readmemd-for-web-components-directory",
+            },
+          ],
+          timestamp: "2025-04-14T16:39:33.639533919Z",
+          conversation_id: "",
+          idx: 17,
+        },
+        {
+          type: "agent",
+          content: "an end-of-turn agent message",
+          end_of_turn: true,
+        },
+      ];
+
+      document.addEventListener("DOMContentLoaded", () => {
+        const appShell = document.querySelector(".app-shell");
+        const timelineEl = document.querySelector("sketch-timeline");
+        timelineEl.messages = messages;
+        timelineEl.scrollContainer = appShell;
+        const addMessagesCheckbox = document.querySelector("#addMessages");
+        addMessagesCheckbox.addEventListener("change", toggleAddMessages);
+
+        let addingMessages = false;
+        const addNewMessagesInterval = 1000;
+
+        function addNewMessages() {
+          if (!addingMessages) {
+            return;
+          }
+          const n = new Date().getMilliseconds() % messages.length;
+          const msgToDup = messages[n];
+          const dup = JSON.parse(JSON.stringify(msgToDup));
+          dup.idx = messages.length;
+          dup.timestamp = new Date().toISOString();
+          messages.push(dup);
+          timelineEl.messages = messages.concat();
+          timelineEl.prop;
+          timelineEl.requestUpdate();
+        }
+
+        let addMessagesHandler = setInterval(
+          addNewMessages,
+          addNewMessagesInterval,
+        );
+
+        function toggleAddMessages() {
+          addingMessages = !addingMessages;
+          if (addingMessages) {
+          } else {
+          }
+        }
+      });
+    </script>
+    <style>
+      .app-shell {
+        display: block;
+        font-family:
+          system-ui,
+          -apple-system,
+          BlinkMacSystemFont,
+          "Segoe UI",
+          Roboto,
+          sans-serif;
+        color: rgb(51, 51, 51);
+        line-height: 1.4;
+        min-height: 100vh;
+        width: 100%;
+        position: relative;
+        overflow-x: hidden;
+      }
+      .app-header {
+        flex-grow: 0;
+      }
+      .view-container {
+        flex-grow: 2;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="app-shell">
+      <div class="app-header">
+        <h1>sketch-timeline demo</h1>
+        <input
+          type="checkbox"
+          id="addMessages"
+          title="Automatically add new messages"
+        /><label for="addMessages">Automatically add new messages</label>
+      </div>
+      <div class="view-container">
+        <div class="chat-view view-active">
+          <sketch-timeline></sketch-timeline>
+        </div>
+      </div>
+    </div>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-tool-calls.demo.html b/webui/src/web-components/demo/sketch-tool-calls.demo.html
new file mode 100644
index 0000000..9ad1677
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-tool-calls.demo.html
@@ -0,0 +1,184 @@
+<html>
+  <head>
+    <title>sketch-tool-calls demo</title>
+    <link rel="stylesheet" href="demo.css" />
+
+    <script type="module" src="../sketch-tool-calls.ts"></script>
+
+    <script>
+      const toolCalls = [
+        [
+          {
+            name: "bash",
+            input: JSON.stringify({
+              command:
+                "docker ps -a --format '{{.ID}} {{.Image }} {{.Names}}' | grep sketch | awk '{print $1 }' | xargs -I {} docker rm {} && docker image prune -af",
+            }),
+          },
+        ],
+        [
+          {
+            name: "bash",
+            input: JSON.stringify({
+              command: "ls -a",
+            }),
+            result_message: {
+              type: "tool",
+              tool_result: ".\n..",
+            },
+          },
+        ],
+        [
+          {
+            name: "bash",
+            input: JSON.stringify({
+              command: "sleep 200",
+            }),
+            result_message: {
+              type: "tool",
+              tool_error: "the user canceled this operation",
+            },
+          },
+        ],
+        [
+          {
+            name: "title",
+            input: JSON.stringify({
+              title: "a new title for this sketch",
+            }),
+          },
+        ],
+        [
+          {
+            name: "codereview",
+            input: "{}",
+            tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+            result_message: {
+              type: "tool",
+              end_of_turn: false,
+              content: "",
+              tool_name: "codereview",
+              input: "{}",
+              tool_result: "OK",
+              tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+              timestamp: "2025-04-14T16:33:17.575759565Z",
+              conversation_id: "xsa-8hw0",
+              start_time: "2025-04-14T16:33:07.11793816Z",
+              end_time: "2025-04-14T16:33:17.57575719Z",
+              elapsed: 10457819031,
+              idx: 45,
+            },
+          },
+        ],
+        [
+          {
+            name: "codereview",
+            input: "{}",
+            tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+            result_message: {
+              type: "tool",
+              end_of_turn: false,
+              content: "",
+              tool_name: "codereview",
+              input: "{}",
+              tool_result: "Not OK",
+              tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+              timestamp: "2025-04-14T16:33:17.575759565Z",
+              conversation_id: "xsa-8hw0",
+              start_time: "2025-04-14T16:33:07.11793816Z",
+              end_time: "2025-04-14T16:33:17.57575719Z",
+              elapsed: 10457819031,
+              idx: 45,
+            },
+          },
+        ],
+        [
+          {
+            name: "think",
+            input:
+              '{"thoughts":"I\'m going to inspect a few key components to understand their purpose and relationships:\\n1. sketch-app-shell.ts - Appears to be the main container component\\n2. sketch-timeline.ts - Likely manages the chat timeline\\n3. sketch-view-mode-select.ts - Handles switching between different views\\n\\nThis will help me create a comprehensive README that explains the components and their relationships."}',
+            tool_call_id: "toolu_01R1g5mQVgKxEJZFNp9QGvUr",
+            result_message: {
+              type: "tool",
+              end_of_turn: false,
+              content: "",
+              tool_name: "think",
+              input:
+                '{"thoughts":"I\'m going to inspect a few key components to understand their purpose and relationships:\\n1. sketch-app-shell.ts - Appears to be the main container component\\n2. sketch-timeline.ts - Likely manages the chat timeline\\n3. sketch-view-mode-select.ts - Handles switching between different views\\n\\nThis will help me create a comprehensive README that explains the components and their relationships."}',
+              tool_result: "recorded",
+              tool_call_id: "toolu_01R1g5mQVgKxEJZFNp9QGvUr",
+              timestamp: "2025-04-14T16:32:14.12647133Z",
+              conversation_id: "xsa-8hw0",
+              start_time: "2025-04-14T16:32:14.126454329Z",
+              end_time: "2025-04-14T16:32:14.126468539Z",
+              elapsed: 14209,
+              idx: 18,
+            },
+          },
+        ],
+        [
+          {
+            name: "patch",
+            input:
+              '{"path":"/app/webui/src/web-components/README.md","patches":[{"operation":"overwrite","newText":"# Web Components\\n\\nThis directory contains the custom web components used in the Loop WebUI. These components are built using the [Lit](https://lit.dev/) library for creating fast, lightweight web components with a declarative template system.\\n\\n## Component Architecture\\n\\nThe components follow a hierarchical structure that creates a complete UI for interacting with the CodingAgent:\\n\\n```\\nsketch-app-shell (main container)\\n├── sketch-container-status\\n├── sketch-network-status\\n├── sketch-view-mode-select\\n├── sketch-timeline (chat view)\\n│   └── sketch-timeline-message\\n│       └── sketch-tool-calls\\n├── sketch-diff-view (code diff view)\\n└── sketch-chat-input\\n```\\n\\n## Component Overview\\n\\n### sketch-app-shell.ts\\nThe main container component that orchestrates the entire UI. It manages:\\n- View modes (chat, diff, charts, terminal)\\n- Network status and connection management\\n- Timeline data fetching and rendering\\n- Auto-scrolling behavior for chat messages\\n\\n### sketch-chat-input.ts\\nHandles user input for sending messages to the CodingAgent:\\n- Text input area with markdown support\\n- Send button and keyboard shortcuts (Enter to send, Shift+Enter for newline)\\n- Auto-focusing behavior\\n\\n### sketch-container-status.ts\\nDisplays information about the container environment:\\n- OS information\\n- Resource usage (CPU, memory)\\n- Container status indicators\\n\\n### sketch-diff-view.ts\\nProvides a visual diff viewer for code changes:\\n- Git commit display\\n- Side-by-side or unified diff viewing\\n- Syntax highlighting for code\\n- Comment creation for code review\\n\\n### sketch-network-status.ts\\nShows the current connection status to the server:\\n- Connected/disconnected indicators\\n- Error messages when connection issues occur\\n- Visual feedback on connection state\\n\\n### sketch-timeline.ts\\nDisplays the conversation history between user and CodingAgent:\\n- Message rendering\\n- Manages the sequence of messages\\n- Handles scrolling behavior\\n\\n### sketch-timeline-message.ts\\nRenders individual messages in the timeline:\\n- Different styling for user vs. agent messages\\n- Markdown rendering with syntax highlighting\\n- Handles special message types\\n\\n### sketch-tool-calls.ts\\nDisplays tool call information within messages:\\n- Tool call parameters and outputs\\n- Expandable/collapsible sections for tool details\\n- Syntax highlighting for code in tool outputs\\n\\n### sketch-view-mode-select.ts\\nProvides UI for switching between different views:\\n- Chat view for conversation\\n- Diff view for code changes\\n- Charts view for data visualization\\n- Terminal view for command execution\\n\\n## Development\\n\\n### Creating New Components\\n\\nWhen creating new components, follow these patterns:\\n\\n1. Use the `@customElement` decorator to define the component\\n2. Encapsulate styles using the static `styles` property\\n3. Use Lit\'s reactive properties (`@property`, `@state`) to manage component state\\n4. Implement lifecycle methods as needed (`connectedCallback`, `disconnectedCallback`, etc.)\\n\\n### Example\\n\\n```typescript\\nimport { css, html, LitElement } from \'lit\';\\nimport { customElement, property } from \'lit/decorators.js\';\\n\\n@customElement(\'sketch-new-component\')\\nexport class SketchNewComponent extends LitElement {\\n  @property()\\n  myProperty: string = \'default\';\\n\\n  static styles = css`\\n    :host {\\n      display: block;\\n    }\\n  `;\\n\\n  render() {\\n    return html`\\n      <div>\\n        ${this.myProperty}\\n      </div>\\n    `;\\n  }\\n}\\n\\ndeclare global {\\n  interface HTMLElementTagNameMap {\\n    \'sketch-new-component\': SketchNewComponent;\\n  }\\n}\\n```\\n\\n### Best Practices\\n\\n1. Maintain a single responsibility for each component\\n2. Use properties and events for parent-child communication\\n3. Document the purpose and API of each component\\n4. Write tests for components where possible\\n5. Keep styles encapsulated within components\\n6. Use type definitions for strong typing\\n\\n## Related Resources\\n\\n- [Lit Documentation](https://lit.dev/docs/)\\n- [Web Components Introduction](https://developer.mozilla.org/en-US/docs/Web/Web_Components)\\n- See `/app/webui/readme.md` for more information on the Loop WebUI project\\n"}]}',
+            tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
+            result_message: {
+              type: "tool",
+              end_of_turn: false,
+              content: "",
+              tool_name: "patch",
+              input:
+                '{"path":"/app/webui/src/web-components/README.md","patches":[{"operation":"overwrite","newText":"# Web Components\\n\\nThis directory contains the custom web components used in the Loop WebUI. These components are built using the [Lit](https://lit.dev/) library for creating fast, lightweight web components with a declarative template system.\\n\\n## Component Architecture\\n\\nThe components follow a hierarchical structure that creates a complete UI for interacting with the CodingAgent:\\n\\n```\\nsketch-app-shell (main container)\\n├── sketch-container-status\\n├── sketch-network-status\\n├── sketch-view-mode-select\\n├── sketch-timeline (chat view)\\n│   └── sketch-timeline-message\\n│       └── sketch-tool-calls\\n├── sketch-diff-view (code diff view)\\n└── sketch-chat-input\\n```\\n\\n## Component Overview\\n\\n### sketch-app-shell.ts\\nThe main container component that orchestrates the entire UI. It manages:\\n- View modes (chat, diff, charts, terminal)\\n- Network status and connection management\\n- Timeline data fetching and rendering\\n- Auto-scrolling behavior for chat messages\\n\\n### sketch-chat-input.ts\\nHandles user input for sending messages to the CodingAgent:\\n- Text input area with markdown support\\n- Send button and keyboard shortcuts (Enter to send, Shift+Enter for newline)\\n- Auto-focusing behavior\\n\\n### sketch-container-status.ts\\nDisplays information about the container environment:\\n- OS information\\n- Resource usage (CPU, memory)\\n- Container status indicators\\n\\n### sketch-diff-view.ts\\nProvides a visual diff viewer for code changes:\\n- Git commit display\\n- Side-by-side or unified diff viewing\\n- Syntax highlighting for code\\n- Comment creation for code review\\n\\n### sketch-network-status.ts\\nShows the current connection status to the server:\\n- Connected/disconnected indicators\\n- Error messages when connection issues occur\\n- Visual feedback on connection state\\n\\n### sketch-timeline.ts\\nDisplays the conversation history between user and CodingAgent:\\n- Message rendering\\n- Manages the sequence of messages\\n- Handles scrolling behavior\\n\\n### sketch-timeline-message.ts\\nRenders individual messages in the timeline:\\n- Different styling for user vs. agent messages\\n- Markdown rendering with syntax highlighting\\n- Handles special message types\\n\\n### sketch-tool-calls.ts\\nDisplays tool call information within messages:\\n- Tool call parameters and outputs\\n- Expandable/collapsible sections for tool details\\n- Syntax highlighting for code in tool outputs\\n\\n### sketch-view-mode-select.ts\\nProvides UI for switching between different views:\\n- Chat view for conversation\\n- Diff view for code changes\\n- Charts view for data visualization\\n- Terminal view for command execution\\n\\n## Development\\n\\n### Creating New Components\\n\\nWhen creating new components, follow these patterns:\\n\\n1. Use the `@customElement` decorator to define the component\\n2. Encapsulate styles using the static `styles` property\\n3. Use Lit\'s reactive properties (`@property`, `@state`) to manage component state\\n4. Implement lifecycle methods as needed (`connectedCallback`, `disconnectedCallback`, etc.)\\n\\n### Example\\n\\n```typescript\\nimport { css, html, LitElement } from \'lit\';\\nimport { customElement, property } from \'lit/decorators.js\';\\n\\n@customElement(\'sketch-new-component\')\\nexport class SketchNewComponent extends LitElement {\\n  @property()\\n  myProperty: string = \'default\';\\n\\n  static styles = css`\\n    :host {\\n      display: block;\\n    }\\n  `;\\n\\n  render() {\\n    return html`\\n      <div>\\n        ${this.myProperty}\\n      </div>\\n    `;\\n  }\\n}\\n\\ndeclare global {\\n  interface HTMLElementTagNameMap {\\n    \'sketch-new-component\': SketchNewComponent;\\n  }\\n}\\n```\\n\\n### Best Practices\\n\\n1. Maintain a single responsibility for each component\\n2. Use properties and events for parent-child communication\\n3. Document the purpose and API of each component\\n4. Write tests for components where possible\\n5. Keep styles encapsulated within components\\n6. Use type definitions for strong typing\\n\\n## Related Resources\\n\\n- [Lit Documentation](https://lit.dev/docs/)\\n- [Web Components Introduction](https://developer.mozilla.org/en-US/docs/Web/Web_Components)\\n- See `/app/webui/readme.md` for more information on the Loop WebUI project\\n"}]}',
+              tool_result: "- Applied all patches\n",
+              tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
+              timestamp: "2025-04-14T16:32:47.10299854Z",
+              conversation_id: "xsa-8hw0",
+              start_time: "2025-04-14T16:32:47.101660683Z",
+              end_time: "2025-04-14T16:32:47.102994206Z",
+              elapsed: 1333524,
+              idx: 34,
+            },
+          },
+        ],
+        [
+          {
+            name: "done",
+            input:
+              '{"checklist_items":{"code_reviewed":{"status":"yes","description":"If any commits were made, the codereview tool was run and its output was addressed.","comments":"Since this commit only adds a documentation file (README.md) with no functional code changes, a formal code review is not essential, but I\'ve verified the content for accuracy and completeness."},"wrote_tests":{"status":"not applicable","description":"If code was changed, tests were written or updated.","comments":"No code was changed; only documentation was added. The README file is purely informational and doesn\'t require tests."},"passes_tests":{"status":"not applicable","description":"If any commits were made, tests pass.","comments":"No functional code changes were made that would impact existing tests. The addition of a README file is a documentation-only change."},"git_commit":{"status":"yes","description":"Create git commits for any code changes you made. Match the style of recent commit messages. Include \'Co-Authored-By: sketch\' and the original user prompt. Use GIT_AUTHOR_NAME=\\"Sean McCullough\\" GIT_AUTHOR_EMAIL=\\"banksean@gmail.com\\" (not git config).","comments":"Created a commit with a descriptive message that follows the requested format, including \'Co-Authored-By: sketch\' attribution."}}}',
+            tool_call_id: "toolu_01HPgWQJF1aF9LUqkdDKWeES",
+            result_message: {
+              type: "tool",
+              end_of_turn: false,
+              content: "",
+              tool_name: "done",
+              input:
+                '{"checklist_items":{"code_reviewed":{"status":"yes","description":"If any commits were made, the codereview tool was run and its output was addressed.","comments":"Since this commit only adds a documentation file (README.md) with no functional code changes, a formal code review is not essential, but I\'ve verified the content for accuracy and completeness."},"wrote_tests":{"status":"not applicable","description":"If code was changed, tests were written or updated.","comments":"No code was changed; only documentation was added. The README file is purely informational and doesn\'t require tests."},"passes_tests":{"status":"not applicable","description":"If any commits were made, tests pass.","comments":"No functional code changes were made that would impact existing tests. The addition of a README file is a documentation-only change."},"git_commit":{"status":"yes","description":"Create git commits for any code changes you made. Match the style of recent commit messages. Include \'Co-Authored-By: sketch\' and the original user prompt. Use GIT_AUTHOR_NAME=\\"Sean McCullough\\" GIT_AUTHOR_EMAIL=\\"banksean@gmail.com\\" (not git config).","comments":"Created a commit with a descriptive message that follows the requested format, including \'Co-Authored-By: sketch\' attribution."}}}',
+              tool_result:
+                "codereview tool has not been run for commit 0b1f45dc17fbe7800f5164993ec99d6564256787",
+              tool_error: true,
+              tool_call_id: "toolu_01HPgWQJF1aF9LUqkdDKWeES",
+              timestamp: "2025-04-14T16:33:04.639179373Z",
+              conversation_id: "xsa-8hw0",
+              start_time: "2025-04-14T16:33:04.616273148Z",
+              end_time: "2025-04-14T16:33:04.639173456Z",
+              elapsed: 22900309,
+              idx: 43,
+            },
+          },
+        ],
+      ];
+      document.addEventListener("DOMContentLoaded", () => {
+        toolCalls.forEach((calls) => {
+          const toolCallsEl = document.createElement("sketch-tool-calls");
+          toolCallsEl.toolCalls = calls;
+          document.body.append(toolCallsEl);
+        });
+      });
+    </script>
+  </head>
+  <body>
+    <h1>sketch-tool-calls demo</h1>
+
+    <sketch-tool-calls></sketch-tool-calls>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-tool-card.demo.html b/webui/src/web-components/demo/sketch-tool-card.demo.html
new file mode 100644
index 0000000..f8ba308
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-tool-card.demo.html
@@ -0,0 +1,254 @@
+<html>
+  <head>
+    <title>sketch-tool-card demo</title>
+    <link rel="stylesheet" href="demo.css" />
+
+    <script type="module" src="../sketch-tool-card.ts"></script>
+
+    <script>
+      const toolCalls = [
+        {
+          name: "bash",
+          input: JSON.stringify({
+            command:
+              "docker ps -a --format '{{.ID}} {{.Image }} {{.Names}}' | grep sketch | awk '{print $1 }' | xargs -I {} docker rm {} && docker image prune -af",
+          }),
+          result_message: {
+            type: "tool",
+            tool_result: `Deleted Images:
+deleted: sha256:110d4aed8bcc76cb7327412504af8aef31670b816453a3088d834bbeefd11a2c
+deleted: sha256:042622460c913078901555a8a72de18e95228fca98b9ac388503b3baafafb683
+deleted: sha256:04ccf3d087e258ffd5f940f378c2aab3c0ed646fb2fb283f90e65397db304694
+deleted: sha256:877120aa3efd02b6afdad181c1cd75bbdc67e41a75dd770fbf781e4fe9c95fc7
+deleted: sha256:d96824c284e594acacc631458818d07842fd4cfa3a1037668a1b23abce077d7b
+deleted: sha256:d90eef6007f5782b59643eecb3edab38af6399d4142f0bb306742efa0e1cf6a4
+deleted: sha256:66b006b0d7570ccf7e2afa15e7b6e6385debba0e60e76eb314383215e480a664
+deleted: sha256:834ff90a57edf5c3987a3f21713310d189f209cec7b002a863c75a22e24cc114
+deleted: sha256:735be867a9939611842099b1131e23096fbde47bb326416382ff7a90a86ab687
+deleted: sha256:986792e96058cabe4452eab0fda2694fe2d5f0b951c446c9c1f94d86614f7bc6
+deleted: sha256:01539d19a06b87dd7a2268677c6beb06bc5aed3cde0c52691a684f4d085bc437
+deleted: sha256:d03b7602a43340d6d1e53ad1d7daa5b55740613ad969c360e1377b7af7597eba
+deleted: sha256:5a7310817c5fa3e29ebfe5b17031fdc5789543460c790ae2e1039226044a6109
+deleted: sha256:def65005e4b1e48e9531ce6ca6bea682bd8285e32b0748212fb8ace12976f920
+deleted: sha256:3b17b8e4e349ac09bac24da27ec4d65e3dec359645f73bd9a38bf015ca5f8a98
+deleted: sha256:1bef4e5c965c2fa2658954096dbe64dae8f3b1d7d595bdb370d54f4027a95603
+deleted: sha256:16e6b5b274b06916833d3f040ca045a12fe1a6a10bebf5f92338fe6b4c7dbbf7
+deleted: sha256:d90588879cc818bc3b3b575a291a3c4088d0ea1c61fad2c4a2f34160bdc86db6
+deleted: sha256:85903960027c7b9baf8bd0ee662571758ce8ffe83526839377284e2fccac558f
+untagged: sketch-94924d08c163:latest
+deleted: sha256:7c7c3957d3ba526a351d21e52a1aee0e72bb4a62d0422a0eb3a0e2b53391824f
+deleted: sha256:e4a1fe6a3369ca8f24baaba277bc9d97353992e9e051020c5a25e588a702e634
+deleted: sha256:28ccbe834ee66199498458f500b10cc9ea69460216982a537ea3294d6dfb0b63
+deleted: sha256:95c7d2956020039d92b546d6824c5d7fac163a6247be599160483d263094c047
+deleted: sha256:f87bc9eb655a06edd50d5a34e016175006c430ad129146b9b755169a3c318a57
+deleted: sha256:b455829fdcd5fe238567af2370f9fc021eb416ec2140f98b0ab59478febcfb2e
+deleted: sha256:ed64271d223807308a391a733fc556a6c16bfb87e6f9aed6d4ce394fcbb77ba6
+deleted: sha256:a5ce6521003bca24abcb4a0021837e789349fb3f44f7ceb00ef4af33ca01f84f
+deleted: sha256:57e05db1ff95deab5f5c3f38f9607a1c3bb21518133f4e0c137ffe6bb9cbfde9
+deleted: sha256:540194db01e12f59d19f7795ec9c8a1bb753df2de935469b21a10fc7ca1d25a5
+deleted: sha256:97519dae495c256597a9b7975a332e67edb21f93e306b72132ed2c30bb01b8aa
+deleted: sha256:162c7a942156fd5f16616c6fea4a26f2bfa01a53e499d59fdb8c68e815f5350e
+deleted: sha256:51b9d76df1fbcb277e4f22496ff661d4d748f499453a27a012629f78bb61107e
+deleted: sha256:7a1a595c3015a6b2f5e996988d094bcaca328ebeaafe37403e78322e10d6b859
+deleted: sha256:27631f63a84d9a524381a95168f24deb89612fb468e03bce724f352bb5ef7b3b
+deleted: sha256:58746669dff4a4051d05542e05109d57c94f867981b47bdb5800d62567a6280f
+untagged: golang:1.24.2-alpine3.21
+untagged: golang@sha256:7772cb5322baa875edd74705556d08f0eeca7b9c4b5367754ce3f2f00041ccee
+untagged: sketch-3c262c60c42c:latest
+deleted: sha256:fadf166900e61610d77d613ce52ca1c03711ce2a7bcd31f1f634529791c0c107
+deleted: sha256:8b719162dad84cddd630e1e943520041947ca91b3794417c0d2a03b3726ebaa4
+deleted: sha256:444f0e44dcaff517142f8aab35d35f08536d886a746f6858dac7052977ee2cff
+deleted: sha256:a95a3660958ed25a27ae7b0622b5426e046d4c5587693aa7c0098e050e057311
+deleted: sha256:edb781114acb505bbde5e4a3db68b7ab6f4a3c0da92ceed2d10f02c6278b93c8
+deleted: sha256:1429402020a73b7d5c1de32f9451c68e22508cc4238750f5a500e1d9737eedae
+deleted: sha256:3f749e03b0f5ef2dfc538581c92230f2cd6b844fe3c734c728fd3775865ed24c
+deleted: sha256:f62c6ba2d4f4b94796d4c4c111031fbbbaf22df24623a2d6729277dc1eaf8da8
+deleted: sha256:504579f990b8894755910252d3b401f86a589709efafb30b9ded67cb3edad80e
+deleted: sha256:2e22f953ef8cc5fac95fb0babc5042f5e2a7fefc9d5ec444429c490d54acb1ab
+deleted: sha256:afa0c23676c039532a39faa1f1506b19f34507b586796ea070dcaee30e6228ef
+deleted: sha256:5f176f397253734bdc726a505c84448f9b00e5652d9a28ef59de0581a2e8e923
+deleted: sha256:253afbfd579bc6daf71e42b0f1e369d2b6c9015028191af4478da0b77b8a85ed
+deleted: sha256:81f79e13183887f93db52268f00975f43613abc520c88e1090a1dbb3d09094e9
+deleted: sha256:3c0b6f56bdbec5bf995b818e8a67d2d6c3bd9aa3698c403b6dabc01a81a4cb52
+deleted: sha256:635f4ba57c6445e69cf8c6fba61c3690f76901e17334f6d2d165979b2d387dfa
+
+Total reclaimed space: 1.426GB`,
+          },
+        },
+        {
+          name: "bash",
+          input: JSON.stringify({
+            command: "ls -a",
+          }),
+          result_message: {
+            type: "tool",
+            tool_result: ".\n..",
+          },
+        },
+        {
+          name: "bash",
+          input: JSON.stringify({
+            command: "sleep 200",
+          }),
+          result_message: {
+            type: "tool",
+            tool_error: "the user canceled this operation",
+          },
+        },
+        {
+          name: "title",
+          input: JSON.stringify({
+            title: "a new title for this sketch",
+          }),
+        },
+        {
+          name: "codereview",
+          input: "{}",
+          tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+          result_message: {
+            type: "tool",
+            end_of_turn: false,
+            content: "",
+            tool_name: "codereview",
+            input: "{}",
+            tool_result: "OK",
+            tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+            timestamp: "2025-04-14T16:33:17.575759565Z",
+            conversation_id: "xsa-8hw0",
+            start_time: "2025-04-14T16:33:07.11793816Z",
+            end_time: "2025-04-14T16:33:17.57575719Z",
+            elapsed: 10457819031,
+            idx: 45,
+          },
+        },
+        {
+          name: "codereview",
+          input: "{}",
+          tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+          result_message: {
+            type: "tool",
+            end_of_turn: false,
+            content: "",
+            tool_name: "codereview",
+            input: "{}",
+            tool_result: "Not OK",
+            tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+            timestamp: "2025-04-14T16:33:17.575759565Z",
+            conversation_id: "xsa-8hw0",
+            start_time: "2025-04-14T16:33:07.11793816Z",
+            end_time: "2025-04-14T16:33:17.57575719Z",
+            elapsed: 10457819031,
+            idx: 45,
+          },
+        },
+        {
+          name: "think",
+          input:
+            '{"thoughts":"I\'m going to inspect a few key components to understand their purpose and relationships:\\n1. sketch-app-shell.ts - Appears to be the main container component\\n2. sketch-timeline.ts - Likely manages the chat timeline\\n3. sketch-view-mode-select.ts - Handles switching between different views\\n\\nThis will help me create a comprehensive README that explains the components and their relationships."}',
+          tool_call_id: "toolu_01R1g5mQVgKxEJZFNp9QGvUr",
+          result_message: {
+            type: "tool",
+            end_of_turn: false,
+            content: "",
+            tool_name: "think",
+            input:
+              '{"thoughts":"I\'m going to inspect a few key components to understand their purpose and relationships:\\n1. sketch-app-shell.ts - Appears to be the main container component\\n2. sketch-timeline.ts - Likely manages the chat timeline\\n3. sketch-view-mode-select.ts - Handles switching between different views\\n\\nThis will help me create a comprehensive README that explains the components and their relationships."}',
+            tool_result: "recorded",
+            tool_call_id: "toolu_01R1g5mQVgKxEJZFNp9QGvUr",
+            timestamp: "2025-04-14T16:32:14.12647133Z",
+            conversation_id: "xsa-8hw0",
+            start_time: "2025-04-14T16:32:14.126454329Z",
+            end_time: "2025-04-14T16:32:14.126468539Z",
+            elapsed: 14209,
+            idx: 18,
+          },
+        },
+        {
+          name: "patch",
+          input:
+            '{"path":"/app/webui/src/web-components/README.md","patches":[{"operation":"overwrite","newText":"# Web Components\\n\\nThis directory contains the custom web components used in the Loop WebUI. These components are built using the [Lit](https://lit.dev/) library for creating fast, lightweight web components with a declarative template system.\\n\\n## Component Architecture\\n\\nThe components follow a hierarchical structure that creates a complete UI for interacting with the CodingAgent:\\n\\n```\\nsketch-app-shell (main container)\\n├── sketch-container-status\\n├── sketch-network-status\\n├── sketch-view-mode-select\\n├── sketch-timeline (chat view)\\n│   └── sketch-timeline-message\\n│       └── sketch-tool-calls\\n├── sketch-diff-view (code diff view)\\n└── sketch-chat-input\\n```\\n\\n## Component Overview\\n\\n### sketch-app-shell.ts\\nThe main container component that orchestrates the entire UI. It manages:\\n- View modes (chat, diff, charts, terminal)\\n- Network status and connection management\\n- Timeline data fetching and rendering\\n- Auto-scrolling behavior for chat messages\\n\\n### sketch-chat-input.ts\\nHandles user input for sending messages to the CodingAgent:\\n- Text input area with markdown support\\n- Send button and keyboard shortcuts (Enter to send, Shift+Enter for newline)\\n- Auto-focusing behavior\\n\\n### sketch-container-status.ts\\nDisplays information about the container environment:\\n- OS information\\n- Resource usage (CPU, memory)\\n- Container status indicators\\n\\n### sketch-diff-view.ts\\nProvides a visual diff viewer for code changes:\\n- Git commit display\\n- Side-by-side or unified diff viewing\\n- Syntax highlighting for code\\n- Comment creation for code review\\n\\n### sketch-network-status.ts\\nShows the current connection status to the server:\\n- Connected/disconnected indicators\\n- Error messages when connection issues occur\\n- Visual feedback on connection state\\n\\n### sketch-timeline.ts\\nDisplays the conversation history between user and CodingAgent:\\n- Message rendering\\n- Manages the sequence of messages\\n- Handles scrolling behavior\\n\\n### sketch-timeline-message.ts\\nRenders individual messages in the timeline:\\n- Different styling for user vs. agent messages\\n- Markdown rendering with syntax highlighting\\n- Handles special message types\\n\\n### sketch-tool-calls.ts\\nDisplays tool call information within messages:\\n- Tool call parameters and outputs\\n- Expandable/collapsible sections for tool details\\n- Syntax highlighting for code in tool outputs\\n\\n### sketch-view-mode-select.ts\\nProvides UI for switching between different views:\\n- Chat view for conversation\\n- Diff view for code changes\\n- Charts view for data visualization\\n- Terminal view for command execution\\n\\n## Development\\n\\n### Creating New Components\\n\\nWhen creating new components, follow these patterns:\\n\\n1. Use the `@customElement` decorator to define the component\\n2. Encapsulate styles using the static `styles` property\\n3. Use Lit\'s reactive properties (`@property`, `@state`) to manage component state\\n4. Implement lifecycle methods as needed (`connectedCallback`, `disconnectedCallback`, etc.)\\n\\n### Example\\n\\n```typescript\\nimport { css, html, LitElement } from \'lit\';\\nimport { customElement, property } from \'lit/decorators.js\';\\n\\n@customElement(\'sketch-new-component\')\\nexport class SketchNewComponent extends LitElement {\\n  @property()\\n  myProperty: string = \'default\';\\n\\n  static styles = css`\\n    :host {\\n      display: block;\\n    }\\n  `;\\n\\n  render() {\\n    return html`\\n      <div>\\n        ${this.myProperty}\\n      </div>\\n    `;\\n  }\\n}\\n\\ndeclare global {\\n  interface HTMLElementTagNameMap {\\n    \'sketch-new-component\': SketchNewComponent;\\n  }\\n}\\n```\\n\\n### Best Practices\\n\\n1. Maintain a single responsibility for each component\\n2. Use properties and events for parent-child communication\\n3. Document the purpose and API of each component\\n4. Write tests for components where possible\\n5. Keep styles encapsulated within components\\n6. Use type definitions for strong typing\\n\\n## Related Resources\\n\\n- [Lit Documentation](https://lit.dev/docs/)\\n- [Web Components Introduction](https://developer.mozilla.org/en-US/docs/Web/Web_Components)\\n- See `/app/webui/readme.md` for more information on the Loop WebUI project\\n"}]}',
+          tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
+          result_message: {
+            type: "tool",
+            end_of_turn: false,
+            content: "",
+            tool_name: "patch",
+            input:
+              '{"path":"/app/webui/src/web-components/README.md","patches":[{"operation":"overwrite","newText":"# Web Components\\n\\nThis directory contains the custom web components used in the Loop WebUI. These components are built using the [Lit](https://lit.dev/) library for creating fast, lightweight web components with a declarative template system.\\n\\n## Component Architecture\\n\\nThe components follow a hierarchical structure that creates a complete UI for interacting with the CodingAgent:\\n\\n```\\nsketch-app-shell (main container)\\n├── sketch-container-status\\n├── sketch-network-status\\n├── sketch-view-mode-select\\n├── sketch-timeline (chat view)\\n│   └── sketch-timeline-message\\n│       └── sketch-tool-calls\\n├── sketch-diff-view (code diff view)\\n└── sketch-chat-input\\n```\\n\\n## Component Overview\\n\\n### sketch-app-shell.ts\\nThe main container component that orchestrates the entire UI. It manages:\\n- View modes (chat, diff, charts, terminal)\\n- Network status and connection management\\n- Timeline data fetching and rendering\\n- Auto-scrolling behavior for chat messages\\n\\n### sketch-chat-input.ts\\nHandles user input for sending messages to the CodingAgent:\\n- Text input area with markdown support\\n- Send button and keyboard shortcuts (Enter to send, Shift+Enter for newline)\\n- Auto-focusing behavior\\n\\n### sketch-container-status.ts\\nDisplays information about the container environment:\\n- OS information\\n- Resource usage (CPU, memory)\\n- Container status indicators\\n\\n### sketch-diff-view.ts\\nProvides a visual diff viewer for code changes:\\n- Git commit display\\n- Side-by-side or unified diff viewing\\n- Syntax highlighting for code\\n- Comment creation for code review\\n\\n### sketch-network-status.ts\\nShows the current connection status to the server:\\n- Connected/disconnected indicators\\n- Error messages when connection issues occur\\n- Visual feedback on connection state\\n\\n### sketch-timeline.ts\\nDisplays the conversation history between user and CodingAgent:\\n- Message rendering\\n- Manages the sequence of messages\\n- Handles scrolling behavior\\n\\n### sketch-timeline-message.ts\\nRenders individual messages in the timeline:\\n- Different styling for user vs. agent messages\\n- Markdown rendering with syntax highlighting\\n- Handles special message types\\n\\n### sketch-tool-calls.ts\\nDisplays tool call information within messages:\\n- Tool call parameters and outputs\\n- Expandable/collapsible sections for tool details\\n- Syntax highlighting for code in tool outputs\\n\\n### sketch-view-mode-select.ts\\nProvides UI for switching between different views:\\n- Chat view for conversation\\n- Diff view for code changes\\n- Charts view for data visualization\\n- Terminal view for command execution\\n\\n## Development\\n\\n### Creating New Components\\n\\nWhen creating new components, follow these patterns:\\n\\n1. Use the `@customElement` decorator to define the component\\n2. Encapsulate styles using the static `styles` property\\n3. Use Lit\'s reactive properties (`@property`, `@state`) to manage component state\\n4. Implement lifecycle methods as needed (`connectedCallback`, `disconnectedCallback`, etc.)\\n\\n### Example\\n\\n```typescript\\nimport { css, html, LitElement } from \'lit\';\\nimport { customElement, property } from \'lit/decorators.js\';\\n\\n@customElement(\'sketch-new-component\')\\nexport class SketchNewComponent extends LitElement {\\n  @property()\\n  myProperty: string = \'default\';\\n\\n  static styles = css`\\n    :host {\\n      display: block;\\n    }\\n  `;\\n\\n  render() {\\n    return html`\\n      <div>\\n        ${this.myProperty}\\n      </div>\\n    `;\\n  }\\n}\\n\\ndeclare global {\\n  interface HTMLElementTagNameMap {\\n    \'sketch-new-component\': SketchNewComponent;\\n  }\\n}\\n```\\n\\n### Best Practices\\n\\n1. Maintain a single responsibility for each component\\n2. Use properties and events for parent-child communication\\n3. Document the purpose and API of each component\\n4. Write tests for components where possible\\n5. Keep styles encapsulated within components\\n6. Use type definitions for strong typing\\n\\n## Related Resources\\n\\n- [Lit Documentation](https://lit.dev/docs/)\\n- [Web Components Introduction](https://developer.mozilla.org/en-US/docs/Web/Web_Components)\\n- See `/app/webui/readme.md` for more information on the Loop WebUI project\\n"}]}',
+            tool_result: "- Applied all patches\n",
+            tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
+            timestamp: "2025-04-14T16:32:47.10299854Z",
+            conversation_id: "xsa-8hw0",
+            start_time: "2025-04-14T16:32:47.101660683Z",
+            end_time: "2025-04-14T16:32:47.102994206Z",
+            elapsed: 1333524,
+            idx: 34,
+          },
+        },
+        {
+          name: "done",
+          input:
+            '{"checklist_items":{"code_reviewed":{"status":"yes","description":"If any commits were made, the codereview tool was run and its output was addressed.","comments":"Since this commit only adds a documentation file (README.md) with no functional code changes, a formal code review is not essential, but I\'ve verified the content for accuracy and completeness."},"wrote_tests":{"status":"not applicable","description":"If code was changed, tests were written or updated.","comments":"No code was changed; only documentation was added. The README file is purely informational and doesn\'t require tests."},"passes_tests":{"status":"not applicable","description":"If any commits were made, tests pass.","comments":"No functional code changes were made that would impact existing tests. The addition of a README file is a documentation-only change."},"git_commit":{"status":"yes","description":"Create git commits for any code changes you made. Match the style of recent commit messages. Include \'Co-Authored-By: sketch\' and the original user prompt. Use GIT_AUTHOR_NAME=\\"Sean McCullough\\" GIT_AUTHOR_EMAIL=\\"banksean@gmail.com\\" (not git config).","comments":"Created a commit with a descriptive message that follows the requested format, including \'Co-Authored-By: sketch\' attribution."}}}',
+          tool_call_id: "toolu_01HPgWQJF1aF9LUqkdDKWeES",
+          result_message: {
+            type: "tool",
+            end_of_turn: false,
+            content: "",
+            tool_name: "done",
+            input:
+              '{"checklist_items":{"code_reviewed":{"status":"yes","description":"If any commits were made, the codereview tool was run and its output was addressed.","comments":"Since this commit only adds a documentation file (README.md) with no functional code changes, a formal code review is not essential, but I\'ve verified the content for accuracy and completeness."},"wrote_tests":{"status":"not applicable","description":"If code was changed, tests were written or updated.","comments":"No code was changed; only documentation was added. The README file is purely informational and doesn\'t require tests."},"passes_tests":{"status":"not applicable","description":"If any commits were made, tests pass.","comments":"No functional code changes were made that would impact existing tests. The addition of a README file is a documentation-only change."},"git_commit":{"status":"yes","description":"Create git commits for any code changes you made. Match the style of recent commit messages. Include \'Co-Authored-By: sketch\' and the original user prompt. Use GIT_AUTHOR_NAME=\\"Sean McCullough\\" GIT_AUTHOR_EMAIL=\\"banksean@gmail.com\\" (not git config).","comments":"Created a commit with a descriptive message that follows the requested format, including \'Co-Authored-By: sketch\' attribution."}}}',
+            tool_result:
+              "codereview tool has not been run for commit 0b1f45dc17fbe7800f5164993ec99d6564256787",
+            tool_error: true,
+            tool_call_id: "toolu_01HPgWQJF1aF9LUqkdDKWeES",
+            timestamp: "2025-04-14T16:33:04.639179373Z",
+            conversation_id: "xsa-8hw0",
+            start_time: "2025-04-14T16:33:04.616273148Z",
+            end_time: "2025-04-14T16:33:04.639173456Z",
+            elapsed: 22900309,
+            idx: 43,
+          },
+        },
+      ];
+      document.addEventListener("DOMContentLoaded", () => {
+        toolCalls.forEach((toolCall) => {
+          const h2El = document.createElement("h2");
+          h2El.innerText = toolCall.name;
+          document.body.append(h2El);
+
+          let toolCardEl = document.createElement("sketch-tool-card-generic");
+          switch (toolCall.name) {
+            case "bash":
+              toolCardEl = document.createElement("sketch-tool-card-bash");
+              break;
+            case "codereview":
+              toolCardEl = document.createElement(
+                "sketch-tool-card-codereview",
+              );
+              break;
+            case "done":
+              toolCardEl = document.createElement("sketch-tool-card-done");
+              break;
+            case "patch":
+              toolCardEl = document.createElement("sketch-tool-card-patch");
+              break;
+            case "think":
+              toolCardEl = document.createElement("sketch-tool-card-think");
+              break;
+            case "title":
+              toolCardEl = document.createElement("sketch-tool-card-title");
+              break;
+          }
+          toolCardEl.toolCall = toolCall;
+          toolCardEl.open = true;
+          document.body.append(toolCardEl);
+        });
+      });
+    </script>
+  </head>
+  <body>
+    <h1>sketch-tool-calls demo</h1>
+
+    <sketch-tool-calls></sketch-tool-calls>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-view-mode-select.demo.html b/webui/src/web-components/demo/sketch-view-mode-select.demo.html
new file mode 100644
index 0000000..0068616
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-view-mode-select.demo.html
@@ -0,0 +1,32 @@
+<html>
+  <head>
+    <title>sketch-view-mode-select demo</title>
+    <link rel="stylesheet" href="demo.css" />
+
+    <script type="module" src="../sketch-view-mode-select.ts"></script>
+
+    <script>
+      document.addEventListener("DOMContentLoaded", () => {
+        const viewModeSelect = document.querySelector(
+          "sketch-view-mode-select",
+        );
+        const msgDiv = document.querySelector("#selected-mode");
+        msgDiv.innerText = `selected mode: ${viewModeSelect.activeMode}`;
+
+        console.log("viewModeSelect: ", viewModeSelect);
+        viewModeSelect.addEventListener("view-mode-select", (evt) => {
+          console.log("view mode change event: ", evt);
+          const msgDiv = document.querySelector("#selected-mode");
+          msgDiv.innerText = `selected mode: ${evt.detail.mode}`;
+          viewModeSelect.activeMode = evt.detail.mode;
+        });
+      });
+    </script>
+  </head>
+  <body>
+    <h1>sketch-view-mode-select demo</h1>
+
+    <sketch-view-mode-select></sketch-view-mode-select>
+    <div id="selected-mode"></div>
+  </body>
+</html>
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
new file mode 100644
index 0000000..1dd3b6f
--- /dev/null
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -0,0 +1,603 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { DataManager, ConnectionStatus } from "../data";
+import { State, AgentMessage } from "../types";
+import "./sketch-container-status";
+import "./sketch-view-mode-select";
+import "./sketch-network-status";
+import "./sketch-timeline";
+import "./sketch-chat-input";
+import "./sketch-diff-view";
+import "./sketch-charts";
+import "./sketch-terminal";
+import { SketchDiffView } from "./sketch-diff-view";
+import { aggregateAgentMessages } from "./aggregateAgentMessages";
+
+type ViewMode = "chat" | "diff" | "charts" | "terminal";
+
+@customElement("sketch-app-shell")
+export class SketchAppShell extends LitElement {
+  // Current view mode (chat, diff, charts, terminal)
+  @state()
+  viewMode: "chat" | "diff" | "charts" | "terminal" = "chat";
+
+  // Current commit hash for diff view
+  @state()
+  currentCommitHash: string = "";
+
+  // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
+  // Note that these styles only apply to the scope of this web component's
+  // shadow DOM node, so they won't leak out or collide with CSS declared in
+  // other components or the containing web page (...unless you want it to do that).
+  static styles = css`
+    :host {
+      display: block;
+      font-family:
+        system-ui,
+        -apple-system,
+        BlinkMacSystemFont,
+        "Segoe UI",
+        Roboto,
+        sans-serif;
+      color: #333;
+      line-height: 1.4;
+      min-height: 100vh;
+      width: 100%;
+      position: relative;
+      overflow-x: hidden;
+    }
+
+    /* Top banner with combined elements */
+    .top-banner {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 5px 20px;
+      margin-bottom: 0;
+      border-bottom: 1px solid #eee;
+      gap: 10px;
+      position: fixed;
+      top: 0;
+      left: 0;
+      right: 0;
+      background: white;
+      z-index: 100;
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+      max-width: 100%;
+    }
+
+    .banner-title {
+      font-size: 18px;
+      font-weight: 600;
+      margin: 0;
+      min-width: 6em;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .chat-title {
+      margin: 0;
+      padding: 0;
+      color: rgba(82, 82, 82, 0.85);
+      font-size: 16px;
+      font-weight: normal;
+      font-style: italic;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    /* View mode container styles - mirroring timeline.css structure */
+    .view-container {
+      max-width: 1200px;
+      margin: 0 auto;
+      margin-top: 65px; /* Space for the top banner */
+      margin-bottom: 90px; /* Increased space for the chat input */
+      position: relative;
+      padding-bottom: 15px; /* Additional padding to prevent clipping */
+      padding-top: 15px; /* Add padding at top to prevent content touching the header */
+    }
+
+    /* Allow the container to expand to full width in diff mode */
+    .view-container.diff-active {
+      max-width: 100%;
+    }
+
+    /* Individual view styles */
+    .chat-view,
+    .diff-view,
+    .chart-view,
+    .terminal-view {
+      display: none; /* Hidden by default */
+      width: 100%;
+    }
+
+    /* Active view styles - these will be applied via JavaScript */
+    .view-active {
+      display: flex;
+      flex-direction: column;
+    }
+
+    .title-container {
+      display: flex;
+      flex-direction: column;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      max-width: 33%;
+    }
+
+    .refresh-control {
+      display: flex;
+      align-items: center;
+      margin-bottom: 0;
+      flex-wrap: nowrap;
+      white-space: nowrap;
+      flex-shrink: 0;
+    }
+
+    .refresh-button {
+      background: #4caf50;
+      color: white;
+      border: none;
+      padding: 4px 10px;
+      border-radius: 4px;
+      cursor: pointer;
+      font-size: 12px;
+      margin-right: 5px;
+    }
+
+    .stop-button:hover {
+      background-color: #c82333 !important;
+    }
+
+    .poll-updates {
+      display: flex;
+      align-items: center;
+      margin: 0 5px;
+      font-size: 12px;
+    }
+  `;
+
+  // Header bar: Network connection status details
+  @property()
+  connectionStatus: ConnectionStatus = "disconnected";
+
+  @property()
+  connectionErrorMessage: string = "";
+
+  @property()
+  messageStatus: string = "";
+
+  // Chat messages
+  @property({ attribute: false })
+  messages: AgentMessage[] = [];
+
+  @property()
+  title: string = "";
+
+  private dataManager = new DataManager();
+
+  @property({ attribute: false })
+  containerState: State = {
+    title: "",
+    os: "",
+    message_count: 0,
+    hostname: "",
+    working_dir: "",
+    initial_commit: "",
+  };
+
+  // Mutation observer to detect when new messages are added
+  private mutationObserver: MutationObserver | null = null;
+
+  constructor() {
+    super();
+    console.log("Hello!");
+
+    // Binding methods to this
+    this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
+    this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
+    this._handlePopState = this._handlePopState.bind(this);
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Initialize client-side nav history.
+    const url = new URL(window.location.href);
+    const mode = url.searchParams.get("view") || "chat";
+    window.history.replaceState({ mode }, "", url.toString());
+
+    this.toggleViewMode(mode as ViewMode, false);
+    // Add popstate event listener to handle browser back/forward navigation
+    window.addEventListener("popstate", this._handlePopState);
+
+    // Add event listeners
+    window.addEventListener("view-mode-select", this._handleViewModeSelect);
+    window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
+
+    // register event listeners
+    this.dataManager.addEventListener(
+      "dataChanged",
+      this.handleDataChanged.bind(this),
+    );
+    this.dataManager.addEventListener(
+      "connectionStatusChanged",
+      this.handleConnectionStatusChanged.bind(this),
+    );
+
+    // Initialize the data manager
+    this.dataManager.initialize();
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener("popstate", this._handlePopState);
+
+    // Remove event listeners
+    window.removeEventListener("view-mode-select", this._handleViewModeSelect);
+    window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
+
+    // unregister data manager event listeners
+    this.dataManager.removeEventListener(
+      "dataChanged",
+      this.handleDataChanged.bind(this),
+    );
+    this.dataManager.removeEventListener(
+      "connectionStatusChanged",
+      this.handleConnectionStatusChanged.bind(this),
+    );
+
+    // Disconnect mutation observer if it exists
+    if (this.mutationObserver) {
+      console.log("Auto-scroll: Disconnecting mutation observer");
+      this.mutationObserver.disconnect();
+      this.mutationObserver = null;
+    }
+  }
+
+  updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
+    // Get the current URL without search parameters
+    const url = new URL(window.location.href);
+
+    // Clear existing parameters
+    url.search = "";
+
+    // Only add view parameter if not in default chat view
+    if (mode !== "chat") {
+      url.searchParams.set("view", mode);
+      const diffView = this.shadowRoot?.querySelector(
+        ".diff-view",
+      ) as SketchDiffView;
+
+      // If in diff view and there's a commit hash, include that too
+      if (mode === "diff" && diffView.commitHash) {
+        url.searchParams.set("commit", diffView.commitHash);
+      }
+    }
+
+    // Update the browser history without reloading the page
+    window.history.pushState({ mode }, "", url.toString());
+  }
+
+  private _handlePopState(event: PopStateEvent) {
+    if (event.state && event.state.mode) {
+      this.toggleViewMode(event.state.mode, false);
+    } else {
+      this.toggleViewMode("chat", false);
+    }
+  }
+
+  /**
+   * Handle view mode selection event
+   */
+  private _handleViewModeSelect(event: CustomEvent) {
+    const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
+    this.toggleViewMode(mode, true);
+  }
+
+  /**
+   * Handle show commit diff event
+   */
+  private _handleShowCommitDiff(event: CustomEvent) {
+    const { commitHash } = event.detail;
+    if (commitHash) {
+      this.showCommitDiff(commitHash);
+    }
+  }
+
+  /**
+   * Listen for commit diff event
+   * @param commitHash The commit hash to show diff for
+   */
+  private showCommitDiff(commitHash: string): void {
+    // Store the commit hash
+    this.currentCommitHash = commitHash;
+
+    // Switch to diff view
+    this.toggleViewMode("diff", true);
+
+    // Wait for DOM update to complete
+    this.updateComplete.then(() => {
+      // Get the diff view component
+      const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
+      if (diffView) {
+        // Call the showCommitDiff method
+        (diffView as any).showCommitDiff(commitHash);
+      }
+    });
+  }
+
+  /**
+   * Toggle between different view modes: chat, diff, charts, terminal
+   */
+  private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
+    // Don't do anything if the mode is already active
+    if (this.viewMode === mode) return;
+
+    // Update the view mode
+    this.viewMode = mode;
+
+    if (updateHistory) {
+      // Update URL with the current view mode
+      this.updateUrlForViewMode(mode);
+    }
+
+    // Wait for DOM update to complete
+    this.updateComplete.then(() => {
+      // Update active view
+      const viewContainer = this.shadowRoot?.querySelector(".view-container");
+      const chatView = this.shadowRoot?.querySelector(".chat-view");
+      const diffView = this.shadowRoot?.querySelector(".diff-view");
+      const chartView = this.shadowRoot?.querySelector(".chart-view");
+      const terminalView = this.shadowRoot?.querySelector(".terminal-view");
+
+      // Remove active class from all views
+      chatView?.classList.remove("view-active");
+      diffView?.classList.remove("view-active");
+      chartView?.classList.remove("view-active");
+      terminalView?.classList.remove("view-active");
+
+      // Add/remove diff-active class on view container
+      if (mode === "diff") {
+        viewContainer?.classList.add("diff-active");
+      } else {
+        viewContainer?.classList.remove("diff-active");
+      }
+
+      // Add active class to the selected view
+      switch (mode) {
+        case "chat":
+          chatView?.classList.add("view-active");
+          break;
+        case "diff":
+          diffView?.classList.add("view-active");
+          // Load diff content if we have a diff view
+          const diffViewComp =
+            this.shadowRoot?.querySelector("sketch-diff-view");
+          if (diffViewComp && this.currentCommitHash) {
+            (diffViewComp as any).showCommitDiff(this.currentCommitHash);
+          } else if (diffViewComp) {
+            (diffViewComp as any).loadDiffContent();
+          }
+          break;
+        case "charts":
+          chartView?.classList.add("view-active");
+          break;
+        case "terminal":
+          terminalView?.classList.add("view-active");
+          break;
+      }
+
+      // Update view mode buttons
+      const viewModeSelect = this.shadowRoot?.querySelector(
+        "sketch-view-mode-select",
+      );
+      if (viewModeSelect) {
+        const event = new CustomEvent("update-active-mode", {
+          detail: { mode },
+          bubbles: true,
+          composed: true,
+        });
+        viewModeSelect.dispatchEvent(event);
+      }
+
+      // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
+      // When the chart is in the background, its container has a width of 0, so vega
+      // renders width 0 and only changes that width on a resize event.
+      // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
+      window.dispatchEvent(new Event("resize"));
+    });
+  }
+
+  private handleDataChanged(eventData: {
+    state: State;
+    newMessages: AgentMessage[];
+    isFirstFetch?: boolean;
+  }): void {
+    const { state, newMessages, isFirstFetch } = eventData;
+
+    // Check if this is the first data fetch or if there are new messages
+    if (isFirstFetch) {
+      this.messageStatus = "Initial messages loaded";
+    } else if (newMessages && newMessages.length > 0) {
+      this.messageStatus = "Updated just now";
+    } else {
+      this.messageStatus = "No new messages";
+    }
+
+    // Update state if we received it
+    if (state) {
+      this.containerState = state;
+      this.title = state.title;
+    }
+
+    // Create a copy of the current messages before updating
+    const oldMessageCount = this.messages.length;
+
+    // Update messages
+    this.messages = aggregateAgentMessages(this.messages, newMessages);
+
+    // Log information about the message update
+    if (this.messages.length > oldMessageCount) {
+      console.log(
+        `Auto-scroll: Messages updated from ${oldMessageCount} to ${this.messages.length}`,
+      );
+    }
+  }
+
+  private handleConnectionStatusChanged(
+    status: ConnectionStatus,
+    errorMessage?: string,
+  ): void {
+    this.connectionStatus = status;
+    this.connectionErrorMessage = errorMessage || "";
+  }
+
+  async _sendChat(e: CustomEvent) {
+    console.log("app shell: _sendChat", e);
+    const message = e.detail.message?.trim();
+    if (message == "") {
+      return;
+    }
+    try {
+      // Send the message to the server
+      const response = await fetch("chat", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({ message }),
+      });
+
+      if (!response.ok) {
+        const errorData = await response.text();
+        throw new Error(`Server error: ${response.status} - ${errorData}`);
+      }
+
+      // TOOD(philip): If the data manager is getting messages out of order, there's a bug?
+      // Reset data manager state to force a full refresh after sending a message
+      // This ensures we get all messages in the correct order
+      // Use private API for now - TODO: add a resetState() method to DataManager
+      (this.dataManager as any).nextFetchIndex = 0;
+      (this.dataManager as any).currentFetchStartIndex = 0;
+
+      // // If in diff view, switch to conversation view
+      // if (this.viewMode === "diff") {
+      //   await this.toggleViewMode("chat");
+      // }
+
+      // Refresh the timeline data to show the new message
+      await this.dataManager.fetchData();
+    } catch (error) {
+      console.error("Error sending chat message:", error);
+      const statusText = document.getElementById("statusText");
+      if (statusText) {
+        statusText.textContent = "Error sending message";
+      }
+    }
+  }
+
+  render() {
+    return html`
+      <div class="top-banner">
+        <div class="title-container">
+          <h1 class="banner-title">sketch</h1>
+          <h2 id="chatTitle" class="chat-title">${this.title}</h2>
+        </div>
+
+        <sketch-container-status
+          .state=${this.containerState}
+        ></sketch-container-status>
+
+        <div class="refresh-control">
+          <sketch-view-mode-select></sketch-view-mode-select>
+
+          <button id="stopButton" class="refresh-button stop-button">
+            Stop
+          </button>
+
+          <div class="poll-updates">
+            <input type="checkbox" id="pollToggle" checked />
+            <label for="pollToggle">Poll</label>
+          </div>
+
+          <sketch-network-status
+            message=${this.messageStatus}
+            connection=${this.connectionStatus}
+            error=${this.connectionErrorMessage}
+          ></sketch-network-status>
+        </div>
+      </div>
+
+      <div class="view-container">
+        <div class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}">
+          <sketch-timeline
+            .messages=${this.messages}
+            .scrollContainer=${this}
+          ></sketch-timeline>
+        </div>
+
+        <div class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}">
+          <sketch-diff-view
+            .commitHash=${this.currentCommitHash}
+          ></sketch-diff-view>
+        </div>
+
+        <div
+          class="chart-view ${this.viewMode === "charts" ? "view-active" : ""}"
+        >
+          <sketch-charts .messages=${this.messages}></sketch-charts>
+        </div>
+
+        <div
+          class="terminal-view ${this.viewMode === "terminal"
+            ? "view-active"
+            : ""}"
+        >
+          <sketch-terminal></sketch-terminal>
+        </div>
+      </div>
+
+      <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
+    `;
+  }
+
+  /**
+   * Lifecycle callback when component is first connected to DOM
+   */
+  firstUpdated(): void {
+    if (this.viewMode !== "chat") {
+      return;
+    }
+
+    // Initial scroll to bottom when component is first rendered
+    setTimeout(
+      () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
+      50,
+    );
+
+    const pollToggleCheckbox = this.renderRoot?.querySelector(
+      "#pollToggle",
+    ) as HTMLInputElement;
+    pollToggleCheckbox?.addEventListener("change", () => {
+      this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
+      if (!pollToggleCheckbox.checked) {
+        this.connectionStatus = "disabled";
+        this.messageStatus = "Polling stopped";
+      } else {
+        this.messageStatus = "Polling for updates...";
+      }
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-app-shell": SketchAppShell;
+  }
+}
diff --git a/webui/src/web-components/sketch-charts.ts b/webui/src/web-components/sketch-charts.ts
new file mode 100644
index 0000000..8cf2606
--- /dev/null
+++ b/webui/src/web-components/sketch-charts.ts
@@ -0,0 +1,498 @@
+import "./vega-embed";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { TopLevelSpec } from "vega-lite";
+import type { AgentMessage } from "../types";
+import "vega-embed";
+import { VisualizationSpec } from "vega-embed";
+
+/**
+ * Web component for rendering charts related to the timeline data
+ * Displays cumulative cost over time and message timing visualization
+ */
+@customElement("sketch-charts")
+export class SketchCharts extends LitElement {
+  @property({ type: Array })
+  messages: AgentMessage[] = [];
+
+  @state()
+  private chartData: { timestamp: Date; cost: number }[] = [];
+
+  // We need to make the styles available to Vega-Embed when it's rendered
+  static styles = css`
+    :host {
+      display: block;
+      width: 100%;
+    }
+
+    .chart-container {
+      padding: 20px;
+      background-color: #fff;
+      border-radius: 8px;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+      margin-bottom: 20px;
+    }
+
+    .chart-section {
+      margin-bottom: 30px;
+    }
+
+    .chart-section h3 {
+      margin-top: 0;
+      margin-bottom: 15px;
+      font-size: 18px;
+      color: #333;
+      border-bottom: 1px solid #eee;
+      padding-bottom: 8px;
+    }
+
+    .chart-content {
+      width: 100%;
+      min-height: 300px;
+    }
+
+    .loader {
+      border: 4px solid #f3f3f3;
+      border-radius: 50%;
+      border-top: 4px solid #3498db;
+      width: 40px;
+      height: 40px;
+      margin: 20px auto;
+      animation: spin 2s linear infinite;
+    }
+
+    @keyframes spin {
+      0% {
+        transform: rotate(0deg);
+      }
+      100% {
+        transform: rotate(360deg);
+      }
+    }
+  `;
+
+  constructor() {
+    super();
+    this.chartData = [];
+  }
+
+  private calculateCumulativeCostData(
+    messages: AgentMessage[],
+  ): { 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;
+  }
+
+  protected willUpdate(changedProperties: PropertyValues): void {
+    if (changedProperties.has("messages")) {
+      this.chartData = this.calculateCumulativeCostData(this.messages);
+    }
+  }
+
+  private getMessagesChartSpec(): VisualizationSpec {
+    try {
+      const allMessages = this.messages;
+      if (!Array.isArray(allMessages) || allMessages.length === 0) {
+        return null;
+      }
+
+      // 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>();
+      let messageIdx = 0;
+
+      // First pass: Process parent messages
+      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, messageIdx++);
+      });
+
+      // Process tool calls from messages to account for filtered out tool messages
+      const toolCallData: any[] = [];
+      allMessages.forEach((msg) => {
+        if (msg.tool_calls && msg.tool_calls.length > 0) {
+          msg.tool_calls.forEach((toolCall) => {
+            if (toolCall.result_message) {
+              // Add this tool result message to our data
+              const resultMsg = toolCall.result_message;
+
+              // Important: use the original message's idx to maintain the correct order
+              // The original message idx value is what we want to show in the chart
+              if (resultMsg.idx !== undefined) {
+                // If the tool call has start/end times, add it to bar data, otherwise to point data
+                if (resultMsg.start_time && resultMsg.end_time) {
+                  toolCallData.push({
+                    type: "bar",
+                    index: resultMsg.idx, // Use actual idx from message
+                    message_type: "tool",
+                    content: resultMsg.content || "",
+                    tool_name: resultMsg.tool_name || toolCall.name || "",
+                    tool_input: toolCall.input || "",
+                    tool_result: resultMsg.tool_result || "",
+                    start_time: new Date(resultMsg.start_time).toISOString(),
+                    end_time: new Date(resultMsg.end_time).toISOString(),
+                    message: JSON.stringify(resultMsg, null, 2),
+                  });
+                } else if (resultMsg.timestamp) {
+                  toolCallData.push({
+                    type: "point",
+                    index: resultMsg.idx, // Use actual idx from message
+                    message_type: "tool",
+                    content: resultMsg.content || "",
+                    tool_name: resultMsg.tool_name || toolCall.name || "",
+                    tool_input: toolCall.input || "",
+                    tool_result: resultMsg.tool_result || "",
+                    time: new Date(resultMsg.timestamp).toISOString(),
+                    message: JSON.stringify(resultMsg, null, 2),
+                  });
+                }
+              }
+            }
+          });
+        }
+      });
+
+      // 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!);
+
+          // Use the message idx directly for consistent ordering
+          const index = msg.idx;
+
+          // 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!);
+
+          // Use the message idx directly for consistent ordering
+          const index = msg.idx;
+
+          // 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
+          };
+        });
+
+      // Add tool call data to the appropriate arrays
+      const toolBarData = toolCallData
+        .filter((d) => d.type === "bar")
+        .map((d) => {
+          delete d.type;
+          return d;
+        });
+
+      const toolPointData = toolCallData
+        .filter((d) => d.type === "point")
+        .map((d) => {
+          delete d.type;
+          return d;
+        });
+
+      // Check if we have any data to display
+      if (
+        barData.length === 0 &&
+        pointData.length === 0 &&
+        toolBarData.length === 0 &&
+        toolPointData.length === 0
+      ) {
+        return null;
+      }
+
+      // 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 || toolBarData.length > 0) {
+        const combinedBarData = [...barData, ...toolBarData];
+        messagesSpec.layer.push({
+          data: { values: combinedBarData },
+          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 || toolPointData.length > 0) {
+        const combinedPointData = [...pointData, ...toolPointData];
+        messagesSpec.layer.push({
+          data: { values: combinedPointData },
+          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" },
+            ],
+          },
+        });
+      }
+      return messagesSpec;
+    } catch (error) {
+      console.error("Error rendering messages chart:", error);
+    }
+  }
+
+  render() {
+    const costSpec = this.createCostChartSpec();
+    const messagesSpec = this.getMessagesChartSpec();
+
+    return html`
+      <div class="chart-container" id="chartContainer">
+        <div class="chart-section">
+          <h3>Dollar Usage Over Time</h3>
+          <div class="chart-content">
+            ${this.chartData.length > 0
+              ? html`<vega-embed .spec=${costSpec}></vega-embed>`
+              : html`<p>No cost data available to display.</p>`}
+          </div>
+        </div>
+        <div class="chart-section">
+          <h3>Message Timeline</h3>
+          <div class="chart-content">
+            ${messagesSpec?.data
+              ? html`<vega-embed .spec=${messagesSpec}></vega-embed>`
+              : html`<p>No messages available to display.</p>`}
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private createCostChartSpec(): VisualizationSpec {
+    return {
+      $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",
+          },
+        ],
+      },
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-charts": SketchCharts;
+  }
+}
diff --git a/webui/src/web-components/sketch-chat-input.test.ts b/webui/src/web-components/sketch-chat-input.test.ts
new file mode 100644
index 0000000..efb303f
--- /dev/null
+++ b/webui/src/web-components/sketch-chat-input.test.ts
@@ -0,0 +1,163 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchChatInput } from "./sketch-chat-input";
+
+test("initializes with empty content by default", async ({ mount }) => {
+  const component = await mount(SketchChatInput, {});
+
+  // Check public property via component's evaluate method
+  const content = await component.evaluate((el: SketchChatInput) => el.content);
+  expect(content).toBe("");
+
+  // Check textarea value
+  await expect(component.locator("#chatInput")).toHaveValue("");
+});
+
+test("initializes with provided content", async ({ mount }) => {
+  const testContent = "Hello, world!";
+  const component = await mount(SketchChatInput, {
+    props: {
+      content: testContent,
+    },
+  });
+
+  // Check public property via component's evaluate method
+  const content = await component.evaluate((el: SketchChatInput) => el.content);
+  expect(content).toBe(testContent);
+
+  // Check textarea value
+  await expect(component.locator("#chatInput")).toHaveValue(testContent);
+});
+
+test("updates content when typing in the textarea", async ({ mount }) => {
+  const component = await mount(SketchChatInput, {});
+  const newValue = "New message";
+
+  // Fill the textarea with new content
+  await component.locator("#chatInput").fill(newValue);
+
+  // Check that the content property was updated
+  const content = await component.evaluate((el: SketchChatInput) => el.content);
+  expect(content).toBe(newValue);
+});
+
+test("sends message when clicking the send button", async ({ mount }) => {
+  const testContent = "Test message";
+  const component = await mount(SketchChatInput, {
+    props: {
+      content: testContent,
+    },
+  });
+
+  // Set up promise to wait for the event
+  const eventPromise = component.evaluate((el) => {
+    return new Promise((resolve) => {
+      el.addEventListener(
+        "send-chat",
+        (event) => {
+          resolve((event as CustomEvent).detail);
+        },
+        { once: true },
+      );
+    });
+  });
+
+  // Click the send button
+  await component.locator("#sendChatButton").click();
+
+  // Wait for the event and check its details
+  const detail: any = await eventPromise;
+  expect(detail.message).toBe(testContent);
+});
+
+test.skip("sends message when pressing Enter (without shift)", async ({
+  mount,
+}) => {
+  const testContent = "Test message";
+  const component = await mount(SketchChatInput, {
+    props: {
+      content: testContent,
+    },
+  });
+
+  // Set up promise to wait for the event
+  const eventPromise = component.evaluate((el) => {
+    return new Promise((resolve) => {
+      el.addEventListener(
+        "send-chat",
+        (event) => {
+          resolve((event as CustomEvent).detail);
+        },
+        { once: true },
+      );
+    });
+  });
+
+  // Press Enter in the textarea
+  await component.locator("#chatInput").press("Enter");
+
+  // Wait for the event and check its details
+  const detail: any = await eventPromise;
+  expect(detail.message).toBe(testContent);
+
+  // Check that content was cleared
+  const content = await component.evaluate((el: SketchChatInput) => el.content);
+  expect(content).toBe("");
+});
+
+test.skip("does not send message when pressing Shift+Enter", async ({
+  mount,
+}) => {
+  const testContent = "Test message";
+  const component = await mount(SketchChatInput, {
+    props: {
+      content: testContent,
+    },
+  });
+
+  // Set up to track if event fires
+  let eventFired = false;
+  await component.evaluate((el) => {
+    el.addEventListener("send-chat", () => {
+      (window as any).__eventFired = true;
+    });
+    (window as any).__eventFired = false;
+  });
+
+  // Press Shift+Enter in the textarea
+  await component.locator("#chatInput").press("Shift+Enter");
+
+  // Wait a short time and check if event fired
+  await new Promise((resolve) => setTimeout(resolve, 50));
+  eventFired = await component.evaluate(() => (window as any).__eventFired);
+  expect(eventFired).toBe(false);
+
+  // Check that content was not cleared
+  const content = await component.evaluate((el: SketchChatInput) => el.content);
+  expect(content).toBe(testContent);
+});
+
+test("resizes when user enters more text than will fit", async ({ mount }) => {
+  const testContent = "Test message\n\n\n\n\n\n\n\n\n\n\n\n\nends here.";
+  const component = await mount(SketchChatInput, {
+    props: {
+      content: "",
+    },
+  });
+  const origHeight = await component.evaluate(
+    (el: SketchChatInput) => el.chatInput.style.height,
+  );
+
+  // Enter very tall text in the textarea
+  await component.locator("#chatInput").fill(testContent);
+
+  // Wait for the requestAnimationFrame to complete
+  await component.evaluate(() => new Promise(requestAnimationFrame));
+
+  // Check that textarea resized
+  const newHeight = await component.evaluate(
+    (el: SketchChatInput) => el.chatInput.style.height,
+  );
+  expect(Number.parseInt(newHeight)).toBeGreaterThan(
+    Number.parseInt(origHeight),
+  );
+});
diff --git a/webui/src/web-components/sketch-chat-input.ts b/webui/src/web-components/sketch-chat-input.ts
new file mode 100644
index 0000000..74e462f
--- /dev/null
+++ b/webui/src/web-components/sketch-chat-input.ts
@@ -0,0 +1,176 @@
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state, query } from "lit/decorators.js";
+
+@customElement("sketch-chat-input")
+export class SketchChatInput extends LitElement {
+  @state()
+  content: string = "";
+
+  // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
+  // Note that these styles only apply to the scope of this web component's
+  // shadow DOM node, so they won't leak out or collide with CSS declared in
+  // other components or the containing web page (...unless you want it to do that).
+  static styles = css`
+    /* Chat styles - exactly matching timeline.css */
+    .chat-container {
+      position: fixed;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      background: #f0f0f0;
+      padding: 15px;
+      box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
+      z-index: 1000;
+      min-height: 40px; /* Ensure minimum height */
+    }
+
+    .chat-input-wrapper {
+      display: flex;
+      max-width: 1200px;
+      margin: 0 auto;
+      gap: 10px;
+    }
+
+    #chatInput {
+      flex: 1;
+      padding: 12px;
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      resize: vertical;
+      font-family: monospace;
+      font-size: 12px;
+      min-height: 40px;
+      max-height: 300px;
+      background: #f7f7f7;
+      overflow-y: auto;
+      box-sizing: border-box; /* Ensure padding is included in height calculation */
+      line-height: 1.4; /* Consistent line height for better height calculation */
+    }
+
+    #sendChatButton {
+      background-color: #2196f3;
+      color: white;
+      border: none;
+      border-radius: 4px;
+      padding: 0 20px;
+      cursor: pointer;
+      font-weight: 600;
+      align-self: center;
+      height: 40px;
+    }
+
+    #sendChatButton:hover {
+      background-color: #0d8bf2;
+    }
+  `;
+
+  constructor() {
+    super();
+    this._handleDiffComment = this._handleDiffComment.bind(this);
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    window.addEventListener("diff-comment", this._handleDiffComment);
+  }
+
+  private _handleDiffComment(event: CustomEvent) {
+    const { comment } = event.detail;
+    if (!comment) return;
+
+    if (this.content != "") {
+      this.content += "\n\n";
+    }
+    this.content += comment;
+    requestAnimationFrame(() => this.adjustChatSpacing());
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener("diff-comment", this._handleDiffComment);
+  }
+
+  sendChatMessage() {
+    const event = new CustomEvent("send-chat", {
+      detail: { message: this.content },
+      bubbles: true,
+      composed: true,
+    });
+    this.dispatchEvent(event);
+
+    // TODO(philip?): Ideally we only clear the content if the send is successful.
+    this.content = ""; // Clear content after sending
+  }
+
+  adjustChatSpacing() {
+    if (!this.chatInput) return;
+
+    // Reset height to minimal value to correctly calculate scrollHeight
+    this.chatInput.style.height = "auto";
+
+    // Get the scroll height (content height)
+    const scrollHeight = this.chatInput.scrollHeight;
+
+    // Set the height to match content (up to max-height which is handled by CSS)
+    this.chatInput.style.height = `${scrollHeight}px`;
+  }
+
+  async _sendChatClicked() {
+    this.sendChatMessage();
+    this.chatInput.focus(); // Refocus the input after sending
+    // Reset height after sending a message
+    requestAnimationFrame(() => this.adjustChatSpacing());
+  }
+
+  _chatInputKeyDown(event: KeyboardEvent) {
+    // Send message if Enter is pressed without Shift key
+    if (event.key === "Enter" && !event.shiftKey) {
+      event.preventDefault(); // Prevent default newline
+      this.sendChatMessage();
+    }
+  }
+
+  _chatInputChanged(event) {
+    this.content = event.target.value;
+    // Use requestAnimationFrame to ensure DOM updates have completed
+    requestAnimationFrame(() => this.adjustChatSpacing());
+  }
+
+  @query("#chatInput")
+  chatInput: HTMLTextAreaElement;
+
+  protected firstUpdated(): void {
+    if (this.chatInput) {
+      this.chatInput.focus();
+      // Initialize the input height
+      this.adjustChatSpacing();
+    }
+  }
+
+  render() {
+    return html`
+      <div class="chat-container">
+        <div class="chat-input-wrapper">
+          <textarea
+            id="chatInput"
+            placeholder="Type your message here and press Enter to send..."
+            autofocus
+            @keydown="${this._chatInputKeyDown}"
+            @input="${this._chatInputChanged}"
+            .value=${this.content || ""}
+          ></textarea>
+          <button @click="${this._sendChatClicked}" id="sendChatButton">
+            Send
+          </button>
+        </div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-chat-input": SketchChatInput;
+  }
+}
diff --git a/webui/src/web-components/sketch-container-status.test.ts b/webui/src/web-components/sketch-container-status.test.ts
new file mode 100644
index 0000000..db11a4e
--- /dev/null
+++ b/webui/src/web-components/sketch-container-status.test.ts
@@ -0,0 +1,171 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchContainerStatus } from "./sketch-container-status";
+import { State } from "../types";
+
+// Mock complete state for testing
+const mockCompleteState: State = {
+  hostname: "test-host",
+  working_dir: "/test/dir",
+  initial_commit: "abcdef1234567890",
+  message_count: 42,
+  os: "linux",
+  title: "Test Session",
+  total_usage: {
+    input_tokens: 1000,
+    output_tokens: 2000,
+    cache_read_input_tokens: 300,
+    cache_creation_input_tokens: 400,
+    total_cost_usd: 0.25,
+    start_time: "",
+    messages: 0,
+    tool_uses: {},
+  },
+};
+
+test("render props", async ({ mount }) => {
+  const component = await mount(SketchContainerStatus, {
+    props: {
+      state: mockCompleteState,
+    },
+  });
+  await expect(component.locator("#hostname")).toContainText(
+    mockCompleteState.hostname,
+  );
+  // Check that all expected elements exist
+  await expect(component.locator("#workingDir")).toContainText(
+    mockCompleteState.working_dir,
+  );
+  await expect(component.locator("#initialCommit")).toContainText(
+    mockCompleteState.initial_commit.substring(0, 8),
+  );
+
+  await expect(component.locator("#messageCount")).toContainText(
+    mockCompleteState.message_count + "",
+  );
+  await expect(component.locator("#inputTokens")).toContainText(
+    mockCompleteState.total_usage.input_tokens + "",
+  );
+  await expect(component.locator("#outputTokens")).toContainText(
+    mockCompleteState.total_usage.output_tokens + "",
+  );
+
+  await expect(component.locator("#cacheReadInputTokens")).toContainText(
+    mockCompleteState.total_usage.cache_read_input_tokens + "",
+  );
+  await expect(component.locator("#cacheCreationInputTokens")).toContainText(
+    mockCompleteState.total_usage.cache_creation_input_tokens + "",
+  );
+  await expect(component.locator("#totalCost")).toContainText(
+    "$" + mockCompleteState.total_usage.total_cost_usd.toFixed(2),
+  );
+});
+
+test("renders with undefined state", async ({ mount }) => {
+  const component = await mount(SketchContainerStatus, {});
+
+  // Elements should exist but be empty
+  await expect(component.locator("#hostname")).toContainText("");
+  await expect(component.locator("#workingDir")).toContainText("");
+  await expect(component.locator("#initialCommit")).toContainText("");
+  await expect(component.locator("#messageCount")).toContainText("");
+  await expect(component.locator("#inputTokens")).toContainText("");
+  await expect(component.locator("#outputTokens")).toContainText("");
+  await expect(component.locator("#totalCost")).toContainText("$0.00");
+});
+
+test("renders with partial state data", async ({ mount }) => {
+  const partialState: Partial<State> = {
+    hostname: "partial-host",
+    message_count: 10,
+    os: "linux",
+    title: "Partial Test",
+    total_usage: {
+      input_tokens: 500,
+      start_time: "",
+      messages: 0,
+      output_tokens: 0,
+      cache_read_input_tokens: 0,
+      cache_creation_input_tokens: 0,
+      total_cost_usd: 0,
+      tool_uses: {},
+    },
+  };
+
+  const component = await mount(SketchContainerStatus, {
+    props: {
+      state: partialState as State,
+    },
+  });
+
+  // Check that elements with data are properly populated
+  await expect(component.locator("#hostname")).toContainText("partial-host");
+  await expect(component.locator("#messageCount")).toContainText("10");
+  await expect(component.locator("#inputTokens")).toContainText("500");
+
+  // Check that elements without data are empty
+  await expect(component.locator("#workingDir")).toContainText("");
+  await expect(component.locator("#initialCommit")).toContainText("");
+  await expect(component.locator("#outputTokens")).toContainText("");
+  await expect(component.locator("#totalCost")).toContainText("$0.00");
+});
+
+test("handles cost formatting correctly", async ({ mount }) => {
+  // Test with different cost values
+  const testCases = [
+    { cost: 0, expected: "$0.00" },
+    { cost: 0.1, expected: "$0.10" },
+    { cost: 1.234, expected: "$1.23" },
+    { cost: 10.009, expected: "$10.01" },
+  ];
+
+  for (const testCase of testCases) {
+    const stateWithCost = {
+      ...mockCompleteState,
+      total_usage: {
+        ...mockCompleteState.total_usage,
+        total_cost_usd: testCase.cost,
+      },
+    };
+
+    const component = await mount(SketchContainerStatus, {
+      props: {
+        state: stateWithCost,
+      },
+    });
+    await expect(component.locator("#totalCost")).toContainText(
+      testCase.expected,
+    );
+    await component.unmount();
+  }
+});
+
+test("truncates commit hash to 8 characters", async ({ mount }) => {
+  const stateWithLongCommit = {
+    ...mockCompleteState,
+    initial_commit: "1234567890abcdef1234567890abcdef12345678",
+  };
+
+  const component = await mount(SketchContainerStatus, {
+    props: {
+      state: stateWithLongCommit,
+    },
+  });
+
+  await expect(component.locator("#initialCommit")).toContainText("12345678");
+});
+
+test("has correct link elements", async ({ mount }) => {
+  const component = await mount(SketchContainerStatus, {
+    props: {
+      state: mockCompleteState,
+    },
+  });
+
+  // Check for logs link
+  const logsLink = component.locator("a").filter({ hasText: "Logs" });
+  await expect(logsLink).toHaveAttribute("href", "logs");
+
+  // Check for download link
+  const downloadLink = component.locator("a").filter({ hasText: "Download" });
+  await expect(downloadLink).toHaveAttribute("href", "download");
+});
diff --git a/webui/src/web-components/sketch-container-status.ts b/webui/src/web-components/sketch-container-status.ts
new file mode 100644
index 0000000..9e542cb
--- /dev/null
+++ b/webui/src/web-components/sketch-container-status.ts
@@ -0,0 +1,237 @@
+import { State } from "../types";
+import { LitElement, css, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+@customElement("sketch-container-status")
+export class SketchContainerStatus extends LitElement {
+  // Header bar: Container status details
+
+  @property()
+  state: State;
+
+  // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
+  // Note that these styles only apply to the scope of this web component's
+  // shadow DOM node, so they won't leak out or collide with CSS declared in
+  // other components or the containing web page (...unless you want it to do that).
+  static styles = css`
+    .info-card {
+      background: #f9f9f9;
+      border-radius: 8px;
+      padding: 15px;
+      margin-bottom: 20px;
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+      display: none; /* Hidden in the combined layout */
+    }
+
+    .info-grid {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      background: #f9f9f9;
+      border-radius: 4px;
+      padding: 4px 10px;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+      flex: 1;
+    }
+
+    .info-item {
+      display: flex;
+      align-items: center;
+      white-space: nowrap;
+      margin-right: 10px;
+      font-size: 13px;
+    }
+
+    .info-label {
+      font-size: 11px;
+      color: #555;
+      margin-right: 3px;
+      font-weight: 500;
+    }
+
+    .info-value {
+      font-size: 11px;
+      font-weight: 600;
+    }
+
+    [title] {
+      cursor: help;
+      text-decoration: underline dotted;
+    }
+
+    .cost {
+      color: #2e7d32;
+    }
+
+    .info-item a {
+      --tw-text-opacity: 1;
+      color: rgb(37 99 235 / var(--tw-text-opacity, 1));
+      text-decoration: inherit;
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  formatHostname() {
+    const outsideHostname = this.state?.outside_hostname;
+    const insideHostname = this.state?.inside_hostname;
+
+    if (!outsideHostname || !insideHostname) {
+      return this.state?.hostname;
+    }
+
+    if (outsideHostname === insideHostname) {
+      return outsideHostname;
+    }
+
+    return `${outsideHostname}:${insideHostname}`;
+  }
+
+  formatWorkingDir() {
+    const outsideWorkingDir = this.state?.outside_working_dir;
+    const insideWorkingDir = this.state?.inside_working_dir;
+
+    if (!outsideWorkingDir || !insideWorkingDir) {
+      return this.state?.working_dir;
+    }
+
+    if (outsideWorkingDir === insideWorkingDir) {
+      return outsideWorkingDir;
+    }
+
+    return `${outsideWorkingDir}:${insideWorkingDir}`;
+  }
+
+  getHostnameTooltip() {
+    const outsideHostname = this.state?.outside_hostname;
+    const insideHostname = this.state?.inside_hostname;
+
+    if (
+      !outsideHostname ||
+      !insideHostname ||
+      outsideHostname === insideHostname
+    ) {
+      return "";
+    }
+
+    return `Outside: ${outsideHostname}, Inside: ${insideHostname}`;
+  }
+
+  getWorkingDirTooltip() {
+    const outsideWorkingDir = this.state?.outside_working_dir;
+    const insideWorkingDir = this.state?.inside_working_dir;
+
+    if (
+      !outsideWorkingDir ||
+      !insideWorkingDir ||
+      outsideWorkingDir === insideWorkingDir
+    ) {
+      return "";
+    }
+
+    return `Outside: ${outsideWorkingDir}, Inside: ${insideWorkingDir}`;
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+    // register event listeners
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    // unregister event listeners
+  }
+
+  render() {
+    return html`
+      <div class="info-grid">
+        <div class="info-item">
+          <a href="logs">Logs</a>
+        </div>
+        <div class="info-item">
+          <a href="download">Download</a>
+        </div>
+        <div class="info-item">
+          <span
+            id="hostname"
+            class="info-value"
+            title="${this.getHostnameTooltip()}"
+          >
+            ${this.formatHostname()}
+          </span>
+        </div>
+        <div class="info-item">
+          <span
+            id="workingDir"
+            class="info-value"
+            title="${this.getWorkingDirTooltip()}"
+          >
+            ${this.formatWorkingDir()}
+          </span>
+        </div>
+        ${this.state?.git_origin
+          ? html`
+              <div class="info-item">
+                <span class="info-label">Origin:</span>
+                <span id="gitOrigin" class="info-value"
+                  >${this.state?.git_origin}</span
+                >
+              </div>
+            `
+          : ""}
+        <div class="info-item">
+          <span class="info-label">Commit:</span>
+          <span id="initialCommit" class="info-value"
+            >${this.state?.initial_commit?.substring(0, 8)}</span
+          >
+        </div>
+        <div class="info-item">
+          <span class="info-label">Msgs:</span>
+          <span id="messageCount" class="info-value"
+            >${this.state?.message_count}</span
+          >
+        </div>
+        <div class="info-item">
+          <span class="info-label">In:</span>
+          <span id="inputTokens" class="info-value"
+            >${this.state?.total_usage?.input_tokens}</span
+          >
+        </div>
+        <div class="info-item">
+          <span class="info-label">Cache Read:</span>
+          <span id="cacheReadInputTokens" class="info-value"
+            >${this.state?.total_usage?.cache_read_input_tokens}</span
+          >
+        </div>
+        <div class="info-item">
+          <span class="info-label">Cache Create:</span>
+          <span id="cacheCreationInputTokens" class="info-value"
+            >${this.state?.total_usage?.cache_creation_input_tokens}</span
+          >
+        </div>
+        <div class="info-item">
+          <span class="info-label">Out:</span>
+          <span id="outputTokens" class="info-value"
+            >${this.state?.total_usage?.output_tokens}</span
+          >
+        </div>
+        <div class="info-item">
+          <span class="info-label">Cost:</span>
+          <span id="totalCost" class="info-value cost"
+            >$${(this.state?.total_usage?.total_cost_usd || 0).toFixed(2)}</span
+          >
+        </div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-container-status": SketchContainerStatus;
+  }
+}
diff --git a/webui/src/web-components/sketch-diff-view.ts b/webui/src/web-components/sketch-diff-view.ts
new file mode 100644
index 0000000..47c14f3
--- /dev/null
+++ b/webui/src/web-components/sketch-diff-view.ts
@@ -0,0 +1,615 @@
+import { css, html, LitElement, unsafeCSS } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import * as Diff2Html from "diff2html";
+
+@customElement("sketch-diff-view")
+export class SketchDiffView extends LitElement {
+  // Current commit hash being viewed
+  @property({ type: String })
+  commitHash: string = "";
+
+  // Selected line in the diff for commenting
+  @state()
+  private selectedDiffLine: string | null = null;
+
+  // The clicked button element used for positioning the comment box
+  @state()
+  private clickedElement: HTMLElement | null = null;
+
+  // View format (side-by-side or line-by-line)
+  @state()
+  private viewFormat: "side-by-side" | "line-by-line" = "side-by-side";
+
+  static styles = css`
+    .diff-view {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+      height: 100%;
+    }
+
+    .diff-container {
+      height: 100%;
+      overflow: auto;
+      flex: 1;
+      padding: 0 1rem;
+    }
+
+    #diff-view-controls {
+      display: flex;
+      justify-content: flex-end;
+      padding: 10px;
+      background: #f8f8f8;
+      border-bottom: 1px solid #eee;
+    }
+
+    .diff-view-format {
+      display: flex;
+      gap: 10px;
+    }
+
+    .diff-view-format label {
+      cursor: pointer;
+    }
+
+    .diff2html-content {
+      font-family: var(--monospace-font);
+      position: relative;
+    }
+
+    /* Comment box styles */
+    .diff-comment-box {
+      position: absolute;
+      width: 400px;
+      background-color: white;
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+      padding: 16px;
+      z-index: 1000;
+      margin-top: 10px;
+    }
+
+    .diff-comment-box h3 {
+      margin-top: 0;
+      margin-bottom: 10px;
+      font-size: 16px;
+    }
+
+    .selected-line {
+      margin-bottom: 10px;
+      font-size: 14px;
+    }
+
+    .selected-line pre {
+      padding: 6px;
+      background: #f5f5f5;
+      border: 1px solid #eee;
+      border-radius: 3px;
+      margin: 5px 0;
+      max-height: 100px;
+      overflow: auto;
+      font-family: var(--monospace-font);
+      font-size: 13px;
+      white-space: pre-wrap;
+    }
+
+    #diffCommentInput {
+      width: 100%;
+      height: 100px;
+      padding: 8px;
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      resize: vertical;
+      font-family: inherit;
+      margin-bottom: 10px;
+    }
+
+    .diff-comment-buttons {
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+    }
+
+    .diff-comment-buttons button {
+      padding: 6px 12px;
+      border-radius: 4px;
+      border: 1px solid #ddd;
+      background: white;
+      cursor: pointer;
+    }
+
+    .diff-comment-buttons button:hover {
+      background: #f5f5f5;
+    }
+
+    .diff-comment-buttons button#submitDiffComment {
+      background: #1a73e8;
+      color: white;
+      border-color: #1a73e8;
+    }
+
+    .diff-comment-buttons button#submitDiffComment:hover {
+      background: #1967d2;
+    }
+
+    /* Styles for the comment button on diff lines */
+    .d2h-gutter-comment-button {
+      position: absolute;
+      right: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      visibility: hidden;
+      background: rgba(255, 255, 255, 0.8);
+      border-radius: 50%;
+      width: 16px;
+      height: 16px;
+      line-height: 13px;
+      text-align: center;
+      font-size: 14px;
+      cursor: pointer;
+      color: #666;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+    }
+
+    tr:hover .d2h-gutter-comment-button {
+      visibility: visible;
+    }
+
+    .d2h-gutter-comment-button:hover {
+      background: white;
+      color: #333;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Load the diff2html CSS if needed
+    this.loadDiff2HtmlCSS();
+  }
+
+  // Load diff2html CSS into the shadow DOM
+  private async loadDiff2HtmlCSS() {
+    try {
+      // Check if diff2html styles are already loaded
+      const styleId = "diff2html-styles";
+      if (this.shadowRoot?.getElementById(styleId)) {
+        return; // Already loaded
+      }
+
+      // Fetch the diff2html CSS
+      const response = await fetch("static/diff2html.min.css");
+
+      if (!response.ok) {
+        console.error(
+          `Failed to load diff2html CSS: ${response.status} ${response.statusText}`,
+        );
+        return;
+      }
+
+      const cssText = await response.text();
+
+      // Create a style element and append to shadow DOM
+      const style = document.createElement("style");
+      style.id = styleId;
+      style.textContent = cssText;
+      this.shadowRoot?.appendChild(style);
+
+      console.log("diff2html CSS loaded into shadow DOM");
+    } catch (error) {
+      console.error("Error loading diff2html CSS:", error);
+    }
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  // Method called to load diff content
+  async loadDiffContent() {
+    // Wait for the component to be rendered
+    await this.updateComplete;
+
+    const diff2htmlContent =
+      this.shadowRoot?.getElementById("diff2htmlContent");
+    if (!diff2htmlContent) return;
+
+    try {
+      // Build the diff URL - include commit hash if specified
+      const diffUrl = this.commitHash
+        ? `diff?commit=${this.commitHash}`
+        : "diff";
+
+      if (this.commitHash) {
+        diff2htmlContent.innerHTML = `Loading diff for commit <strong>${this.commitHash}</strong>...`;
+      } else {
+        diff2htmlContent.innerHTML = "Loading 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;
+      }
+
+      // Render the diff using diff2html
+      const diffHtml = Diff2Html.html(diffText, {
+        outputFormat: this.viewFormat,
+        drawFileList: true,
+        matching: "lines",
+        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.setupDiffLineComments();
+    } 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>`;
+    }
+  }
+
+  // Handle view format changes
+  private handleViewFormatChange(event: Event) {
+    const input = event.target as HTMLInputElement;
+    if (input.checked) {
+      this.viewFormat = input.value as "side-by-side" | "line-by-line";
+      this.loadDiffContent();
+    }
+  }
+
+  /**
+   * Setup handlers for diff code lines to enable commenting
+   */
+  private setupDiffLineComments(): void {
+    const diff2htmlContent =
+      this.shadowRoot?.getElementById("diff2htmlContent");
+    if (!diff2htmlContent) return;
+
+    // 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 and store the clicked element for positioning
+        this.clickedElement = target;
+        this.openDiffCommentBox(formattedLine);
+
+        // Prevent event from bubbling up
+        event.stopPropagation();
+      }
+    });
+  }
+
+  /**
+   * Add plus buttons to each table row in the diff for commenting
+   */
+  private addCommentButtonsToCodeLines(): void {
+    const diff2htmlContent =
+      this.shadowRoot?.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): void {
+    // Make sure the comment box div exists
+    const commentBoxId = "diffCommentBox";
+    let commentBox = this.shadowRoot?.getElementById(commentBoxId);
+
+    // If it doesn't exist, create it
+    if (!commentBox) {
+      commentBox = document.createElement("div");
+      commentBox.id = commentBoxId;
+      commentBox.className = "diff-comment-box";
+
+      // Create the comment box contents
+      commentBox.innerHTML = `
+        <h3>Add a comment</h3>
+        <div class="selected-line">
+          Line:
+          <pre id="selectedLine"></pre>
+        </div>
+        <textarea
+          id="diffCommentInput"
+          placeholder="Enter your comment about this line..."
+        ></textarea>
+        <div class="diff-comment-buttons">
+          <button id="cancelDiffComment">Cancel</button>
+          <button id="submitDiffComment">Add Comment</button>
+        </div>
+      `;
+
+      // Append the comment box to the diff container to ensure proper positioning
+      const diffContainer = this.shadowRoot?.querySelector(".diff-container");
+      if (diffContainer) {
+        diffContainer.appendChild(commentBox);
+      } else {
+        this.shadowRoot?.appendChild(commentBox);
+      }
+    }
+
+    // Store the selected line
+    this.selectedDiffLine = lineText;
+
+    // Display the line in the comment box
+    const selectedLine = this.shadowRoot?.getElementById("selectedLine");
+    if (selectedLine) {
+      selectedLine.textContent = lineText;
+    }
+
+    // Reset the comment input
+    const commentInput = this.shadowRoot?.getElementById(
+      "diffCommentInput",
+    ) as HTMLTextAreaElement;
+    if (commentInput) {
+      commentInput.value = "";
+    }
+
+    // Show the comment box and position it below the clicked line
+    if (commentBox && this.clickedElement) {
+      // Get the row that contains the clicked button
+      const row = this.clickedElement.closest("tr");
+      if (row) {
+        // Get the position of the row
+        const rowRect = row.getBoundingClientRect();
+        const diffContainerRect = this.shadowRoot
+          ?.querySelector(".diff-container")
+          ?.getBoundingClientRect();
+
+        if (diffContainerRect) {
+          // Position the comment box below the row
+          const topPosition =
+            rowRect.bottom -
+            diffContainerRect.top +
+            this.shadowRoot!.querySelector(".diff-container")!.scrollTop;
+          const leftPosition = rowRect.left - diffContainerRect.left;
+
+          commentBox.style.top = `${topPosition}px`;
+          commentBox.style.left = `${leftPosition}px`;
+          commentBox.style.display = "block";
+        }
+      } else {
+        // Fallback if we can't find the row
+        commentBox.style.display = "block";
+      }
+    } else if (commentBox) {
+      // Fallback if we don't have clickedElement
+      commentBox.style.display = "block";
+    }
+
+    // Add event listeners for submit and cancel buttons
+    const submitButton = this.shadowRoot?.getElementById("submitDiffComment");
+    if (submitButton) {
+      submitButton.onclick = () => this.submitDiffComment();
+    }
+
+    const cancelButton = this.shadowRoot?.getElementById("cancelDiffComment");
+    if (cancelButton) {
+      cancelButton.onclick = () => this.closeDiffCommentBox();
+    }
+
+    // Focus on the comment input
+    if (commentInput) {
+      commentInput.focus();
+    }
+  }
+
+  /**
+   * Close the diff comment box without submitting
+   */
+  private closeDiffCommentBox(): void {
+    const commentBox = this.shadowRoot?.getElementById("diffCommentBox");
+    if (commentBox) {
+      commentBox.style.display = "none";
+    }
+    this.selectedDiffLine = null;
+    this.clickedElement = null;
+  }
+
+  /**
+   * Submit a comment on a diff line
+   */
+  private submitDiffComment(): void {
+    const commentInput = this.shadowRoot?.getElementById(
+      "diffCommentInput",
+    ) as HTMLTextAreaElement;
+
+    if (!commentInput) 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}`;
+
+    // Dispatch a custom event with the formatted comment
+    const event = new CustomEvent("diff-comment", {
+      detail: { comment: formattedComment },
+      bubbles: true,
+      composed: true,
+    });
+    this.dispatchEvent(event);
+
+    // Close only the comment box but keep the diff view open
+    this.closeDiffCommentBox();
+  }
+
+  // Clear the current state
+  public clearState(): void {
+    this.commitHash = "";
+  }
+
+  // Show diff for a specific commit
+  public showCommitDiff(commitHash: string): void {
+    // Store the commit hash
+    this.commitHash = commitHash;
+    // Load the diff content
+    this.loadDiffContent();
+  }
+
+  render() {
+    return html`
+      <div class="diff-view">
+        <div class="diff-container">
+          <div id="diff-view-controls">
+            <div class="diff-view-format">
+              <label>
+                <input
+                  type="radio"
+                  name="diffViewFormat"
+                  value="side-by-side"
+                  ?checked=${this.viewFormat === "side-by-side"}
+                  @change=${this.handleViewFormatChange}
+                />
+                Side-by-side
+              </label>
+              <label>
+                <input
+                  type="radio"
+                  name="diffViewFormat"
+                  value="line-by-line"
+                  ?checked=${this.viewFormat === "line-by-line"}
+                  @change=${this.handleViewFormatChange}
+                />
+                Line-by-line
+              </label>
+            </div>
+          </div>
+          <div id="diff2htmlContent" class="diff2html-content"></div>
+        </div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-diff-view": SketchDiffView;
+  }
+}
diff --git a/webui/src/web-components/sketch-network-status.test.ts b/webui/src/web-components/sketch-network-status.test.ts
new file mode 100644
index 0000000..45882a0
--- /dev/null
+++ b/webui/src/web-components/sketch-network-status.test.ts
@@ -0,0 +1,65 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchNetworkStatus } from "./sketch-network-status";
+
+test("displays the correct connection status when connected", async ({
+  mount,
+}) => {
+  const component = await mount(SketchNetworkStatus, {
+    props: {
+      connection: "connected",
+      message: "Connected to server",
+    },
+  });
+
+  await expect(component.locator(".polling-indicator")).toBeVisible();
+  await expect(component.locator(".status-text")).toBeVisible();
+  await expect(component.locator(".polling-indicator.active")).toBeVisible();
+  await expect(component.locator(".status-text")).toContainText(
+    "Connected to server",
+  );
+});
+
+test("displays the correct connection status when disconnected", async ({
+  mount,
+}) => {
+  const component = await mount(SketchNetworkStatus, {
+    props: {
+      connection: "disconnected",
+      message: "Disconnected",
+    },
+  });
+
+  await expect(component.locator(".polling-indicator")).toBeVisible();
+  await expect(component.locator(".polling-indicator.error")).toBeVisible();
+});
+
+test("displays the correct connection status when disabled", async ({
+  mount,
+}) => {
+  const component = await mount(SketchNetworkStatus, {
+    props: {
+      connection: "disabled",
+      message: "Disabled",
+    },
+  });
+
+  await expect(component.locator(".polling-indicator")).toBeVisible();
+  await expect(component.locator(".polling-indicator.error")).not.toBeVisible();
+  await expect(
+    component.locator(".polling-indicator.active"),
+  ).not.toBeVisible();
+});
+
+test("displays error message when provided", async ({ mount }) => {
+  const errorMsg = "Connection error";
+  const component = await mount(SketchNetworkStatus, {
+    props: {
+      connection: "disconnected",
+      message: "Disconnected",
+      error: errorMsg,
+    },
+  });
+
+  await expect(component.locator(".status-text")).toBeVisible();
+  await expect(component.locator(".status-text")).toContainText(errorMsg);
+});
diff --git a/webui/src/web-components/sketch-network-status.ts b/webui/src/web-components/sketch-network-status.ts
new file mode 100644
index 0000000..2a0e455
--- /dev/null
+++ b/webui/src/web-components/sketch-network-status.ts
@@ -0,0 +1,103 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+@customElement("sketch-network-status")
+export class SketchNetworkStatus extends LitElement {
+  @property()
+  connection: string;
+
+  @property()
+  message: string;
+
+  @property()
+  error: string;
+
+  // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
+  // Note that these styles only apply to the scope of this web component's
+  // shadow DOM node, so they won't leak out or collide with CSS declared in
+  // other components or the containing web page (...unless you want it to do that).
+
+  static styles = css`
+    .status-container {
+      display: flex;
+      align-items: center;
+    }
+
+    .polling-indicator {
+      display: inline-block;
+      width: 8px;
+      height: 8px;
+      border-radius: 50%;
+      margin-right: 4px;
+      background-color: #ccc;
+    }
+
+    .polling-indicator.active {
+      background-color: #4caf50;
+      animation: pulse 1.5s infinite;
+    }
+
+    .polling-indicator.error {
+      background-color: #f44336;
+      animation: pulse 1.5s infinite;
+    }
+
+    @keyframes pulse {
+      0% {
+        opacity: 1;
+      }
+      50% {
+        opacity: 0.5;
+      }
+      100% {
+        opacity: 1;
+      }
+    }
+
+    .status-text {
+      font-size: 11px;
+      color: #666;
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  indicator() {
+    if (this.connection === "disabled") {
+      return "";
+    }
+    return this.connection === "connected" ? "active" : "error";
+  }
+
+  render() {
+    return html`
+      <div class="status-container">
+        <span
+          id="pollingIndicator"
+          class="polling-indicator ${this.indicator()}"
+        ></span>
+        <span id="statusText" class="status-text"
+          >${this.error || this.message}</span
+        >
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-network-status": SketchNetworkStatus;
+  }
+}
diff --git a/webui/src/web-components/sketch-terminal.ts b/webui/src/web-components/sketch-terminal.ts
new file mode 100644
index 0000000..4ffccfd
--- /dev/null
+++ b/webui/src/web-components/sketch-terminal.ts
@@ -0,0 +1,365 @@
+import { Terminal } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+
+import { css, html, LitElement } from "lit";
+import { customElement } from "lit/decorators.js";
+import "./sketch-container-status";
+
+@customElement("sketch-terminal")
+export class SketchTerminal extends LitElement {
+  // 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;
+
+  static styles = css`
+    /* Terminal View Styles */
+    .terminal-view {
+      width: 100%;
+      background-color: #f5f5f5;
+      border-radius: 8px;
+      overflow: hidden;
+      margin-bottom: 20px;
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+      padding: 15px;
+      height: 70vh;
+    }
+
+    .terminal-container {
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+  `;
+
+  constructor() {
+    super();
+    this._resizeHandler = this._resizeHandler.bind(this);
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.loadXtermlCSS();
+    // Setup resize handler
+    window.addEventListener("resize", this._resizeHandler);
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener("resize", this._resizeHandler);
+
+    this.closeTerminalConnections();
+
+    if (this.terminal) {
+      this.terminal.dispose();
+      this.terminal = null;
+    }
+    this.fitAddon = null;
+  }
+
+  firstUpdated() {
+    this.initializeTerminal();
+  }
+
+  _resizeHandler() {
+    if (this.fitAddon) {
+      this.fitAddon.fit();
+      // Send resize information to server
+      this.sendTerminalResize();
+    }
+  }
+
+  // Load xterm CSS into the shadow DOM
+  private async loadXtermlCSS() {
+    try {
+      // Check if diff2html styles are already loaded
+      const styleId = "xterm-styles";
+      if (this.shadowRoot?.getElementById(styleId)) {
+        return; // Already loaded
+      }
+
+      // Fetch the diff2html CSS
+      const response = await fetch("static/xterm.css");
+
+      if (!response.ok) {
+        console.error(
+          `Failed to load xterm CSS: ${response.status} ${response.statusText}`,
+        );
+        return;
+      }
+
+      const cssText = await response.text();
+
+      // Create a style element and append to shadow DOM
+      const style = document.createElement("style");
+      style.id = styleId;
+      style.textContent = cssText;
+      this.renderRoot?.appendChild(style);
+
+      console.log("xterm CSS loaded into shadow DOM");
+    } catch (error) {
+      console.error("Error loading xterm CSS:", error);
+    }
+  }
+
+  /**
+   * Initialize the terminal component
+   * @param terminalContainer The DOM element to contain the terminal
+   */
+  public async initializeTerminal(): Promise<void> {
+    const terminalContainer = this.renderRoot.querySelector(
+      "#terminalContainer",
+    ) as HTMLElement;
+
+    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();
+
+    // 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 {
+            // @ts-ignore This isn't in the type definitions yet; it's pretty new?!?
+            const decoded = base64ToUint8Array(event.data);
+            this.terminal.write(decoded);
+          } catch (e) {
+            console.error("Error decoding terminal data:", e);
+          }
+        }
+      };
+
+      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);
+    }
+  }
+
+  render() {
+    return html`
+      <div id="terminalView" class="terminal-view">
+        <div id="terminalContainer" class="terminal-container"></div>
+      </div>
+    `;
+  }
+}
+
+function base64ToUint8Array(base64String) {
+  // This isn't yet available in Chrome, but Safari has it!
+  // @ts-ignore
+  if (Uint8Array.fromBase64) {
+    // @ts-ignore
+    return Uint8Array.fromBase64(base64String);
+  }
+
+  const binaryString = atob(base64String);
+  return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-terminal": SketchTerminal;
+  }
+}
diff --git a/webui/src/web-components/sketch-timeline-message.test.ts b/webui/src/web-components/sketch-timeline-message.test.ts
new file mode 100644
index 0000000..bc74202
--- /dev/null
+++ b/webui/src/web-components/sketch-timeline-message.test.ts
@@ -0,0 +1,311 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchTimelineMessage } from "./sketch-timeline-message";
+import {
+  AgentMessage,
+  CodingAgentMessageType,
+  GitCommit,
+  Usage,
+} from "../types";
+
+// Helper function to create mock timeline messages
+function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
+  return {
+    idx: props.idx || 0,
+    type: props.type || "agent",
+    content: props.content || "Hello world",
+    timestamp: props.timestamp || "2023-05-15T12:00:00Z",
+    elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds
+    end_of_turn: props.end_of_turn || false,
+    conversation_id: props.conversation_id || "conv123",
+    tool_calls: props.tool_calls || [],
+    commits: props.commits || [],
+    usage: props.usage,
+    ...props,
+  };
+}
+
+test("renders with basic message content", async ({ mount }) => {
+  const message = createMockMessage({
+    type: "agent",
+    content: "This is a test message",
+  });
+
+  const component = await mount(SketchTimelineMessage, {
+    props: {
+      message: message,
+    },
+  });
+
+  await expect(component.locator(".message-text")).toBeVisible();
+  await expect(component.locator(".message-text")).toContainText(
+    "This is a test message",
+  );
+});
+
+test.skip("renders with correct message type classes", async ({ mount }) => {
+  const messageTypes: CodingAgentMessageType[] = [
+    "user",
+    "agent",
+    "error",
+    "budget",
+    "tool",
+    "commit",
+    "auto",
+  ];
+
+  for (const type of messageTypes) {
+    const message = createMockMessage({ type });
+
+    const component = await mount(SketchTimelineMessage, {
+      props: {
+        message: message,
+      },
+    });
+
+    await expect(component.locator(".message")).toBeVisible();
+    await expect(component.locator(`.message.${type}`)).toBeVisible();
+  }
+});
+
+test("renders end-of-turn marker correctly", async ({ mount }) => {
+  const message = createMockMessage({
+    end_of_turn: true,
+  });
+
+  const component = await mount(SketchTimelineMessage, {
+    props: {
+      message: message,
+    },
+  });
+
+  await expect(component.locator(".message")).toBeVisible();
+  await expect(component.locator(".message.end-of-turn")).toBeVisible();
+});
+
+test("formats timestamps correctly", async ({ mount }) => {
+  const message = createMockMessage({
+    timestamp: "2023-05-15T12:00:00Z",
+  });
+
+  const component = await mount(SketchTimelineMessage, {
+    props: {
+      message: message,
+    },
+  });
+
+  await expect(component.locator(".message-timestamp")).toBeVisible();
+  // Should include a formatted date like "May 15, 2023"
+  await expect(component.locator(".message-timestamp")).toContainText(
+    "May 15, 2023",
+  );
+  // Should include elapsed time
+  await expect(component.locator(".message-timestamp")).toContainText(
+    "(1.50s)",
+  );
+});
+
+test("renders markdown content correctly", async ({ mount }) => {
+  const markdownContent =
+    "# Heading\n\n- List item 1\n- List item 2\n\n`code block`";
+  const message = createMockMessage({
+    content: markdownContent,
+  });
+
+  const component = await mount(SketchTimelineMessage, {
+    props: {
+      message: message,
+    },
+  });
+
+  await expect(component.locator(".markdown-content")).toBeVisible();
+
+  // Check HTML content
+  const html = await component
+    .locator(".markdown-content")
+    .evaluate((element) => element.innerHTML);
+  expect(html).toContain("<h1>Heading</h1>");
+  expect(html).toContain("<ul>");
+  expect(html).toContain("<li>List item 1</li>");
+  expect(html).toContain("<code>code block</code>");
+});
+
+test("displays usage information when available", async ({ mount }) => {
+  const usage: Usage = {
+    input_tokens: 150,
+    output_tokens: 300,
+    cost_usd: 0.025,
+    cache_read_input_tokens: 50,
+    cache_creation_input_tokens: 0,
+  };
+
+  const message = createMockMessage({
+    usage,
+  });
+
+  const component = await mount(SketchTimelineMessage, {
+    props: {
+      message: message,
+    },
+  });
+
+  await expect(component.locator(".message-usage")).toBeVisible();
+  await expect(component.locator(".message-usage")).toContainText("150"); // In
+  await expect(component.locator(".message-usage")).toContainText("300"); // Out
+  await expect(component.locator(".message-usage")).toContainText("50"); // Cache
+  await expect(component.locator(".message-usage")).toContainText("$0.03"); // Cost
+});
+
+test("renders commit information correctly", async ({ mount }) => {
+  const commits: GitCommit[] = [
+    {
+      hash: "1234567890abcdef",
+      subject: "Fix bug in application",
+      body: "This fixes a major bug in the application\n\nSigned-off-by: Developer",
+      pushed_branch: "main",
+    },
+  ];
+
+  const message = createMockMessage({
+    commits,
+  });
+
+  const component = await mount(SketchTimelineMessage, {
+    props: {
+      message: message,
+    },
+  });
+
+  await expect(component.locator(".commits-container")).toBeVisible();
+  await expect(component.locator(".commits-header")).toBeVisible();
+  await expect(component.locator(".commits-header")).toContainText("1 new");
+
+  await expect(component.locator(".commit-hash")).toBeVisible();
+  await expect(component.locator(".commit-hash")).toHaveText("12345678"); // First 8 chars
+
+  await expect(component.locator(".pushed-branch")).toBeVisible();
+  await expect(component.locator(".pushed-branch")).toContainText("main");
+});
+
+test("dispatches show-commit-diff event when commit diff button is clicked", async ({
+  mount,
+}) => {
+  const commits: GitCommit[] = [
+    {
+      hash: "1234567890abcdef",
+      subject: "Fix bug in application",
+      body: "This fixes a major bug in the application",
+      pushed_branch: "main",
+    },
+  ];
+
+  const message = createMockMessage({
+    commits,
+  });
+
+  const component = await mount(SketchTimelineMessage, {
+    props: {
+      message: message,
+    },
+  });
+
+  await expect(component.locator(".commit-diff-button")).toBeVisible();
+
+  // Set up promise to wait for the event
+  const eventPromise = component.evaluate((el) => {
+    return new Promise((resolve) => {
+      el.addEventListener(
+        "show-commit-diff",
+        (event) => {
+          resolve((event as CustomEvent).detail);
+        },
+        { once: true },
+      );
+    });
+  });
+
+  // Click the diff button
+  await component.locator(".commit-diff-button").click();
+
+  // Wait for the event and check its details
+  const detail = await eventPromise;
+  expect(detail["commitHash"]).toBe("1234567890abcdef");
+});
+
+test.skip("handles message type icon display correctly", async ({ mount }) => {
+  // First message of a type should show icon
+  const firstMessage = createMockMessage({
+    type: "user",
+    idx: 0,
+  });
+
+  // Second message of same type should not show icon
+  const secondMessage = createMockMessage({
+    type: "user",
+    idx: 1,
+  });
+
+  // Test first message (should show icon)
+  const firstComponent = await mount(SketchTimelineMessage, {
+    props: {
+      message: firstMessage,
+    },
+  });
+
+  await expect(firstComponent.locator(".message-icon")).toBeVisible();
+  await expect(firstComponent.locator(".message-icon")).toHaveText("U");
+
+  // Test second message with previous message of same type
+  const secondComponent = await mount(SketchTimelineMessage, {
+    props: {
+      message: secondMessage,
+      previousMessage: firstMessage,
+    },
+  });
+
+  await expect(secondComponent.locator(".message-icon")).not.toBeVisible();
+});
+
+test("formats numbers correctly", async ({ mount }) => {
+  const component = await mount(SketchTimelineMessage, {});
+
+  // Test accessing public method via evaluate
+  const result1 = await component.evaluate((el: SketchTimelineMessage) =>
+    el.formatNumber(1000),
+  );
+  expect(result1).toBe("1,000");
+
+  const result2 = await component.evaluate((el: SketchTimelineMessage) =>
+    el.formatNumber(null, "N/A"),
+  );
+  expect(result2).toBe("N/A");
+
+  const result3 = await component.evaluate((el: SketchTimelineMessage) =>
+    el.formatNumber(undefined, "--"),
+  );
+  expect(result3).toBe("--");
+});
+
+test("formats currency values correctly", async ({ mount }) => {
+  const component = await mount(SketchTimelineMessage, {});
+
+  // Test with different precisions
+  const result1 = await component.evaluate((el: SketchTimelineMessage) =>
+    el.formatCurrency(10.12345, "$0.00", true),
+  );
+  expect(result1).toBe("$10.1235"); // message level (4 decimals)
+
+  const result2 = await component.evaluate((el: SketchTimelineMessage) =>
+    el.formatCurrency(10.12345, "$0.00", false),
+  );
+  expect(result2).toBe("$10.12"); // total level (2 decimals)
+
+  const result3 = await component.evaluate((el: SketchTimelineMessage) =>
+    el.formatCurrency(null, "N/A"),
+  );
+  expect(result3).toBe("N/A");
+
+  const result4 = await component.evaluate((el: SketchTimelineMessage) =>
+    el.formatCurrency(undefined, "--"),
+  );
+  expect(result4).toBe("--");
+});
diff --git a/webui/src/web-components/sketch-timeline-message.ts b/webui/src/web-components/sketch-timeline-message.ts
new file mode 100644
index 0000000..36f1640
--- /dev/null
+++ b/webui/src/web-components/sketch-timeline-message.ts
@@ -0,0 +1,765 @@
+import { css, html, LitElement } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { customElement, property } from "lit/decorators.js";
+import { AgentMessage } from "../types";
+import { marked, MarkedOptions } from "marked";
+import "./sketch-tool-calls";
+@customElement("sketch-timeline-message")
+export class SketchTimelineMessage extends LitElement {
+  @property()
+  message: AgentMessage;
+
+  @property()
+  previousMessage: AgentMessage;
+
+  @property()
+  open: boolean = false;
+
+  // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
+  // Note that these styles only apply to the scope of this web component's
+  // shadow DOM node, so they won't leak out or collide with CSS declared in
+  // other components or the containing web page (...unless you want it to do that).
+  static styles = css`
+    .message {
+      position: relative;
+      margin-bottom: 5px;
+      padding-left: 30px;
+    }
+
+    .message-icon {
+      position: absolute;
+      left: 10px;
+      top: 0;
+      transform: translateX(-50%);
+      width: 16px;
+      height: 16px;
+      border-radius: 3px;
+      text-align: center;
+      line-height: 16px;
+      color: #fff;
+      font-size: 10px;
+    }
+
+    .message-content {
+      position: relative;
+      padding: 5px 10px;
+      background: #fff;
+      border-radius: 3px;
+      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+      border-left: 3px solid transparent;
+    }
+
+    /* Copy button styles */
+    .message-text-container,
+    .tool-result-container {
+      position: relative;
+    }
+
+    .message-actions {
+      position: absolute;
+      top: 5px;
+      right: 5px;
+      z-index: 10;
+      opacity: 0;
+      transition: opacity 0.2s ease;
+    }
+
+    .message-text-container:hover .message-actions,
+    .tool-result-container:hover .message-actions {
+      opacity: 1;
+    }
+
+    .copy-button {
+      background-color: rgba(255, 255, 255, 0.9);
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      color: #555;
+      cursor: pointer;
+      font-size: 12px;
+      padding: 2px 8px;
+      transition: all 0.2s ease;
+    }
+
+    .copy-button:hover {
+      background-color: #f0f0f0;
+      color: #333;
+    }
+
+    /* Removed arrow decoration for a more compact look */
+
+    .message-header {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 5px;
+      margin-bottom: 3px;
+      font-size: 12px;
+    }
+
+    .message-timestamp {
+      font-size: 10px;
+      color: #888;
+      font-style: italic;
+      margin-left: 3px;
+    }
+
+    .message-usage {
+      font-size: 10px;
+      color: #888;
+      margin-left: 3px;
+    }
+
+    .conversation-id {
+      font-family: monospace;
+      font-size: 12px;
+      padding: 2px 4px;
+      background-color: #f0f0f0;
+      border-radius: 3px;
+      margin-left: auto;
+    }
+
+    .parent-info {
+      font-size: 11px;
+      opacity: 0.8;
+    }
+
+    .subconversation {
+      border-left: 2px solid transparent;
+      padding-left: 5px;
+      margin-left: 20px;
+      transition: margin-left 0.3s ease;
+    }
+
+    .message-text {
+      overflow-x: auto;
+      margin-bottom: 3px;
+      font-family: monospace;
+      padding: 3px 5px;
+      background: rgb(236, 236, 236);
+      border-radius: 6px;
+      user-select: text;
+      cursor: text;
+      -webkit-user-select: text;
+      -moz-user-select: text;
+      -ms-user-select: text;
+      font-size: 13px;
+      line-height: 1.3;
+    }
+
+    .tool-details {
+      margin-top: 3px;
+      padding-top: 3px;
+      border-top: 1px dashed #e0e0e0;
+      font-size: 12px;
+    }
+
+    .tool-name {
+      font-size: 12px;
+      font-weight: bold;
+      margin-bottom: 2px;
+      background: #f0f0f0;
+      padding: 2px 4px;
+      border-radius: 2px;
+      display: flex;
+      align-items: center;
+      gap: 3px;
+    }
+
+    .tool-input,
+    .tool-result {
+      margin-top: 2px;
+      padding: 3px 5px;
+      background: #f7f7f7;
+      border-radius: 2px;
+      font-family: monospace;
+      font-size: 12px;
+      overflow-x: auto;
+      white-space: pre;
+      line-height: 1.3;
+      user-select: text;
+      cursor: text;
+      -webkit-user-select: text;
+      -moz-user-select: text;
+      -ms-user-select: text;
+    }
+
+    .tool-result {
+      max-height: 300px;
+      overflow-y: auto;
+    }
+
+    .usage-info {
+      margin-top: 10px;
+      padding-top: 10px;
+      border-top: 1px dashed #e0e0e0;
+      font-size: 12px;
+      color: #666;
+    }
+
+    /* Custom styles for IRC-like experience */
+    .user .message-content {
+      border-left-color: #2196f3;
+    }
+
+    .agent .message-content {
+      border-left-color: #4caf50;
+    }
+
+    .tool .message-content {
+      border-left-color: #ff9800;
+    }
+
+    .error .message-content {
+      border-left-color: #f44336;
+    }
+
+    /* Make message type display bold but without the IRC-style markers */
+    .message-type {
+      font-weight: bold;
+    }
+
+    /* Commit message styling */
+    .message.commit {
+      background-color: #f0f7ff;
+      border-left: 4px solid #0366d6;
+    }
+
+    .commits-container {
+      margin-top: 10px;
+      padding: 5px;
+    }
+
+    .commits-header {
+      font-weight: bold;
+      margin-bottom: 5px;
+      color: #24292e;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+    }
+
+    .commit-boxes-row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-top: 8px;
+    }
+
+    .commit-box {
+      border: 1px solid #d1d5da;
+      border-radius: 4px;
+      overflow: hidden;
+      background-color: #ffffff;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+      max-width: 100%;
+      display: flex;
+      flex-direction: column;
+    }
+
+    .commit-preview {
+      padding: 8px 12px;
+      font-family: monospace;
+      background-color: #f6f8fa;
+      border-bottom: 1px dashed #d1d5da;
+      display: flex;
+      align-items: center;
+      flex-wrap: wrap;
+      gap: 4px;
+    }
+
+    .commit-preview:hover {
+      background-color: #eef2f6;
+    }
+
+    .commit-hash {
+      color: #0366d6;
+      font-weight: bold;
+      cursor: pointer;
+      margin-right: 8px;
+      text-decoration: none;
+      position: relative;
+    }
+
+    .commit-hash:hover {
+      text-decoration: underline;
+    }
+
+    .commit-hash:hover::after {
+      content: "📋";
+      font-size: 10px;
+      position: absolute;
+      top: -8px;
+      right: -12px;
+      opacity: 0.7;
+    }
+
+    .branch-wrapper {
+      margin-right: 8px;
+      color: #555;
+    }
+
+    .commit-branch {
+      color: #28a745;
+      font-weight: 500;
+      cursor: pointer;
+      text-decoration: none;
+      position: relative;
+    }
+
+    .commit-branch:hover {
+      text-decoration: underline;
+    }
+
+    .commit-branch:hover::after {
+      content: "📋";
+      font-size: 10px;
+      position: absolute;
+      top: -8px;
+      right: -12px;
+      opacity: 0.7;
+    }
+
+    .commit-preview {
+      display: flex;
+      align-items: center;
+      flex-wrap: wrap;
+      gap: 4px;
+    }
+
+    .commit-details {
+      padding: 8px 12px;
+      max-height: 200px;
+      overflow-y: auto;
+    }
+
+    .commit-details pre {
+      margin: 0;
+      white-space: pre-wrap;
+      word-break: break-word;
+    }
+
+    .commit-details.is-hidden {
+      display: none;
+    }
+
+    .pushed-branch {
+      color: #28a745;
+      font-weight: 500;
+      margin-left: 6px;
+    }
+
+    .commit-diff-button {
+      padding: 3px 6px;
+      border: 1px solid #ccc;
+      border-radius: 3px;
+      background-color: #f7f7f7;
+      color: #24292e;
+      font-size: 11px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      margin-left: auto;
+    }
+
+    .commit-diff-button:hover {
+      background-color: #e7e7e7;
+      border-color: #aaa;
+    }
+
+    /* Tool call cards */
+    .tool-call-cards-container {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+      margin-top: 8px;
+    }
+
+    /* Message type styles */
+
+    .user .message-icon {
+      background-color: #2196f3;
+    }
+
+    .agent .message-icon {
+      background-color: #4caf50;
+    }
+
+    .tool .message-icon {
+      background-color: #ff9800;
+    }
+
+    .error .message-icon {
+      background-color: #f44336;
+    }
+
+    .end-of-turn {
+      margin-bottom: 15px;
+    }
+
+    .end-of-turn::after {
+      content: "End of Turn";
+      position: absolute;
+      left: 15px;
+      bottom: -10px;
+      transform: translateX(-50%);
+      font-size: 10px;
+      color: #666;
+      background: #f0f0f0;
+      padding: 1px 4px;
+      border-radius: 3px;
+    }
+
+    .markdown-content {
+      box-sizing: border-box;
+      min-width: 200px;
+      margin: 0 auto;
+    }
+
+    .markdown-content p {
+      margin-block-start: 0.5em;
+      margin-block-end: 0.5em;
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  renderMarkdown(markdownContent: string): string {
+    try {
+      // Set markdown options for proper code block highlighting and safety
+      const markedOptions: MarkedOptions = {
+        gfm: true, // GitHub Flavored Markdown
+        breaks: true, // Convert newlines to <br>
+        async: false,
+        // DOMPurify is recommended for production, but not included in this implementation
+      };
+      return marked.parse(markdownContent, markedOptions) as string;
+    } catch (error) {
+      console.error("Error rendering markdown:", error);
+      // Fallback to plain text if markdown parsing fails
+      return markdownContent;
+    }
+  }
+
+  /**
+   * Format timestamp for display
+   */
+  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;
+    }
+  }
+
+  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);
+    }
+  }
+  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;
+    }
+  }
+
+  showCommit(commitHash: string) {
+    this.dispatchEvent(
+      new CustomEvent("show-commit-diff", {
+        bubbles: true,
+        composed: true,
+        detail: { commitHash },
+      }),
+    );
+  }
+
+  copyToClipboard(text: string, event: Event) {
+    const element = event.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+
+    navigator.clipboard
+      .writeText(text)
+      .then(() => {
+        this.showFloatingMessage("Copied!", rect, "success");
+      })
+      .catch((err) => {
+        console.error("Failed to copy text: ", err);
+        this.showFloatingMessage("Failed to copy!", rect, "error");
+      });
+  }
+
+  showFloatingMessage(
+    message: string,
+    targetRect: DOMRect,
+    type: "success" | "error",
+  ) {
+    // Create floating message element
+    const floatingMsg = document.createElement("div");
+    floatingMsg.textContent = message;
+    floatingMsg.className = `floating-message ${type}`;
+
+    // Position it near the clicked element
+    // Position just above the element
+    const top = targetRect.top - 30;
+    const left = targetRect.left + targetRect.width / 2 - 40;
+
+    floatingMsg.style.position = "fixed";
+    floatingMsg.style.top = `${top}px`;
+    floatingMsg.style.left = `${left}px`;
+    floatingMsg.style.zIndex = "9999";
+
+    // Add to document body
+    document.body.appendChild(floatingMsg);
+
+    // Animate in
+    floatingMsg.style.opacity = "0";
+    floatingMsg.style.transform = "translateY(10px)";
+
+    setTimeout(() => {
+      floatingMsg.style.opacity = "1";
+      floatingMsg.style.transform = "translateY(0)";
+    }, 10);
+
+    // Remove after animation
+    setTimeout(() => {
+      floatingMsg.style.opacity = "0";
+      floatingMsg.style.transform = "translateY(-10px)";
+
+      setTimeout(() => {
+        document.body.removeChild(floatingMsg);
+      }, 300);
+    }, 1500);
+  }
+
+  render() {
+    return html`
+      <div
+        class="message ${this.message?.type} ${this.message?.end_of_turn
+          ? "end-of-turn"
+          : ""}"
+      >
+        ${this.previousMessage?.type != this.message?.type
+          ? html`<div class="message-icon">
+              ${this.message?.type.toUpperCase()[0]}
+            </div>`
+          : ""}
+        <div class="message-content">
+          <div class="message-header">
+            <span class="message-type">${this.message?.type}</span>
+            <span class="message-timestamp"
+              >${this.formatTimestamp(this.message?.timestamp)}
+              ${this.message?.elapsed
+                ? html`(${(this.message?.elapsed / 1e9).toFixed(2)}s)`
+                : ""}</span
+            >
+            ${this.message?.usage
+              ? html` <span class="message-usage">
+                  <span title="Input tokens"
+                    >In: ${this.message?.usage?.input_tokens}</span
+                  >
+                  ${this.message?.usage?.cache_read_input_tokens > 0
+                    ? html`<span title="Cache tokens"
+                        >[Cache:
+                        ${this.formatNumber(
+                          this.message?.usage?.cache_read_input_tokens,
+                        )}]</span
+                      >`
+                    : ""}
+                  <span title="Output tokens"
+                    >Out: ${this.message?.usage?.output_tokens}</span
+                  >
+                  <span title="Message cost"
+                    >(${this.formatCurrency(
+                      this.message?.usage?.cost_usd,
+                    )})</span
+                  >
+                </span>`
+              : ""}
+          </div>
+          <div class="message-text-container">
+            <div class="message-actions">
+              ${copyButton(this.message?.content)}
+            </div>
+            ${this.message?.content
+              ? html`
+                  <div class="message-text markdown-content">
+                    ${unsafeHTML(this.renderMarkdown(this.message?.content))}
+                  </div>
+                `
+              : ""}
+          </div>
+          <sketch-tool-calls
+            .toolCalls=${this.message?.tool_calls}
+            .open=${this.open}
+          ></sketch-tool-calls>
+          ${this.message?.commits
+            ? html`
+                <div class="commits-container">
+                  <div class="commits-header">
+                    ${this.message.commits.length} new
+                    commit${this.message.commits.length > 1 ? "s" : ""} detected
+                  </div>
+                  ${this.message.commits.map((commit) => {
+                    return html`
+                      <div class="commit-boxes-row">
+                        <div class="commit-box">
+                          <div class="commit-preview">
+                            <span
+                              class="commit-hash"
+                              title="Click to copy: ${commit.hash}"
+                              @click=${(e) =>
+                                this.copyToClipboard(
+                                  commit.hash.substring(0, 8),
+                                  e,
+                                )}
+                            >
+                              ${commit.hash.substring(0, 8)}
+                            </span>
+                            ${commit.pushed_branch
+                              ? html`
+                                  <span class="branch-wrapper">
+                                    (<span
+                                      class="commit-branch pushed-branch"
+                                      title="Click to copy: ${commit.pushed_branch}"
+                                      @click=${(e) =>
+                                        this.copyToClipboard(
+                                          commit.pushed_branch,
+                                          e,
+                                        )}
+                                      >${commit.pushed_branch}</span
+                                    >)
+                                  </span>
+                                `
+                              : ``}
+                            <span class="commit-subject"
+                              >${commit.subject}</span
+                            >
+                            <button
+                              class="commit-diff-button"
+                              @click=${() => this.showCommit(commit.hash)}
+                            >
+                              View Diff
+                            </button>
+                          </div>
+                          <div class="commit-details is-hidden">
+                            <pre>${commit.body}</pre>
+                          </div>
+                        </div>
+                      </div>
+                    `;
+                  })}
+                </div>
+              `
+            : ""}
+        </div>
+      </div>
+    `;
+  }
+}
+
+function copyButton(textToCopy: string) {
+  // Add click event listener to handle copying
+  const buttonClass = "copy-button";
+  const buttonContent = "Copy";
+  const successContent = "Copied!";
+  const failureContent = "Failed";
+
+  const ret = html`<button
+    class="${buttonClass}"
+    title="Copy to clipboard"
+    @click=${(e: Event) => {
+      e.stopPropagation();
+      const copyButton = e.currentTarget as HTMLButtonElement;
+      navigator.clipboard
+        .writeText(textToCopy)
+        .then(() => {
+          copyButton.textContent = successContent;
+          setTimeout(() => {
+            copyButton.textContent = buttonContent;
+          }, 2000);
+        })
+        .catch((err) => {
+          console.error("Failed to copy text: ", err);
+          copyButton.textContent = failureContent;
+          setTimeout(() => {
+            copyButton.textContent = buttonContent;
+          }, 2000);
+        });
+    }}
+  >
+    ${buttonContent}
+  </button>`;
+
+  return ret;
+}
+
+// Create global styles for floating messages
+const floatingMessageStyles = document.createElement("style");
+floatingMessageStyles.textContent = `
+  .floating-message {
+    background-color: rgba(0, 0, 0, 0.8);
+    color: white;
+    padding: 5px 10px;
+    border-radius: 4px;
+    font-size: 12px;
+    font-family: system-ui, sans-serif;
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
+    pointer-events: none;
+    transition: opacity 0.3s ease, transform 0.3s ease;
+  }
+  
+  .floating-message.success {
+    background-color: rgba(40, 167, 69, 0.9);
+  }
+  
+  .floating-message.error {
+    background-color: rgba(220, 53, 69, 0.9);
+  }
+`;
+document.head.appendChild(floatingMessageStyles);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-timeline-message": SketchTimelineMessage;
+  }
+}
diff --git a/webui/src/web-components/sketch-timeline.ts b/webui/src/web-components/sketch-timeline.ts
new file mode 100644
index 0000000..e2c8ee7
--- /dev/null
+++ b/webui/src/web-components/sketch-timeline.ts
@@ -0,0 +1,224 @@
+import { css, html, LitElement } from "lit";
+import { PropertyValues } from "lit";
+import { repeat } from "lit/directives/repeat.js";
+import { customElement, property, state } from "lit/decorators.js";
+import { AgentMessage } from "../types";
+import "./sketch-timeline-message";
+
+@customElement("sketch-timeline")
+export class SketchTimeline extends LitElement {
+  @property({ attribute: false })
+  messages: AgentMessage[] = [];
+
+  // Track if we should scroll to the bottom
+  @state()
+  private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
+
+  @property({ attribute: false })
+  scrollContainer: HTMLElement;
+
+  static styles = css`
+    /* Hide views initially to prevent flash of content */
+    .timeline-container .timeline,
+    .timeline-container .diff-view,
+    .timeline-container .chart-view,
+    .timeline-container .terminal-view {
+      visibility: hidden;
+    }
+
+    /* Will be set by JavaScript once we know which view to display */
+    .timeline-container.view-initialized .timeline,
+    .timeline-container.view-initialized .diff-view,
+    .timeline-container.view-initialized .chart-view,
+    .timeline-container.view-initialized .terminal-view {
+      visibility: visible;
+    }
+
+    .timeline-container {
+      width: 100%;
+      position: relative;
+    }
+
+    /* Timeline styles that should remain unchanged */
+    .timeline {
+      position: relative;
+      margin: 10px 0;
+      scroll-behavior: smooth;
+    }
+
+    .timeline::before {
+      content: "";
+      position: absolute;
+      top: 0;
+      bottom: 0;
+      left: 15px;
+      width: 2px;
+      background: #e0e0e0;
+      border-radius: 1px;
+    }
+
+    /* Hide the timeline vertical line when there are no messages */
+    .timeline.empty::before {
+      display: none;
+    }
+
+    #scroll-container {
+      overflow: auto;
+      padding-left: 1em;
+    }
+    #jump-to-latest {
+      display: none;
+      position: fixed;
+      bottom: 100px;
+      right: 0;
+      background: rgb(33, 150, 243);
+      color: white;
+      border-radius: 8px;
+      padding: 0.5em;
+      margin: 0.5em;
+      font-size: x-large;
+      opacity: 0.5;
+      cursor: pointer;
+    }
+    #jump-to-latest:hover {
+      opacity: 1;
+    }
+    #jump-to-latest.floating {
+      display: block;
+    }
+  `;
+
+  constructor() {
+    super();
+
+    // Binding methods
+    this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
+    this._handleScroll = this._handleScroll.bind(this);
+  }
+
+  /**
+   * Scroll to the bottom of the timeline
+   */
+  private scrollToBottom(): void {
+    this.scrollContainer?.scrollTo({
+      top: this.scrollContainer?.scrollHeight,
+      behavior: "smooth",
+    });
+  }
+
+  /**
+   * Called after the component's properties have been updated
+   */
+  updated(changedProperties: PropertyValues): void {
+    // If messages have changed, scroll to bottom if needed
+    if (changedProperties.has("messages") && this.messages.length > 0) {
+      if (this.scrollingState == "pinToLatest") {
+        setTimeout(() => this.scrollToBottom(), 50);
+      }
+    }
+    if (changedProperties.has("scrollContainer")) {
+      this.scrollContainer?.addEventListener("scroll", this._handleScroll);
+    }
+  }
+
+  /**
+   * Handle showCommitDiff event
+   */
+  private _handleShowCommitDiff(event: CustomEvent) {
+    const { commitHash } = event.detail;
+    if (commitHash) {
+      // Bubble up the event to the app shell
+      const newEvent = new CustomEvent("show-commit-diff", {
+        detail: { commitHash },
+        bubbles: true,
+        composed: true,
+      });
+      this.dispatchEvent(newEvent);
+    }
+  }
+
+  private _handleScroll(event) {
+    const isAtBottom =
+      Math.abs(
+        this.scrollContainer.scrollHeight -
+          this.scrollContainer.clientHeight -
+          this.scrollContainer.scrollTop,
+      ) <= 1;
+    if (isAtBottom) {
+      this.scrollingState = "pinToLatest";
+    } else {
+      // TODO: does scroll direction matter here?
+      this.scrollingState = "floating";
+    }
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Listen for showCommitDiff events from the renderer
+    document.addEventListener(
+      "showCommitDiff",
+      this._handleShowCommitDiff as EventListener,
+    );
+    this.scrollContainer?.addEventListener("scroll", this._handleScroll);
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    // Remove event listeners
+    document.removeEventListener(
+      "showCommitDiff",
+      this._handleShowCommitDiff as EventListener,
+    );
+
+    this.scrollContainer?.removeEventListener("scroll", this._handleScroll);
+  }
+
+  // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
+  // that we only re-render <sketch-message> elements that we need to re-render.
+  messageKey(message: AgentMessage): string {
+    // If the message has tool calls, and any of the tool_calls get a response, we need to
+    // re-render that message.
+    const toolCallResponses = message.tool_calls
+      ?.filter((tc) => tc.result_message)
+      .map((tc) => tc.tool_call_id)
+      .join("-");
+    return `message-${message.idx}-${toolCallResponses}`;
+  }
+
+  render() {
+    return html`
+      <div id="scroll-container">
+        <div class="timeline-container">
+          ${repeat(this.messages, this.messageKey, (message, index) => {
+            let previousMessage: AgentMessage;
+            if (index > 0) {
+              previousMessage = this.messages[index - 1];
+            }
+            return html`<sketch-timeline-message
+              .message=${message}
+              .previousMessage=${previousMessage}
+              .open=${index == this.messages.length - 1}
+            ></sketch-timeline-message>`;
+          })}
+        </div>
+      </div>
+      <div
+        id="jump-to-latest"
+        class="${this.scrollingState}"
+        @click=${this.scrollToBottom}
+      >
+        ⇩
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-timeline": SketchTimeline;
+  }
+}
diff --git a/webui/src/web-components/sketch-tool-calls.ts b/webui/src/web-components/sketch-tool-calls.ts
new file mode 100644
index 0000000..3f036c2
--- /dev/null
+++ b/webui/src/web-components/sketch-tool-calls.ts
@@ -0,0 +1,148 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { repeat } from "lit/directives/repeat.js";
+import { ToolCall } from "../types";
+import "./sketch-tool-card";
+
+@customElement("sketch-tool-calls")
+export class SketchToolCalls extends LitElement {
+  @property()
+  toolCalls: ToolCall[] = [];
+
+  @property()
+  open: boolean = false;
+
+  static styles = css`
+    /* Tool calls container styles */
+    .tool-calls-container {
+      /* Container for all tool calls */
+    }
+
+    /* Header for tool calls section */
+    .tool-calls-header {
+      /* Empty header - just small spacing */
+    }
+
+    /* Card container */
+    .tool-call-card {
+      display: flex;
+      flex-direction: column;
+      background-color: white;
+      overflow: hidden;
+      cursor: pointer;
+    }
+
+    /* Status indicators for tool calls */
+    .tool-call-status {
+      margin-right: 4px;
+      text-align: center;
+    }
+
+    .tool-call-status.spinner {
+      animation: spin 1s infinite linear;
+      display: inline-block;
+      width: 1em;
+    }
+
+    @keyframes spin {
+      0% {
+        transform: rotate(0deg);
+      }
+      100% {
+        transform: rotate(360deg);
+      }
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  cardForToolCall(toolCall: ToolCall, open: boolean) {
+    switch (toolCall.name) {
+      case "bash":
+        return html`<sketch-tool-card-bash
+          .open=${open}
+          .toolCall=${toolCall}
+        ></sketch-tool-card-bash>`;
+      case "codereview":
+        return html`<sketch-tool-card-codereview
+          .open=${open}
+          .toolCall=${toolCall}
+        ></sketch-tool-card-codereview>`;
+      case "done":
+        return html`<sketch-tool-card-done
+          .open=${open}
+          .toolCall=${toolCall}
+        ></sketch-tool-card-done>`;
+      case "patch":
+        return html`<sketch-tool-card-patch
+          .open=${open}
+          .toolCall=${toolCall}
+        ></sketch-tool-card-patch>`;
+      case "think":
+        return html`<sketch-tool-card-think
+          .open=${open}
+          .toolCall=${toolCall}
+        ></sketch-tool-card-think>`;
+      case "title":
+        return html`<sketch-tool-card-title
+          .open=${open}
+          .toolCall=${toolCall}
+        ></sketch-tool-card-title>`;
+    }
+    return html`<sketch-tool-card-generic
+      .open=${open}
+      .toolCall=${toolCall}
+    ></sketch-tool-card-generic>`;
+  }
+
+  // toolUseKey return value should change, if the toolCall gets a response.
+  toolUseKey(toolCall: ToolCall): string {
+    console.log(
+      "toolUseKey",
+      toolCall.tool_call_id,
+      toolCall.result_message?.idx,
+    );
+    if (!toolCall.result_message) {
+      return toolCall.tool_call_id;
+    }
+    return `${toolCall.tool_call_id}-${toolCall.result_message.idx}`;
+  }
+
+  render() {
+    return html`<div class="tool-calls-container">
+      <div class="tool-calls-header"></div>
+      <div class="tool-call-cards-container">
+        ${this.toolCalls
+          ? repeat(this.toolCalls, this.toolUseKey, (toolCall, idx) => {
+              let lastCall = false;
+              if (idx == this.toolCalls?.length - 1) {
+                lastCall = true;
+              }
+              return html`<div
+                id="${toolCall.tool_call_id}"
+                class="tool-call-card ${toolCall.name}"
+              >
+                ${this.cardForToolCall(toolCall, lastCall && this.open)}
+              </div>`;
+            })
+          : ""}
+      </div>
+    </div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-tool-calls": SketchToolCalls;
+  }
+}
diff --git a/webui/src/web-components/sketch-tool-card.ts b/webui/src/web-components/sketch-tool-card.ts
new file mode 100644
index 0000000..dbb09ae
--- /dev/null
+++ b/webui/src/web-components/sketch-tool-card.ts
@@ -0,0 +1,630 @@
+import { css, html, LitElement } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { customElement, property } from "lit/decorators.js";
+import { ToolCall } from "../types";
+import { marked, MarkedOptions } from "marked";
+
+function renderMarkdown(markdownContent: string): string {
+  try {
+    // Set markdown options for proper code block highlighting and safety
+    const markedOptions: MarkedOptions = {
+      gfm: true, // GitHub Flavored Markdown
+      breaks: true, // Convert newlines to <br>
+      async: false,
+      // DOMPurify is recommended for production, but not included in this implementation
+    };
+    return marked.parse(markdownContent, markedOptions) as string;
+  } catch (error) {
+    console.error("Error rendering markdown:", error);
+    // Fallback to plain text if markdown parsing fails
+    return markdownContent;
+  }
+}
+
+@customElement("sketch-tool-card")
+export class SketchToolCard extends LitElement {
+  @property()
+  toolCall: ToolCall;
+
+  @property()
+  open: boolean;
+
+  static styles = css`
+    .tool-call {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      white-space: nowrap;
+    }
+
+    .tool-call-status {
+      margin-right: 4px;
+      text-align: center;
+    }
+
+    .tool-call-status.spinner {
+      animation: spin 1s infinite linear;
+      display: inline-block;
+      width: 1em;
+    }
+
+    @keyframes spin {
+      0% {
+        transform: rotate(0deg);
+      }
+      100% {
+        transform: rotate(360deg);
+      }
+    }
+
+    .title {
+      font-style: italic;
+    }
+
+    .cancel-button {
+      background: rgb(76, 175, 80);
+      color: white;
+      border: none;
+      padding: 4px 10px;
+      border-radius: 4px;
+      cursor: pointer;
+      font-size: 12px;
+      margin: 5px;
+    }
+
+    .cancel-button:hover {
+      background: rgb(200, 35, 51) !important;
+    }
+
+    .codereview-OK {
+      color: green;
+    }
+
+    details {
+      border-radius: 4px;
+      padding: 0.25em;
+      margin: 0.25em;
+      display: flex;
+      flex-direction: column;
+      align-items: start;
+    }
+
+    details summary {
+      list-style: none;
+      &::before {
+        cursor: hand;
+        font-family: monospace;
+        content: "+";
+        color: white;
+        background-color: darkgray;
+        border-radius: 1em;
+        padding-left: 0.5em;
+        margin: 0.25em;
+        min-width: 1em;
+      }
+      [open] &::before {
+        content: "-";
+      }
+    }
+
+    details summary:hover {
+      list-style: none;
+      &::before {
+        background-color: gray;
+      }
+    }
+    summary {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: nowrap;
+      justify-content: flex-start;
+      align-items: baseline;
+    }
+
+    summary .tool-name {
+      font-family: monospace;
+      color: white;
+      background: rgb(124 145 160);
+      border-radius: 4px;
+      padding: 0.25em;
+      margin: 0.25em;
+      white-space: pre;
+    }
+
+    .summary-text {
+      padding: 0.25em;
+      display: flex;
+      max-width: 50%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    details[open] .summary-text {
+      /*display: none;*/
+    }
+
+    .tool-error-message {
+      font-style: italic;
+      color: #aa0909;
+    }
+
+    .elapsed {
+      font-size: 10px;
+      color: #888;
+      font-style: italic;
+      margin-left: 3px;
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  _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",
+        }),
+      });
+      if (response.ok) {
+        console.log("cancel", tool_call_id, response);
+        button.parentElement.removeChild(button);
+      } else {
+        button.innerText = "Cancel";
+        console.log(`error trying to cancel ${tool_call_id}: `, response);
+      }
+    } catch (e) {
+      console.error("cancel", tool_call_id, e);
+    }
+  };
+
+  render() {
+    const toolCallStatus = this.toolCall?.result_message
+      ? this.toolCall?.result_message.tool_error
+        ? html`❌
+            <span class="tool-error-message"
+              >${this.toolCall?.result_message.tool_result}</span
+            >`
+        : ""
+      : "⏳";
+
+    const cancelButton = this.toolCall?.result_message
+      ? ""
+      : html`<button
+          class="cancel-button"
+          title="Cancel this operation"
+          @click=${(e: Event) => {
+            e.stopPropagation();
+            const button = e.target as HTMLButtonElement;
+            this._cancelToolCall(this.toolCall?.tool_call_id, button);
+          }}
+        >
+          Cancel
+        </button>`;
+
+    const status = html`<span
+      class="tool-call-status ${this.toolCall?.result_message ? "" : "spinner"}"
+      >${toolCallStatus}</span
+    >`;
+
+    const elapsed = html`${this.toolCall?.result_message?.elapsed
+      ? html`<span class="elapsed"
+          >${(this.toolCall?.result_message?.elapsed / 1e9).toFixed(2)}s
+          elapsed</span
+        >`
+      : ""}`;
+
+    const ret = html`<div class="tool-call">
+      <details ?open=${this.open}>
+        <summary>
+          <span class="tool-name">${this.toolCall?.name}</span>
+          <span class="summary-text"><slot name="summary"></slot></span>
+          ${status} ${cancelButton} ${elapsed}
+        </summary>
+        <slot name="input"></slot>
+        <slot name="result"></slot>
+      </details>
+    </div> `;
+    if (true) {
+      return ret;
+    }
+  }
+}
+
+@customElement("sketch-tool-card-bash")
+export class SketchToolCardBash extends LitElement {
+  @property()
+  toolCall: ToolCall;
+
+  @property()
+  open: boolean;
+
+  static styles = css`
+    pre {
+      background: rgb(236, 236, 236);
+      color: black;
+      padding: 0.5em;
+      border-radius: 4px;
+    }
+    .summary-text {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      font-family: monospace;
+    }
+    .input {
+      display: flex;
+    }
+    .input pre {
+      width: 100%;
+      margin-bottom: 0;
+      border-radius: 4px 4px 0 0;
+    }
+    .result pre {
+      margin-top: 0;
+      color: #555;
+      border-radius: 0 0 4px 4px;
+    }
+    .background-badge {
+      display: inline-block;
+      background-color: #6200ea;
+      color: white;
+      font-size: 10px;
+      font-weight: bold;
+      padding: 2px 6px;
+      border-radius: 10px;
+      margin-left: 8px;
+      vertical-align: middle;
+    }
+    .command-wrapper {
+      display: flex;
+      align-items: center;
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  render() {
+    const inputData = JSON.parse(this.toolCall?.input || "{}");
+    const isBackground = inputData?.background === true;
+    const backgroundIcon = isBackground ? "🔄 " : "";
+
+    return html`
+    <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
+    <span slot="summary" class="summary-text">
+      <div class="command-wrapper">
+        🖥️ ${backgroundIcon}${inputData?.command}
+      </div>
+    </span>
+    <div slot="input" class="input">
+      <pre>🖥️ ${backgroundIcon}${inputData?.command}</pre>
+    </div>
+    ${
+      this.toolCall?.result_message
+        ? html` ${this.toolCall?.result_message.tool_result
+            ? html`<div slot="result" class="result">
+                <pre class="tool-call-result">
+${this.toolCall?.result_message.tool_result}</pre
+                >
+              </div>`
+            : ""}`
+        : ""
+    }</div>
+    </sketch-tool-card>`;
+  }
+}
+
+@customElement("sketch-tool-card-codereview")
+export class SketchToolCardCodeReview extends LitElement {
+  @property()
+  toolCall: ToolCall;
+
+  @property()
+  open: boolean;
+
+  static styles = css``;
+
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+  render() {
+    return html` <sketch-tool-card
+      .open=${this.open}
+      .toolCall=${this.toolCall}
+    >
+      <span slot="summary" class="summary-text">
+        ${this.toolCall?.result_message?.tool_result == "OK" ? "✔️" : "⛔"}
+      </span>
+      <div slot="result">
+        <pre>${this.toolCall?.result_message?.tool_result}</pre>
+      </div>
+    </sketch-tool-card>`;
+  }
+}
+
+@customElement("sketch-tool-card-done")
+export class SketchToolCardDone extends LitElement {
+  @property()
+  toolCall: ToolCall;
+
+  @property()
+  open: boolean;
+
+  static styles = css``;
+
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  render() {
+    const doneInput = JSON.parse(this.toolCall.input);
+    return html` <sketch-tool-card
+      .open=${this.open}
+      .toolCall=${this.toolCall}
+    >
+      <span slot="summary" class="summary-text"> </span>
+      <div slot="result">
+        ${Object.keys(doneInput.checklist_items).map((key) => {
+          const item = doneInput.checklist_items[key];
+          let statusIcon = "⛔";
+          if (item.status == "yes") {
+            statusIcon = "👍";
+          } else if (item.status == "not applicable") {
+            statusIcon = "🤷‍♂️";
+          }
+          return html`<div>
+            <span>${statusIcon}</span> ${key}:${item.status}
+          </div>`;
+        })}
+      </div>
+    </sketch-tool-card>`;
+  }
+}
+
+@customElement("sketch-tool-card-patch")
+export class SketchToolCardPatch extends LitElement {
+  @property()
+  toolCall: ToolCall;
+
+  @property()
+  open: boolean;
+
+  static styles = css`
+    .summary-text {
+      color: #555;
+      font-family: monospace;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      border-radius: 3px;
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  render() {
+    const patchInput = JSON.parse(this.toolCall?.input);
+    return html` <sketch-tool-card
+      .open=${this.open}
+      .toolCall=${this.toolCall}
+    >
+      <span slot="summary" class="summary-text">
+        ${patchInput?.path}: ${patchInput.patches.length}
+        edit${patchInput.patches.length > 1 ? "s" : ""}
+      </span>
+      <div slot="input">
+        ${patchInput.patches.map((patch) => {
+          return html` Patch operation: <b>${patch.operation}</b>
+            <pre>${patch.newText}</pre>`;
+        })}
+      </div>
+      <div slot="result">
+        <pre>${this.toolCall?.result_message?.tool_result}</pre>
+      </div>
+    </sketch-tool-card>`;
+  }
+}
+
+@customElement("sketch-tool-card-think")
+export class SketchToolCardThink extends LitElement {
+  @property()
+  toolCall: ToolCall;
+
+  @property()
+  open: boolean;
+
+  static styles = css`
+    .thought-bubble {
+      overflow-x: auto;
+      margin-bottom: 3px;
+      font-family: monospace;
+      padding: 3px 5px;
+      background: rgb(236, 236, 236);
+      border-radius: 6px;
+      user-select: text;
+      cursor: text;
+      -webkit-user-select: text;
+      -moz-user-select: text;
+      -ms-user-select: text;
+      font-size: 13px;
+      line-height: 1.3;
+    }
+    .summary-text {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      font-family: monospace;
+      max-width: 50%;
+    }
+  `;
+
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  render() {
+    return html`
+      <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
+        <span slot="summary" class="summary-text"
+          >${JSON.parse(this.toolCall?.input)?.thoughts}</span
+        >
+        <div slot="input" class="thought-bubble">
+          <div class="markdown-content">
+            ${unsafeHTML(
+              renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts),
+            )}
+          </div>
+        </div>
+      </sketch-tool-card>
+    `;
+  }
+}
+
+@customElement("sketch-tool-card-title")
+export class SketchToolCardTitle extends LitElement {
+  @property()
+  toolCall: ToolCall;
+
+  @property()
+  open: boolean;
+
+  static styles = css`
+    .summary-text {
+      font-style: italic;
+    }
+  `;
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  render() {
+    return html`
+      <span class="summary-text"
+        >I've set the title of this sketch to
+        <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span
+      >
+    `;
+  }
+}
+
+@customElement("sketch-tool-card-generic")
+export class SketchToolCardGeneric extends LitElement {
+  @property()
+  toolCall: ToolCall;
+
+  @property()
+  open: boolean;
+
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  render() {
+    return html` <sketch-tool-card
+      .open=${this.open}
+      .toolCall=${this.toolCall}
+    >
+      <span slot="summary" class="summary-text">${this.toolCall?.input}</span>
+      <div slot="input">
+        Input:
+        <pre>${this.toolCall?.input}</pre>
+      </div>
+      <div slot="result">
+        Result:
+        ${this.toolCall?.result_message
+          ? html` ${this.toolCall?.result_message.tool_result
+              ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
+              : ""}`
+          : ""}
+      </div>
+    </sketch-tool-card>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-tool-card": SketchToolCard;
+    "sketch-tool-card-generic": SketchToolCardGeneric;
+    "sketch-tool-card-bash": SketchToolCardBash;
+    "sketch-tool-card-codereview": SketchToolCardCodeReview;
+    "sketch-tool-card-done": SketchToolCardDone;
+    "sketch-tool-card-patch": SketchToolCardPatch;
+    "sketch-tool-card-think": SketchToolCardThink;
+    "sketch-tool-card-title": SketchToolCardTitle;
+  }
+}
diff --git a/webui/src/web-components/sketch-view-mode-select.test.ts b/webui/src/web-components/sketch-view-mode-select.test.ts
new file mode 100644
index 0000000..6db790b
--- /dev/null
+++ b/webui/src/web-components/sketch-view-mode-select.test.ts
@@ -0,0 +1,119 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchViewModeSelect } from "./sketch-view-mode-select";
+
+test("initializes with 'chat' as the default mode", async ({ mount }) => {
+  const component = await mount(SketchViewModeSelect, {});
+
+  // Check the activeMode property
+  const activeMode = await component.evaluate(
+    (el: SketchViewModeSelect) => el.activeMode,
+  );
+  expect(activeMode).toBe("chat");
+
+  // Check that the chat button has the active class
+  await expect(
+    component.locator("#showConversationButton.active"),
+  ).toBeVisible();
+});
+
+test("displays all four view mode buttons", async ({ mount }) => {
+  const component = await mount(SketchViewModeSelect, {});
+
+  // Count the number of buttons
+  const buttonCount = await component.locator(".emoji-button").count();
+  expect(buttonCount).toBe(4);
+
+  // Check that each button exists
+  await expect(component.locator("#showConversationButton")).toBeVisible();
+  await expect(component.locator("#showDiffButton")).toBeVisible();
+  await expect(component.locator("#showChartsButton")).toBeVisible();
+  await expect(component.locator("#showTerminalButton")).toBeVisible();
+
+  // Check the title attributes
+  expect(
+    await component.locator("#showConversationButton").getAttribute("title"),
+  ).toBe("Conversation View");
+  expect(await component.locator("#showDiffButton").getAttribute("title")).toBe(
+    "Diff View",
+  );
+  expect(
+    await component.locator("#showChartsButton").getAttribute("title"),
+  ).toBe("Charts View");
+  expect(
+    await component.locator("#showTerminalButton").getAttribute("title"),
+  ).toBe("Terminal View");
+});
+
+test("dispatches view-mode-select event when clicking a mode button", async ({
+  mount,
+}) => {
+  const component = await mount(SketchViewModeSelect, {});
+
+  // Set up promise to wait for the event
+  const eventPromise = component.evaluate((el) => {
+    return new Promise((resolve) => {
+      el.addEventListener(
+        "view-mode-select",
+        (event) => {
+          resolve((event as CustomEvent).detail);
+        },
+        { once: true },
+      );
+    });
+  });
+
+  // Click the diff button
+  await component.locator("#showDiffButton").click();
+
+  // Wait for the event and check its details
+  const detail: any = await eventPromise;
+  expect(detail.mode).toBe("diff");
+});
+
+test("updates the active mode when receiving update-active-mode event", async ({
+  mount,
+}) => {
+  const component = await mount(SketchViewModeSelect, {});
+
+  // Initially should be in chat mode
+  let activeMode = await component.evaluate(
+    (el: SketchViewModeSelect) => el.activeMode,
+  );
+  expect(activeMode).toBe("chat");
+
+  // Dispatch the update-active-mode event
+  await component.evaluate((el) => {
+    const updateEvent = new CustomEvent("update-active-mode", {
+      detail: { mode: "diff" },
+      bubbles: true,
+    });
+    el.dispatchEvent(updateEvent);
+  });
+
+  // Check that the mode was updated
+  activeMode = await component.evaluate(
+    (el: SketchViewModeSelect) => el.activeMode,
+  );
+  expect(activeMode).toBe("diff");
+
+  // Check that the diff button is now active
+  await expect(component.locator("#showDiffButton.active")).toBeVisible();
+});
+
+test("correctly marks the active button based on mode", async ({ mount }) => {
+  const component = await mount(SketchViewModeSelect, {
+    props: {
+      activeMode: "terminal",
+    },
+  });
+
+  // Terminal button should be active
+  await expect(component.locator("#showTerminalButton.active")).toBeVisible();
+
+  // Other buttons should not be active
+  await expect(
+    component.locator("#showConversationButton.active"),
+  ).not.toBeVisible();
+  await expect(component.locator("#showDiffButton.active")).not.toBeVisible();
+  await expect(component.locator("#showChartsButton.active")).not.toBeVisible();
+});
diff --git a/webui/src/web-components/sketch-view-mode-select.ts b/webui/src/web-components/sketch-view-mode-select.ts
new file mode 100644
index 0000000..52f8a4e
--- /dev/null
+++ b/webui/src/web-components/sketch-view-mode-select.ts
@@ -0,0 +1,146 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import "./sketch-container-status";
+
+@customElement("sketch-view-mode-select")
+export class SketchViewModeSelect extends LitElement {
+  // Current active mode
+  @property()
+  activeMode: "chat" | "diff" | "charts" | "terminal" = "chat";
+  // Header bar: view mode buttons
+
+  static styles = css`
+    /* View Mode Button Styles */
+    .view-mode-buttons {
+      display: flex;
+      gap: 8px;
+      margin-right: 10px;
+    }
+
+    .emoji-button {
+      font-size: 18px;
+      width: 32px;
+      height: 32px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background: white;
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      padding: 0;
+      line-height: 1;
+    }
+
+    .emoji-button:hover {
+      background-color: #f0f0f0;
+      transform: translateY(-2px);
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+    }
+
+    .emoji-button.active {
+      background-color: #e6f7ff;
+      border-color: #1890ff;
+      color: #1890ff;
+    }
+  `;
+
+  constructor() {
+    super();
+
+    // Binding methods
+    this._handleViewModeClick = this._handleViewModeClick.bind(this);
+    this._handleUpdateActiveMode = this._handleUpdateActiveMode.bind(this);
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Listen for update-active-mode events
+    this.addEventListener(
+      "update-active-mode",
+      this._handleUpdateActiveMode as EventListener,
+    );
+  }
+
+  /**
+   * Handle view mode button clicks
+   */
+  private _handleViewModeClick(mode: "chat" | "diff" | "charts" | "terminal") {
+    // Dispatch a custom event to notify the app shell to change the view
+    const event = new CustomEvent("view-mode-select", {
+      detail: { mode },
+      bubbles: true,
+      composed: true,
+    });
+    this.dispatchEvent(event);
+  }
+
+  /**
+   * Handle updates to the active mode
+   */
+  private _handleUpdateActiveMode(event: CustomEvent) {
+    const { mode } = event.detail;
+    if (mode) {
+      this.activeMode = mode;
+    }
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    // Remove event listeners
+    this.removeEventListener(
+      "update-active-mode",
+      this._handleUpdateActiveMode as EventListener,
+    );
+  }
+
+  render() {
+    return html`
+      <div class="view-mode-buttons">
+        <button
+          id="showConversationButton"
+          class="emoji-button ${this.activeMode === "chat" ? "active" : ""}"
+          title="Conversation View"
+          @click=${() => this._handleViewModeClick("chat")}
+        >
+          💬
+        </button>
+        <button
+          id="showDiffButton"
+          class="emoji-button ${this.activeMode === "diff" ? "active" : ""}"
+          title="Diff View"
+          @click=${() => this._handleViewModeClick("diff")}
+        >
+          ±
+        </button>
+        <button
+          id="showChartsButton"
+          class="emoji-button ${this.activeMode === "charts" ? "active" : ""}"
+          title="Charts View"
+          @click=${() => this._handleViewModeClick("charts")}
+        >
+          📈
+        </button>
+        <button
+          id="showTerminalButton"
+          class="emoji-button ${this.activeMode === "terminal" ? "active" : ""}"
+          title="Terminal View"
+          @click=${() => this._handleViewModeClick("terminal")}
+        >
+          💻
+        </button>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-view-mode-select": SketchViewModeSelect;
+  }
+}
diff --git a/webui/src/web-components/vega-embed.ts b/webui/src/web-components/vega-embed.ts
new file mode 100644
index 0000000..04f0087
--- /dev/null
+++ b/webui/src/web-components/vega-embed.ts
@@ -0,0 +1,86 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import vegaEmbed from "vega-embed";
+import { VisualizationSpec } from "vega-embed";
+
+/**
+ * A web component wrapper for vega-embed.
+ * Renders Vega and Vega-Lite visualizations.
+ *
+ * Usage:
+ * <vega-embed .spec="${yourVegaLiteSpec}"></vega-embed>
+ */
+@customElement("vega-embed")
+export class VegaEmbed extends LitElement {
+  /**
+   * The Vega or Vega-Lite specification to render
+   */
+  @property({ type: Object })
+  spec?: VisualizationSpec;
+
+  static styles = css`
+    :host {
+      display: block;
+      width: 100%;
+      height: 100%;
+    }
+
+    #vega-container {
+      width: 100%;
+      height: 100%;
+      min-height: 200px;
+    }
+  `;
+
+  @query("#vega-container")
+  protected container?: HTMLElement;
+
+  protected firstUpdated() {
+    this.renderVegaVisualization();
+  }
+
+  protected updated() {
+    this.renderVegaVisualization();
+  }
+
+  /**
+   * Renders the Vega/Vega-Lite visualization using vega-embed
+   */
+  private async renderVegaVisualization() {
+    if (!this.spec) {
+      return;
+    }
+
+    if (!this.container) {
+      return;
+    }
+
+    try {
+      // Clear previous visualization if any
+      this.container.innerHTML = "";
+
+      // Render new visualization
+      await vegaEmbed(this.container, this.spec, {
+        actions: true,
+        renderer: "svg",
+      });
+    } catch (error) {
+      console.error("Error rendering Vega visualization:", error);
+      this.container.innerHTML = `<div style="color: red; padding: 10px;">
+        Error rendering visualization: ${
+          error instanceof Error ? error.message : String(error)
+        }
+      </div>`;
+    }
+  }
+
+  render() {
+    return html`<div id="vega-container"></div> `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "vega-embed": VegaEmbed;
+  }
+}