loop/webui: swtich to web components impl (#1)

* loop/webui: swtich to web components impl

This change reorganizes the original vibe-coded
frontend code into a structure that's much
easier for a human to read and reason about,
while retaining the user-visible functionality
of its vibe-coded predecessor. Perhaps most
importantly, this change makes the code testable.

Some other notable details:

This does not use any of the popular large web
frameworks, but instead follows more of an
"a la carte" approach: leverage features
that already exist in modern web browsers,
like custom elements and shadow DOM.

Templating and basic component lifecycle
management are provided by lit.

State management is nothing fancy. It
doesn't use any library or framework, just
a basic "Events up, properties down"
approach.

* fix bad esbuild.go merge

* loop/webui: don't bundle src/web-components/demo

* loop/webui: don't 'npm ci' dev deps in the container

* rebase to main, undo README.md changes, add webuil.Build() call to LaunchContainer()
diff --git a/loop/webui/src/web-components/demo/demo.css b/loop/webui/src/web-components/demo/demo.css
new file mode 100644
index 0000000..bb9750e
--- /dev/null
+++ b/loop/webui/src/web-components/demo/demo.css
@@ -0,0 +1,9 @@
+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 */
+}
\ No newline at end of file
diff --git a/loop/webui/src/web-components/demo/index.html b/loop/webui/src/web-components/demo/index.html
new file mode 100644
index 0000000..3f728c0
--- /dev/null
+++ b/loop/webui/src/web-components/demo/index.html
@@ -0,0 +1,28 @@
+<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-view-mode-select.demo.html">sketch-view-mode-select</a>
+      </li>
+    </ul>
+  </body>
+</html>
diff --git a/loop/webui/src/web-components/demo/readme.md b/loop/webui/src/web-components/demo/readme.md
new file mode 100644
index 0000000..8e3c33c
--- /dev/null
+++ b/loop/webui/src/web-components/demo/readme.md
@@ -0,0 +1,14 @@
+# 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.
+
+# How to use this demo directory to iterate on component development
+
+From the `loop/webui` directory:
+
+1. In one shell, run `npm run watch` to build the web components and watch for changes
+1. In another shell, run `npm run demo` to start a local web server to serve the demo pages
+1. open http://localhost:8000/src/web-components/demo/ in your browser
+1. make edits to the .ts code or to the demo.html files and see how it affects the demo pages in real time
+
+Alternately, use the `webui: watch demo` task in VSCode, which runs all of the above for you.
diff --git a/loop/webui/src/web-components/demo/sketch-app-shell.demo.html b/loop/webui/src/web-components/demo/sketch-app-shell.demo.html
new file mode 100644
index 0000000..092ad7c
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-app-shell.demo.html
@@ -0,0 +1,13 @@
+<html>
+  <head>
+    <title>sketch-app-shell demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script src="/dist/web-components/sketch-app-shell.js" type="module"></script>
+  </head>
+  <body>
+    <h1>sketch-app-shell demo</h1>
+
+    <sketch-app-shell></sketch-app-shell>
+
+  </body>
+</html>
\ No newline at end of file
diff --git a/loop/webui/src/web-components/demo/sketch-charts.demo.html b/loop/webui/src/web-components/demo/sketch-charts.demo.html
new file mode 100644
index 0000000..b525785
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-charts.demo.html
@@ -0,0 +1,129 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <title>Sketch Charts Demo</title>
+    <script type="module" src="/dist/web-components/sketch-charts.js"></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/loop/webui/src/web-components/demo/sketch-chat-input.demo.html b/loop/webui/src/web-components/demo/sketch-chat-input.demo.html
new file mode 100644
index 0000000..4806035
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-chat-input.demo.html
@@ -0,0 +1,32 @@
+<html>
+  <head>
+    <title>sketch-chat-input demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script src="/dist/web-components/sketch-chat-input.js" type="module"></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>
\ No newline at end of file
diff --git a/loop/webui/src/web-components/demo/sketch-container-status.demo.html b/loop/webui/src/web-components/demo/sketch-container-status.demo.html
new file mode 100644
index 0000000..bd2544d
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-container-status.demo.html
@@ -0,0 +1,40 @@
+<html>
+  <head>
+    <title>sketch-container-status demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script src="/dist/web-components/sketch-container-status.js" type="module"></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>
\ No newline at end of file
diff --git a/loop/webui/src/web-components/demo/sketch-diff-view.demo.html b/loop/webui/src/web-components/demo/sketch-diff-view.demo.html
new file mode 100644
index 0000000..63b5395
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-diff-view.demo.html
@@ -0,0 +1,104 @@
+<!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="/dist/web-components/sketch-diff-view.js"></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>
\ No newline at end of file
diff --git a/loop/webui/src/web-components/demo/sketch-network-status.demo.html b/loop/webui/src/web-components/demo/sketch-network-status.demo.html
new file mode 100644
index 0000000..9926b0e
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-network-status.demo.html
@@ -0,0 +1,17 @@
+<html>
+  <head>
+    <title>sketch-network-status demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script src="/dist/web-components/sketch-network-status.js" type="module"></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>
\ No newline at end of file
diff --git a/loop/webui/src/web-components/demo/sketch-timeline-message.demo.html b/loop/webui/src/web-components/demo/sketch-timeline-message.demo.html
new file mode 100644
index 0000000..466f910
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-timeline-message.demo.html
@@ -0,0 +1,66 @@
+<html>
+  <head>
+    <title>sketch-timeline-message demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script
+      src="/dist/web-components/sketch-timeline-message.js"
+      type="module"
+    ></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 loop/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/loop/webui/src/web-components/demo/sketch-timeline.demo.html b/loop/webui/src/web-components/demo/sketch-timeline.demo.html
new file mode 100644
index 0000000..427181d
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-timeline.demo.html
@@ -0,0 +1,47 @@
+<html>
+  <head>
+    <title>sketch-timeline demo</title>
+    <link rel="stylesheet" href="demo.css" />
+    <script src="/dist/web-components/sketch-timeline.js" type="module"></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",
+        },
+      ];
+      document.addEventListener("DOMContentLoaded", () => {
+        const timelineEl = document.querySelector('sketch-timeline');
+        timelineEl.messages = messages;
+      });
+    </script>
+
+  </head>
+  <body>
+    <h1>sketch-timeline demo</h1>
+
+    <sketch-timeline></sketch-timeline>
+
+  </body>
+</html>
\ No newline at end of file
diff --git a/loop/webui/src/web-components/demo/sketch-tool-calls.demo.html b/loop/webui/src/web-components/demo/sketch-tool-calls.demo.html
new file mode 100644
index 0000000..a72babc
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-tool-calls.demo.html
@@ -0,0 +1,190 @@
+<html>
+  <head>
+    <title>sketch-tool-calls demo</title>
+    <link rel="stylesheet" href="demo.css" />
+
+    <script
+      src="/dist/web-components/sketch-tool-calls.js"
+      type="module"
+    ></script>
+
+    <script>
+      const toolCalls = [
+        [
+          {
+            name: "bash",
+            input: JSON.stringify({
+              command: "ls -a",
+            }),
+          },
+        ],
+        [
+          {
+            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/loop/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/loop/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/loop/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/loop/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 jsonEl = document.createElement("pre");
+          jsonEl.innerText = `.toolCalls property: ${JSON.stringify(calls)}`;
+          document.body.append(jsonEl);
+
+          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/loop/webui/src/web-components/demo/sketch-view-mode-select.demo.html b/loop/webui/src/web-components/demo/sketch-view-mode-select.demo.html
new file mode 100644
index 0000000..dac6831
--- /dev/null
+++ b/loop/webui/src/web-components/demo/sketch-view-mode-select.demo.html
@@ -0,0 +1,31 @@
+<html>
+  <head>
+    <title>sketch-view-mode-select demo</title>
+    <link rel="stylesheet" href="demo.css" />
+
+    <script src="/dist/web-components/sketch-view-mode-select.js" type="module"></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}`;
+        });
+      });
+  </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>
\ No newline at end of file
diff --git a/loop/webui/src/web-components/sketch-app-shell.ts b/loop/webui/src/web-components/sketch-app-shell.ts
new file mode 100644
index 0000000..2c7b111
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-app-shell.ts
@@ -0,0 +1,769 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { PropertyValues } from "lit";
+import { DataManager, ConnectionStatus } from "../data";
+import { State, TimelineMessage, ToolCall } 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 { View } from "vega";
+
+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 = "";
+
+  // Reference to the diff view component
+  private diffViewRef?: HTMLElement;
+
+  // 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()
+  messages: TimelineMessage[] = [];
+
+  @property()
+  chatMessageText: string = "";
+
+  @property()
+  title: string = "";
+
+  private dataManager = new DataManager();
+
+  @property()
+  containerState: State = { title: "", os: "", total_usage: {} };
+
+  // Track if this is the first load of messages
+  @state()
+  private isFirstLoad: boolean = true;
+
+  // Track if we should scroll to the bottom
+  @state()
+  private shouldScrollToBottom: boolean = true;
+
+  // Mutation observer to detect when new messages are added
+  private mutationObserver: MutationObserver | null = null;
+
+  constructor() {
+    super();
+
+    // Binding methods to this
+    this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
+    this._handleDiffComment = this._handleDiffComment.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 as EventListener);
+
+    // Add event listeners
+    window.addEventListener(
+      "view-mode-select",
+      this._handleViewModeSelect as EventListener
+    );
+    window.addEventListener(
+      "diff-comment",
+      this._handleDiffComment as EventListener
+    );
+    window.addEventListener(
+      "show-commit-diff",
+      this._handleShowCommitDiff as EventListener
+    );
+
+    // 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 as EventListener);
+
+    // Remove event listeners
+    window.removeEventListener(
+      "view-mode-select",
+      this._handleViewModeSelect as EventListener
+    );
+    window.removeEventListener(
+      "diff-comment",
+      this._handleDiffComment as EventListener
+    );
+    window.removeEventListener(
+      "show-commit-diff",
+      this._handleShowCommitDiff as EventListener
+    );
+
+    // 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());
+  }
+
+  _handlePopState(event) {
+    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);
+    }
+  }
+
+  /**
+   * Handle diff comment event
+   */
+  private _handleDiffComment(event: CustomEvent) {
+    const { comment } = event.detail;
+    if (!comment) return;
+
+    // Find the chat input textarea
+    const chatInput = this.shadowRoot?.querySelector("sketch-chat-input");
+    if (chatInput) {
+      // Update the chat input content using property
+      const currentContent = chatInput.getAttribute("content") || "";
+      const newContent = currentContent
+        ? `${currentContent}\n\n${comment}`
+        : comment;
+      chatInput.setAttribute("content", newContent);
+
+      // Dispatch an event to update the textarea value in the chat input component
+      const updateEvent = new CustomEvent("update-content", {
+        detail: { content: newContent },
+        bubbles: true,
+        composed: true,
+      });
+      chatInput.dispatchEvent(updateEvent);
+
+      // Switch back to chat view
+      this.toggleViewMode("chat", true);
+    }
+  }
+
+  /**
+   * Listen for commit diff event
+   * @param commitHash The commit hash to show diff for
+   */
+  public 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
+   */
+  public 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"));
+    });
+  }
+
+  mergeAndDedupe(
+    arr1: TimelineMessage[],
+    arr2: TimelineMessage[]
+  ): TimelineMessage[] {
+    const mergedArray = [...arr1, ...arr2];
+    const seenIds = new Set<number>();
+    const toolCallResults = new Map<string, TimelineMessage>();
+
+    let ret: TimelineMessage[] = 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: TimelineMessage, b: TimelineMessage) => 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;
+  }
+
+  private handleDataChanged(eventData: {
+    state: State;
+    newMessages: TimelineMessage[];
+    isFirstFetch?: boolean;
+  }): void {
+    const { state, newMessages, isFirstFetch } = eventData;
+
+    // Check if this is the first data fetch or if there are new messages
+    if (isFirstFetch) {
+      console.log("Auto-scroll: First data fetch, will scroll to bottom");
+      this.isFirstLoad = true;
+      this.shouldScrollToBottom = true;
+      this.messageStatus = "Initial messages loaded";
+    } else if (newMessages && newMessages.length > 0) {
+      console.log(`Auto-scroll: Received ${newMessages.length} new messages`);
+      this.messageStatus = "Updated just now";
+      // Check if we should scroll before updating messages
+      this.shouldScrollToBottom = this.checkShouldScroll();
+    } 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 = this.mergeAndDedupe(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}, shouldScroll=${this.shouldScrollToBottom}`
+      );
+    }
+  }
+
+  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}`);
+      }
+      // Clear the input after successfully sending the message.
+      this.chatMessageText = "";
+
+      // 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;
+
+      // Always scroll to bottom after sending a message
+      console.log("Auto-scroll: User sent a message, forcing scroll to bottom");
+      this.shouldScrollToBottom = true;
+
+      // // 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();
+
+      // Force multiple scroll attempts to ensure the user message is visible
+      // This addresses potential timing issues with DOM updates
+      const forceScrollAttempts = () => {
+        console.log("Auto-scroll: Forcing scroll after user message");
+        this.shouldScrollToBottom = true;
+
+        // Update the timeline component's scroll state
+        const timeline = this.shadowRoot?.querySelector(
+          "sketch-timeline"
+        ) as any;
+        if (timeline && timeline.setShouldScrollToLatest) {
+          timeline.setShouldScrollToLatest(true);
+          timeline.scrollToLatest();
+        } else {
+          this.scrollToBottom();
+        }
+      };
+
+      // Make multiple scroll attempts with different timings
+      // This ensures we catch the DOM after various update stages
+      setTimeout(forceScrollAttempts, 100);
+      setTimeout(forceScrollAttempts, 300);
+      setTimeout(forceScrollAttempts, 600);
+    } 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}></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
+        .content=${this.chatMessageText}
+        @send-chat="${this._sendChat}"
+      ></sketch-chat-input>
+    `;
+  }
+
+  /**
+   * Check if the page should scroll to the bottom based on current view position
+   * @returns Boolean indicating if we should scroll to the bottom
+   */
+  private checkShouldScroll(): boolean {
+    // If we're not in chat view, don't auto-scroll
+    if (this.viewMode !== "chat") {
+      return false;
+    }
+
+    // More generous threshold - if we're within 500px of the bottom, auto-scroll
+    // This ensures we start scrolling sooner when new messages appear
+    const scrollPosition = window.scrollY;
+    const windowHeight = window.innerHeight;
+    const documentHeight = document.body.scrollHeight;
+    const distanceFromBottom = documentHeight - (scrollPosition + windowHeight);
+    const threshold = 500; // Increased threshold to be more responsive
+
+    return distanceFromBottom <= threshold;
+  }
+
+  /**
+   * Scroll to the bottom of the timeline
+   */
+  private scrollToBottom(): void {
+    if (!this.checkShouldScroll()) {
+      return;
+    }
+
+    this.scrollTo({ top: this.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) {
+      setTimeout(() => this.scrollToBottom(), 50);
+    }
+  }
+
+  /**
+   * 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/loop/webui/src/web-components/sketch-charts.ts b/loop/webui/src/web-components/sketch-charts.ts
new file mode 100644
index 0000000..3bde418
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-charts.ts
@@ -0,0 +1,490 @@
+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 { TimelineMessage } 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: TimelineMessage[] = [];
+
+  @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: TimelineMessage[]
+  ): { timestamp: Date; cost: number }[] {
+    if (!messages || messages.length === 0) {
+      return [];
+    }
+
+    let cumulativeCost = 0;
+    const data: { timestamp: Date; cost: number }[] = [];
+
+    for (const message of messages) {
+      if (message.timestamp && message.usage && message.usage.cost_usd) {
+        const timestamp = new Date(message.timestamp);
+        cumulativeCost += message.usage.cost_usd;
+
+        data.push({
+          timestamp,
+          cost: cumulativeCost,
+        });
+      }
+    }
+
+    return data;
+  }
+
+  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/loop/webui/src/web-components/sketch-chat-input.test.ts b/loop/webui/src/web-components/sketch-chat-input.test.ts
new file mode 100644
index 0000000..7d93c17
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-chat-input.test.ts
@@ -0,0 +1,137 @@
+import { html, fixture, expect, oneEvent, elementUpdated, fixtureCleanup } from "@open-wc/testing";
+import "./sketch-chat-input";
+import { SketchChatInput } from "./sketch-chat-input";
+
+describe("SketchChatInput", () => {
+  afterEach(() => {
+    fixtureCleanup();
+  });
+
+  it("initializes with empty content by default", async () => {
+    const el: SketchChatInput = await fixture(html`
+      <sketch-chat-input></sketch-chat-input>
+    `);
+
+    expect(el.content).to.equal("");
+    const textarea = el.shadowRoot!.querySelector("#chatInput") as HTMLTextAreaElement;
+    expect(textarea.value).to.equal("");
+  });
+
+  it("initializes with provided content", async () => {
+    const testContent = "Hello, world!";
+    const el: SketchChatInput = await fixture(html`
+      <sketch-chat-input .content=${testContent}></sketch-chat-input>
+    `);
+
+    expect(el.content).to.equal(testContent);
+    const textarea = el.shadowRoot!.querySelector("#chatInput") as HTMLTextAreaElement;
+    expect(textarea.value).to.equal(testContent);
+  });
+
+  it("updates content when typing in the textarea", async () => {
+    const el: SketchChatInput = await fixture(html`
+      <sketch-chat-input></sketch-chat-input>
+    `);
+
+    const textarea = el.shadowRoot!.querySelector("#chatInput") as HTMLTextAreaElement;
+    const newValue = "New message";
+    
+    textarea.value = newValue;
+    textarea.dispatchEvent(new Event("input"));
+    
+    expect(el.content).to.equal(newValue);
+  });
+
+  it("sends message when clicking the send button", async () => {
+    const testContent = "Test message";
+    const el: SketchChatInput = await fixture(html`
+      <sketch-chat-input .content=${testContent}></sketch-chat-input>
+    `);
+
+    const button = el.shadowRoot!.querySelector("#sendChatButton") as HTMLButtonElement;
+    
+    // Setup listener for the send-chat event
+    setTimeout(() => button.click());
+    const { detail } = await oneEvent(el, "send-chat");
+    
+    expect(detail.message).to.equal(testContent);
+    expect(el.content).to.equal("");
+  });
+
+  it("sends message when pressing Enter (without shift)", async () => {
+    const testContent = "Test message";
+    const el: SketchChatInput = await fixture(html`
+      <sketch-chat-input .content=${testContent}></sketch-chat-input>
+    `);
+
+    const textarea = el.shadowRoot!.querySelector("#chatInput") as HTMLTextAreaElement;
+    
+    // Setup listener for the send-chat event
+    setTimeout(() => {
+      const enterEvent = new KeyboardEvent("keydown", {
+        key: "Enter",
+        bubbles: true,
+        cancelable: true,
+        shiftKey: false
+      });
+      textarea.dispatchEvent(enterEvent);
+    });
+    
+    const { detail } = await oneEvent(el, "send-chat");
+    
+    expect(detail.message).to.equal(testContent);
+    expect(el.content).to.equal("");
+  });
+
+  it("does not send message when pressing Shift+Enter", async () => {
+    const testContent = "Test message";
+    const el: SketchChatInput = await fixture(html`
+      <sketch-chat-input .content=${testContent}></sketch-chat-input>
+    `);
+
+    const textarea = el.shadowRoot!.querySelector("#chatInput") as HTMLTextAreaElement;
+    
+    // Create a flag to track if the event was fired
+    let eventFired = false;
+    el.addEventListener("send-chat", () => {
+      eventFired = true;
+    });
+    
+    // Dispatch the shift+enter keydown event
+    const shiftEnterEvent = new KeyboardEvent("keydown", {
+      key: "Enter",
+      bubbles: true,
+      cancelable: true,
+      shiftKey: true
+    });
+    textarea.dispatchEvent(shiftEnterEvent);
+    
+    // Wait a short time to verify no event was fired
+    await new Promise(resolve => setTimeout(resolve, 10));
+    
+    expect(eventFired).to.be.false;
+    expect(el.content).to.equal(testContent);
+  });
+
+  it("updates content when receiving update-content event", async () => {
+    const el: SketchChatInput = await fixture(html`
+      <sketch-chat-input></sketch-chat-input>
+    `);
+
+    const newContent = "Updated content";
+    
+    // Dispatch the update-content event
+    const updateEvent = new CustomEvent("update-content", {
+      detail: { content: newContent },
+      bubbles: true
+    });
+    el.dispatchEvent(updateEvent);
+    
+    // Wait for the component to update
+    await elementUpdated(el);
+    
+    expect(el.content).to.equal(newContent);
+    const textarea = el.shadowRoot!.querySelector("#chatInput") as HTMLTextAreaElement;
+    expect(textarea.value).to.equal(newContent);
+  });
+});
diff --git a/loop/webui/src/web-components/sketch-chat-input.ts b/loop/webui/src/web-components/sketch-chat-input.ts
new file mode 100644
index 0000000..3e75b52
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-chat-input.ts
@@ -0,0 +1,178 @@
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import { DataManager, ConnectionStatus } from "../data";
+import { State, TimelineMessage } from "../types";
+import "./sketch-container-status";
+
+@customElement("sketch-chat-input")
+export class SketchChatInput extends LitElement {
+  @property()
+  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: none;
+      font-family: monospace;
+      font-size: 12px;
+      min-height: 40px;
+      max-height: 120px;
+      background: #f7f7f7;
+    }
+
+    #sendChatButton {
+      background-color: #2196f3;
+      color: white;
+      border: none;
+      border-radius: 4px;
+      padding: 0 20px;
+      cursor: pointer;
+      font-weight: 600;
+    }
+
+    #sendChatButton:hover {
+      background-color: #0d8bf2;
+    }
+  `;
+
+  constructor() {
+    super();
+
+    // Binding methods
+    this._handleUpdateContent = this._handleUpdateContent.bind(this);
+  }
+
+  /**
+   * Handle update-content event
+   */
+  private _handleUpdateContent(event: CustomEvent) {
+    const { content } = event.detail;
+    if (content !== undefined) {
+      this.content = content;
+
+      // Update the textarea value directly, otherwise it won't update until next render
+      const textarea = this.shadowRoot?.querySelector(
+        "#chatInput"
+      ) as HTMLTextAreaElement;
+      if (textarea) {
+        textarea.value = content;
+      }
+    }
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Listen for update-content events
+    this.addEventListener(
+      "update-content",
+      this._handleUpdateContent as EventListener
+    );
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    // Remove event listeners
+    this.removeEventListener(
+      "update-content",
+      this._handleUpdateContent as EventListener
+    );
+  }
+
+  sendChatMessage() {
+    const event = new CustomEvent("send-chat", {
+      detail: { message: this.content },
+      bubbles: true,
+      composed: true,
+    });
+    this.dispatchEvent(event);
+    this.content = ""; // Clear the input after sending
+  }
+
+  adjustChatSpacing() {
+    console.log("TODO: adjustChatSpacing");
+  }
+
+  _sendChatClicked() {
+    this.sendChatMessage();
+    this.chatInput.focus(); // Refocus the input after sending
+  }
+
+  _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;
+    requestAnimationFrame(() => this.adjustChatSpacing());
+  }
+
+  @query("#chatInput")
+  private chatInput: HTMLTextAreaElement;
+
+  protected firstUpdated(): void {
+    if (this.chatInput) {
+      this.chatInput.focus();
+    }
+  }
+
+  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/loop/webui/src/web-components/sketch-container-status.test.ts b/loop/webui/src/web-components/sketch-container-status.test.ts
new file mode 100644
index 0000000..3a898ee
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-container-status.test.ts
@@ -0,0 +1,151 @@
+import { html, fixture, expect } from "@open-wc/testing";
+import "./sketch-container-status";
+import type { SketchContainerStatus } from "./sketch-container-status";
+import { State } from "../types";
+
+describe("SketchContainerStatus", () => {
+  // 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
+    }
+  };
+
+  it("renders with complete state data", async () => {
+    const el: SketchContainerStatus = await fixture(html`
+      <sketch-container-status .state=${mockCompleteState}></sketch-container-status>
+    `);
+
+    // Check that all expected elements exist
+    expect(el.shadowRoot!.querySelector("#hostname")).to.exist;
+    expect(el.shadowRoot!.querySelector("#workingDir")).to.exist;
+    expect(el.shadowRoot!.querySelector("#initialCommit")).to.exist;
+    expect(el.shadowRoot!.querySelector("#messageCount")).to.exist;
+    expect(el.shadowRoot!.querySelector("#inputTokens")).to.exist;
+    expect(el.shadowRoot!.querySelector("#outputTokens")).to.exist;
+    expect(el.shadowRoot!.querySelector("#cacheReadInputTokens")).to.exist;
+    expect(el.shadowRoot!.querySelector("#cacheCreationInputTokens")).to.exist;
+    expect(el.shadowRoot!.querySelector("#totalCost")).to.exist;
+
+    // Verify content of displayed elements
+    expect(el.shadowRoot!.querySelector("#hostname")!.textContent).to.equal("test-host");
+    expect(el.shadowRoot!.querySelector("#workingDir")!.textContent).to.equal("/test/dir");
+    expect(el.shadowRoot!.querySelector("#initialCommit")!.textContent).to.equal("abcdef12"); // Only first 8 chars
+    expect(el.shadowRoot!.querySelector("#messageCount")!.textContent).to.equal("42");
+    expect(el.shadowRoot!.querySelector("#inputTokens")!.textContent).to.equal("1000");
+    expect(el.shadowRoot!.querySelector("#outputTokens")!.textContent).to.equal("2000");
+    expect(el.shadowRoot!.querySelector("#cacheReadInputTokens")!.textContent).to.equal("300");
+    expect(el.shadowRoot!.querySelector("#cacheCreationInputTokens")!.textContent).to.equal("400");
+    expect(el.shadowRoot!.querySelector("#totalCost")!.textContent).to.equal("$0.25");
+  });
+
+  it("renders with undefined state", async () => {
+    const el: SketchContainerStatus = await fixture(html`
+      <sketch-container-status></sketch-container-status>
+    `);
+
+    // Elements should exist but be empty
+    expect(el.shadowRoot!.querySelector("#hostname")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#workingDir")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#initialCommit")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#messageCount")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#inputTokens")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#outputTokens")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#totalCost")!.textContent).to.equal("$0.00");
+  });
+
+  it("renders with partial state data", async () => {
+    const partialState: Partial<State> = {
+      hostname: "partial-host",
+      message_count: 10,
+      os: "linux",
+      title: "Partial Test",
+      total_usage: {
+        input_tokens: 500
+      }
+    };
+
+    const el: SketchContainerStatus = await fixture(html`
+      <sketch-container-status .state=${partialState as State}></sketch-container-status>
+    `);
+
+    // Check that elements with data are properly populated
+    expect(el.shadowRoot!.querySelector("#hostname")!.textContent).to.equal("partial-host");
+    expect(el.shadowRoot!.querySelector("#messageCount")!.textContent).to.equal("10");
+    expect(el.shadowRoot!.querySelector("#inputTokens")!.textContent).to.equal("500");
+    
+    // Check that elements without data are empty
+    expect(el.shadowRoot!.querySelector("#workingDir")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#initialCommit")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#outputTokens")!.textContent).to.equal("");
+    expect(el.shadowRoot!.querySelector("#totalCost")!.textContent).to.equal("$0.00");
+  });
+
+  it("handles cost formatting correctly", async () => {
+    // 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 el: SketchContainerStatus = await fixture(html`
+        <sketch-container-status .state=${stateWithCost}></sketch-container-status>
+      `);
+
+      expect(el.shadowRoot!.querySelector("#totalCost")!.textContent).to.equal(testCase.expected);
+    }
+  });
+
+  it("truncates commit hash to 8 characters", async () => {
+    const stateWithLongCommit = {
+      ...mockCompleteState,
+      initial_commit: "1234567890abcdef1234567890abcdef12345678"
+    };
+
+    const el: SketchContainerStatus = await fixture(html`
+      <sketch-container-status .state=${stateWithLongCommit}></sketch-container-status>
+    `);
+
+    expect(el.shadowRoot!.querySelector("#initialCommit")!.textContent).to.equal("12345678");
+  });
+
+  it("has correct link elements", async () => {
+    const el: SketchContainerStatus = await fixture(html`
+      <sketch-container-status .state=${mockCompleteState}></sketch-container-status>
+    `);
+
+    const links = Array.from(el.shadowRoot!.querySelectorAll('a'));
+    expect(links.length).to.equal(2);
+    
+    // Check for logs link
+    const logsLink = links.find(link => link.textContent === 'Logs');
+    expect(logsLink).to.exist;
+    expect(logsLink!.getAttribute('href')).to.equal('logs');
+    
+    // Check for download link
+    const downloadLink = links.find(link => link.textContent === 'Download');
+    expect(downloadLink).to.exist;
+    expect(downloadLink!.getAttribute('href')).to.equal('download');
+  });
+});
diff --git a/loop/webui/src/web-components/sketch-container-status.ts b/loop/webui/src/web-components/sketch-container-status.ts
new file mode 100644
index 0000000..c0f9626
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-container-status.ts
@@ -0,0 +1,150 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { State } from "../types";
+
+@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;
+    }
+
+    .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();
+  }
+
+  // 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">${this.state?.hostname}</span>
+        </div>
+        <div class="info-item">
+          <span id="workingDir" class="info-value"
+            >${this.state?.working_dir}</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/loop/webui/src/web-components/sketch-diff-view.ts b/loop/webui/src/web-components/sketch-diff-view.ts
new file mode 100644
index 0000000..562eb1e
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-diff-view.ts
@@ -0,0 +1,566 @@
+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;
+
+  // 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: fixed;
+      bottom: 80px;
+      right: 20px;
+      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;
+    }
+    
+    .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 {
+      // Show loading state
+      diff2htmlContent.innerHTML = "Loading enhanced diff...";
+      
+      // Build the diff URL - include commit hash if specified
+      const diffUrl = this.commitHash ? `diff?commit=${this.commitHash}` : "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;
+    
+    console.log("Setting up diff line comments");
+    
+    // Add plus buttons to each code line
+    this.addCommentButtonsToCodeLines();
+    
+    // Use event delegation for handling clicks on plus buttons
+    diff2htmlContent.addEventListener("click", (event) => {
+      const target = event.target as HTMLElement;
+      
+      // Only respond to clicks on the plus button
+      if (target.classList.contains("d2h-gutter-comment-button")) {
+        // Find the parent row first
+        const row = target.closest("tr");
+        if (!row) return;
+        
+        // Then find the code line in that row
+        const codeLine = row.querySelector(".d2h-code-side-line") || row.querySelector(".d2h-code-line");
+        if (!codeLine) return;
+        
+        // Get the line text content
+        const lineContent = codeLine.querySelector(".d2h-code-line-ctn");
+        if (!lineContent) return;
+        
+        const lineText = lineContent.textContent?.trim() || "";
+        
+        // Get file name to add context
+        const fileHeader = codeLine
+          .closest(".d2h-file-wrapper")
+          ?.querySelector(".d2h-file-name");
+        const fileName = fileHeader
+          ? fileHeader.textContent?.trim()
+          : "Unknown file";
+        
+        // Get line number if available
+        const lineNumElem = codeLine
+          .closest("tr")
+          ?.querySelector(".d2h-code-side-linenumber");
+        const lineNum = lineNumElem ? lineNumElem.textContent?.trim() : "";
+        const lineInfo = lineNum ? `Line ${lineNum}: ` : "";
+        
+        // Format the line for the comment box with file context and line number
+        const formattedLine = `${fileName} ${lineInfo}${lineText}`;
+        
+        console.log("Comment button clicked for line: ", formattedLine);
+        
+        // Open the comment box with this line
+        this.openDiffCommentBox(formattedLine);
+        
+        // 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>
+      `;
+      
+      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
+    if (commentBox) {
+      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;
+  }
+  
+  /**
+   * 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/loop/webui/src/web-components/sketch-network-status.test.ts b/loop/webui/src/web-components/sketch-network-status.test.ts
new file mode 100644
index 0000000..a580a8f
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-network-status.test.ts
@@ -0,0 +1,68 @@
+import { html, fixture, expect } from "@open-wc/testing";
+import "./sketch-network-status";
+import type { SketchNetworkStatus } from "./sketch-network-status";
+
+describe("SketchNetworkStatus", () => {
+  it("displays the correct connection status when connected", async () => {
+    const el: SketchNetworkStatus = await fixture(html`
+      <sketch-network-status
+        connection="connected"
+        message="Connected to server"
+      ></sketch-network-status>
+    `);
+
+    const indicator = el.shadowRoot!.querySelector(".polling-indicator");
+    const statusText = el.shadowRoot!.querySelector(".status-text");
+
+    expect(indicator).to.exist;
+    expect(statusText).to.exist;
+    expect(indicator!.classList.contains("active")).to.be.true;
+    expect(statusText!.textContent).to.equal("Connected to server");
+  });
+
+  it("displays the correct connection status when disconnected", async () => {
+    const el: SketchNetworkStatus = await fixture(html`
+      <sketch-network-status
+        connection="disconnected"
+        message="Disconnected"
+      ></sketch-network-status>
+    `);
+
+    const indicator = el.shadowRoot!.querySelector(".polling-indicator");
+
+    expect(indicator).to.exist;
+    expect(indicator!.classList.contains("error")).to.be.true;
+  });
+
+
+  it("displays the correct connection status when disabled", async () => {
+    const el: SketchNetworkStatus = await fixture(html`
+      <sketch-network-status
+        connection="disabled"
+        message="Disabled"
+      ></sketch-network-status>
+    `);
+
+    const indicator = el.shadowRoot!.querySelector(".polling-indicator");
+
+    expect(indicator).to.exist;
+    expect(indicator!.classList.contains("error")).to.be.false;
+    expect(indicator!.classList.contains("active")).to.be.false;
+  });
+
+  it("displays error message when provided", async () => {
+    const errorMsg = "Connection error";
+    const el: SketchNetworkStatus = await fixture(html`
+      <sketch-network-status
+        connection="disconnected"
+        message="Disconnected"
+        error="${errorMsg}"
+      ></sketch-network-status>
+    `);
+
+    const statusText = el.shadowRoot!.querySelector(".status-text");
+
+    expect(statusText).to.exist;
+    expect(statusText!.textContent).to.equal(errorMsg);
+  });
+});
diff --git a/loop/webui/src/web-components/sketch-network-status.ts b/loop/webui/src/web-components/sketch-network-status.ts
new file mode 100644
index 0000000..4b01e5e
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-network-status.ts
@@ -0,0 +1,101 @@
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {DataManager, ConnectionStatus} from '../data';
+import {State, TimelineMessage} from '../types';
+import './sketch-container-status';
+
+@customElement('sketch-network-status')
+export class SketchNetworkStatus extends LitElement {
+  // Header bar: view mode buttons
+
+  @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;
+  }
+}
\ No newline at end of file
diff --git a/loop/webui/src/web-components/sketch-terminal.ts b/loop/webui/src/web-components/sketch-terminal.ts
new file mode 100644
index 0000000..788521d
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-terminal.ts
@@ -0,0 +1,341 @@
+import { Terminal } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+
+import {css, html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {DataManager, ConnectionStatus} from '../data';
+import {State, TimelineMessage} from '../types';
+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 {
+            const decoded = atob(event.data);
+            this.terminal.write(decoded);
+          } catch (e) {
+            console.error('Error decoding terminal data:', e);
+            // Fallback to raw data if decoding fails
+            this.terminal.write(event.data);
+          }
+        }
+      };
+
+      this.terminalEventSource.onerror = (error) => {
+        console.error("Terminal SSE error:", error);
+        if (this.terminal) {
+          this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
+        }
+        // Attempt to reconnect if the connection was lost
+        if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
+          this.closeTerminalConnections();
+        }
+      };
+
+      // Send key inputs to the server via POST requests
+      if (this.terminal) {
+        this.terminal.onData((data) => {
+          this.sendTerminalInput(data);
+        });
+      }
+    } catch (error) {
+      console.error("Failed to connect to terminal:", error);
+      if (this.terminal) {
+        this.terminal.write(`\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`);
+      }
+    }
+  }
+
+  /**
+   * Close any active terminal connections
+   */
+  private closeTerminalConnections(): void {
+    if (this.terminalEventSource) {
+      this.terminalEventSource.close();
+      this.terminalEventSource = null;
+    }
+  }
+
+  /**
+   * Send input to the terminal
+   * @param data The input data to send
+   */
+  private async sendTerminalInput(data: string): Promise<void> {
+    // Add the data to the queue
+    this.terminalInputQueue.push(data);
+
+    // If we're not already processing inputs, start processing
+    if (!this.processingTerminalInput) {
+      await this.processTerminalInputQueue();
+    }
+  }
+
+  /**
+   * Process the terminal input queue in order
+   */
+  private async processTerminalInputQueue(): Promise<void> {
+    if (this.terminalInputQueue.length === 0) {
+      this.processingTerminalInput = false;
+      return;
+    }
+
+    this.processingTerminalInput = true;
+
+    // Concatenate all available inputs from the queue into a single request
+    let combinedData = '';
+
+    // Take all currently available items from the queue
+    while (this.terminalInputQueue.length > 0) {
+      combinedData += this.terminalInputQueue.shift()!;
+    }
+
+    try {
+      // Use relative URL based on current location
+      const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+      const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
+        method: 'POST',
+        body: combinedData,
+        headers: {
+          'Content-Type': 'text/plain'
+        }
+      });
+
+      if (!response.ok) {
+        console.error(`Failed to send terminal input: ${response.status} ${response.statusText}`);
+      }
+    } catch (error) {
+      console.error("Error sending terminal input:", error);
+    }
+
+    // Continue processing the queue (for any new items that may have been added)
+    await this.processTerminalInputQueue();
+  }
+
+  /**
+   * Send terminal resize information to the server
+   */
+  private async sendTerminalResize(): Promise<void> {
+    if (!this.terminal || !this.fitAddon) {
+      return;
+    }
+
+    // Get terminal dimensions
+    try {
+      // Send resize message in a format the server can understand
+      // Use relative URL based on current location
+      const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+      const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
+        method: 'POST',
+        body: JSON.stringify({
+          type: "resize",
+          cols: this.terminal.cols || 80, // Default to 80 if undefined
+          rows: this.terminal.rows || 24, // Default to 24 if undefined
+        }),
+        headers: {
+          'Content-Type': 'application/json'
+        }
+      });
+
+      if (!response.ok) {
+        console.error(`Failed to send terminal resize: ${response.status} ${response.statusText}`);
+      }
+    } catch (error) {
+      console.error("Error sending terminal resize:", error);
+    }
+  }
+
+
+  render() {
+    return html`
+      <div id="terminalView" class="terminal-view">
+         <div id="terminalContainer" class="terminal-container"></div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-terminal": SketchTerminal;
+  }
+}
\ No newline at end of file
diff --git a/loop/webui/src/web-components/sketch-timeline-message.test.ts b/loop/webui/src/web-components/sketch-timeline-message.test.ts
new file mode 100644
index 0000000..3f30696
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-timeline-message.test.ts
@@ -0,0 +1,265 @@
+import { html, fixture, expect, oneEvent } from "@open-wc/testing";
+import "./sketch-timeline-message";
+import type { SketchTimelineMessage } from "./sketch-timeline-message";
+import { TimelineMessage, ToolCall, GitCommit, Usage } from "../types";
+
+describe("SketchTimelineMessage", () => {
+  // Helper function to create mock timeline messages
+  function createMockMessage(props: Partial<TimelineMessage> = {}): TimelineMessage {
+    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
+    };
+  }
+
+  it("renders with basic message content", async () => {
+    const message = createMockMessage({
+      type: "agent",
+      content: "This is a test message"
+    });
+
+    const el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${message}
+      ></sketch-timeline-message>
+    `);
+
+    const messageContent = el.shadowRoot!.querySelector(".message-text");
+    expect(messageContent).to.exist;
+    expect(messageContent!.textContent!.trim()).to.include("This is a test message");
+  });
+
+  it("renders with correct message type classes", async () => {
+    const messageTypes = ["user", "agent", "tool", "error"];
+    
+    for (const type of messageTypes) {
+      const message = createMockMessage({ type });
+      
+      const el: SketchTimelineMessage = await fixture(html`
+        <sketch-timeline-message
+          .message=${message}
+        ></sketch-timeline-message>
+      `);
+      
+      const messageElement = el.shadowRoot!.querySelector(".message");
+      expect(messageElement).to.exist;
+      expect(messageElement!.classList.contains(type)).to.be.true;
+    }
+  });
+
+  it("renders end-of-turn marker correctly", async () => {
+    const message = createMockMessage({
+      end_of_turn: true
+    });
+
+    const el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${message}
+      ></sketch-timeline-message>
+    `);
+
+    const messageElement = el.shadowRoot!.querySelector(".message");
+    expect(messageElement).to.exist;
+    expect(messageElement!.classList.contains("end-of-turn")).to.be.true;
+  });
+
+  it("formats timestamps correctly", async () => {
+    const message = createMockMessage({
+      timestamp: "2023-05-15T12:00:00Z"
+    });
+
+    const el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${message}
+      ></sketch-timeline-message>
+    `);
+
+    const timestamp = el.shadowRoot!.querySelector(".message-timestamp");
+    expect(timestamp).to.exist;
+    // Should include a formatted date like "May 15, 2023"
+    expect(timestamp!.textContent).to.include("May 15, 2023");
+    // Should include elapsed time
+    expect(timestamp!.textContent).to.include("(1.50s)");
+  });
+
+  it("renders markdown content correctly", async () => {
+    const markdownContent = "# Heading\n\n- List item 1\n- List item 2\n\n`code block`";
+    const message = createMockMessage({
+      content: markdownContent
+    });
+
+    const el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${message}
+      ></sketch-timeline-message>
+    `);
+
+    const contentElement = el.shadowRoot!.querySelector(".markdown-content");
+    expect(contentElement).to.exist;
+    expect(contentElement!.innerHTML).to.include("<h1>Heading</h1>");
+    expect(contentElement!.innerHTML).to.include("<ul>");
+    expect(contentElement!.innerHTML).to.include("<li>List item 1</li>");
+    expect(contentElement!.innerHTML).to.include("<code>code block</code>");
+  });
+
+  it("displays usage information when available", async () => {
+    const usage: Usage = {
+      input_tokens: 150,
+      output_tokens: 300,
+      cost_usd: 0.025,
+      cache_read_input_tokens: 50
+    };
+    
+    const message = createMockMessage({
+      usage
+    });
+
+    const el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${message}
+      ></sketch-timeline-message>
+    `);
+
+    const usageElement = el.shadowRoot!.querySelector(".message-usage");
+    expect(usageElement).to.exist;
+    expect(usageElement!.textContent).to.include("In: 150");
+    expect(usageElement!.textContent).to.include("Out: 300");
+    expect(usageElement!.textContent).to.include("Cache: 50");
+    expect(usageElement!.textContent).to.include("$0.03");
+  });
+
+  it("renders commit information correctly", async () => {
+    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 el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${message}
+      ></sketch-timeline-message>
+    `);
+
+    const commitsContainer = el.shadowRoot!.querySelector(".commits-container");
+    expect(commitsContainer).to.exist;
+    
+    const commitHeader = commitsContainer!.querySelector(".commits-header");
+    expect(commitHeader).to.exist;
+    expect(commitHeader!.textContent).to.include("1 new commit");
+    
+    const commitHash = commitsContainer!.querySelector(".commit-hash");
+    expect(commitHash).to.exist;
+    expect(commitHash!.textContent).to.equal("12345678"); // First 8 chars
+    
+    const pushedBranch = commitsContainer!.querySelector(".pushed-branch");
+    expect(pushedBranch).to.exist;
+    expect(pushedBranch!.textContent).to.include("main");
+  });
+
+  it("dispatches show-commit-diff event when commit diff button is clicked", async () => {
+    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 el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${message}
+      ></sketch-timeline-message>
+    `);
+
+    const diffButton = el.shadowRoot!.querySelector(".commit-diff-button") as HTMLButtonElement;
+    expect(diffButton).to.exist;
+    
+    // Set up listener for the event
+    setTimeout(() => diffButton!.click());
+    const { detail } = await oneEvent(el, "show-commit-diff");
+    
+    expect(detail).to.exist;
+    expect(detail.commitHash).to.equal("1234567890abcdef");
+  });
+
+  it("handles message type icon display correctly", async () => {
+    // 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 firstEl: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${firstMessage}
+      ></sketch-timeline-message>
+    `);
+
+    const firstIcon = firstEl.shadowRoot!.querySelector(".message-icon");
+    expect(firstIcon).to.exist;
+    expect(firstIcon!.textContent!.trim()).to.equal("U");
+
+    // Test second message with previous message of same type
+    const secondEl: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message
+        .message=${secondMessage}
+        .previousMessage=${firstMessage}
+      ></sketch-timeline-message>
+    `);
+
+    const secondIcon = secondEl.shadowRoot!.querySelector(".message-icon");
+    expect(secondIcon).to.not.exist;
+  });
+
+  it("formats numbers correctly", async () => {
+    const el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message></sketch-timeline-message>
+    `);
+
+    // Test accessing private method via the component instance
+    expect(el.formatNumber(1000)).to.equal("1,000");
+    expect(el.formatNumber(null, "N/A")).to.equal("N/A");
+    expect(el.formatNumber(undefined, "--")).to.equal("--");
+  });
+
+  it("formats currency values correctly", async () => {
+    const el: SketchTimelineMessage = await fixture(html`
+      <sketch-timeline-message></sketch-timeline-message>
+    `);
+
+    // Test with different precisions
+    expect(el.formatCurrency(10.12345, "$0.00", true)).to.equal("$10.1235"); // message level (4 decimals)
+    expect(el.formatCurrency(10.12345, "$0.00", false)).to.equal("$10.12"); // total level (2 decimals)
+    expect(el.formatCurrency(null, "N/A")).to.equal("N/A");
+    expect(el.formatCurrency(undefined, "--")).to.equal("--");
+  });
+});
diff --git a/loop/webui/src/web-components/sketch-timeline-message.ts b/loop/webui/src/web-components/sketch-timeline-message.ts
new file mode 100644
index 0000000..cd2985a
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-timeline-message.ts
@@ -0,0 +1,553 @@
+import { css, html, LitElement } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { customElement, property } from "lit/decorators.js";
+import { State, TimelineMessage } from "../types";
+import { marked, MarkedOptions } from "marked";
+import "./sketch-tool-calls";
+@customElement("sketch-timeline-message")
+export class SketchTimelineMessage extends LitElement {
+  @property()
+  message: TimelineMessage;
+
+  @property()
+  previousMessage: TimelineMessage;
+
+  // 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;
+    }
+
+    .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;
+      cursor: pointer;
+      font-family: monospace;
+      background-color: #f6f8fa;
+      border-bottom: 1px dashed #d1d5da;
+    }
+    
+    .commit-preview:hover {
+      background-color: #eef2f6;
+    }
+    
+    .commit-hash {
+      color: #0366d6;
+      font-weight: bold;
+    }
+    
+    .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: 6px 12px;
+      border: 1px solid #ccc;
+      border-radius: 3px;
+      background-color: #f7f7f7;
+      color: #24292e;
+      font-size: 12px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      margin: 8px 12px;
+      display: block;
+    }
+    
+    .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}}))
+  }
+
+  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}
+          ></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">${commit.hash.substring(0, 8)}</span> 
+                            ${commit.subject}
+                            <span class="pushed-branch"
+                              >→ pushed to ${commit.pushed_branch}</span>
+                          </div>
+                          <div class="commit-details is-hidden">
+                            <pre>${commit.body}</pre>
+                          </div>
+                          <button class="commit-diff-button" @click=${() => this.showCommit(commit.hash)}>View Changes</button>
+                        </div>
+                      </div>
+                    `;
+                  })}
+                </div>
+              `
+            : ""}
+        </div>
+      </div>
+    `;
+  }
+}
+
+function copyButton(textToCopy: string) {  
+  // Add click event listener to handle copying
+  const ret = html`<button class="copy-button" title="Copy text to clipboard" @click=${(e: Event) => {
+    e.stopPropagation();
+    const copyButton = e.currentTarget as HTMLButtonElement;
+    navigator.clipboard
+      .writeText(textToCopy)
+      .then(() => {
+        copyButton.textContent = "Copied!";
+        setTimeout(() => {
+          copyButton.textContent = "Copy";
+        }, 2000);
+      })
+      .catch((err) => {
+        console.error("Failed to copy text: ", err);
+        copyButton.textContent = "Failed";
+        setTimeout(() => {
+          copyButton.textContent = "Copy";
+        }, 2000);
+      });
+  }}>Copy</button`;
+
+  return ret
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-timeline-message": SketchTimelineMessage;
+  }
+}
diff --git a/loop/webui/src/web-components/sketch-timeline.ts b/loop/webui/src/web-components/sketch-timeline.ts
new file mode 100644
index 0000000..8122db7
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-timeline.ts
@@ -0,0 +1,127 @@
+import {css, html, LitElement} from 'lit';
+import {repeat} from 'lit/directives/repeat.js';
+import {customElement, property} from 'lit/decorators.js';
+import {State, TimelineMessage} from '../types';
+import './sketch-timeline-message'
+
+@customElement('sketch-timeline')
+export class SketchTimeline extends LitElement {
+  @property()
+  messages: TimelineMessage[] = [];
+
+  // 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`
+  /* 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;
+  }  
+  `;
+
+  constructor() {
+    super();
+    
+    // Binding methods
+    this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
+  }
+  
+  /**
+   * 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);
+    }
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+    
+    // Listen for showCommitDiff events from the renderer
+    document.addEventListener('showCommitDiff', this._handleShowCommitDiff as EventListener);
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    
+    // Remove event listeners
+    document.removeEventListener('showCommitDiff', this._handleShowCommitDiff as EventListener);
+  }
+
+  messageKey(message: TimelineMessage): 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 class="timeline-container">
+      ${repeat(this.messages, this.messageKey, (message, index) => {        
+        let previousMessage: TimelineMessage;
+        if (index > 0) {
+          previousMessage = this.messages[index-1];
+        } 
+        return html`<sketch-timeline-message .message=${message} .previousMessage=${previousMessage}></sketch-timeline-message>`;
+      })}
+    </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-timeline": SketchTimeline;
+  }
+}
\ No newline at end of file
diff --git a/loop/webui/src/web-components/sketch-tool-calls.ts b/loop/webui/src/web-components/sketch-tool-calls.ts
new file mode 100644
index 0000000..a8d0acc
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-tool-calls.ts
@@ -0,0 +1,639 @@
+import { css, html, LitElement } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { repeat } from "lit/directives/repeat.js";
+import { customElement, property } from "lit/decorators.js";
+import { State, ToolCall } from "../types";
+import { marked, MarkedOptions } from "marked";
+
+@customElement("sketch-tool-calls")
+export class SketchToolCalls extends LitElement {
+  @property()
+  toolCalls: ToolCall[] = [];
+
+  // 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`
+    /* Tool calls container styles */
+    .tool-calls-container {
+      /* Removed dotted border */
+    }
+
+    .tool-calls-toggle {
+      cursor: pointer;
+      background-color: #f0f0f0;
+      padding: 5px 10px;
+      border: none;
+      border-radius: 4px;
+      text-align: left;
+      font-size: 12px;
+      margin-top: 5px;
+      color: #555;
+      font-weight: 500;
+    }
+
+    .tool-calls-toggle:hover {
+      background-color: #e0e0e0;
+    }
+
+    .tool-calls-details {
+      margin-top: 10px;
+      transition: max-height 0.3s ease;
+    }
+
+    .tool-calls-details.collapsed {
+      max-height: 0;
+      overflow: hidden;
+      margin-top: 0;
+    }
+
+    .tool-call {
+      background: #f9f9f9;
+      border-radius: 4px;
+      padding: 10px;
+      margin-bottom: 10px;
+      border-left: 3px solid #4caf50;
+    }
+
+    .tool-call-header {
+      margin-bottom: 8px;
+      font-size: 14px;
+      padding: 2px 0;
+    }
+
+    /* Compact tool display styles */
+    .tool-compact-line {
+      font-family: monospace;
+      font-size: 12px;
+      line-height: 1.4;
+      padding: 4px 6px;
+      background: #f8f8f8;
+      border-radius: 3px;
+      position: relative;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      max-width: 100%;
+      display: flex;
+      align-items: center;
+    }
+
+    .tool-result-inline {
+      font-family: monospace;
+      color: #0066bb;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      max-width: 400px;
+      display: inline-block;
+      vertical-align: middle;
+    }
+
+    .copy-inline-button {
+      font-size: 10px;
+      padding: 2px 4px;
+      margin-left: 8px;
+      background: #eee;
+      border: none;
+      border-radius: 3px;
+      cursor: pointer;
+      opacity: 0.7;
+    }
+
+    .copy-inline-button:hover {
+      opacity: 1;
+      background: #ddd;
+    }
+
+    .tool-input.compact,
+    .tool-result.compact {
+      margin: 2px 0;
+      padding: 4px;
+      font-size: 12px;
+    }
+
+    /* Removed old compact container CSS */
+
+    /* Ultra-compact tool call box styles */
+    .tool-calls-header {
+      /* Empty header - just small spacing */
+    }
+
+    .tool-call-boxes-row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-bottom: 8px;
+    }
+
+    .tool-call-wrapper {
+      display: flex;
+      flex-direction: column;
+      margin-bottom: 4px;
+    }
+
+    .tool-call-box {
+      display: inline-flex;
+      align-items: center;
+      background: #f0f0f0;
+      border-radius: 4px;
+      padding: 3px 8px;
+      font-size: 12px;
+      cursor: pointer;
+      max-width: 320px;
+      position: relative;
+      border: 1px solid #ddd;
+      transition: background-color 0.2s;
+    }
+
+    .tool-call-box:hover {
+      background-color: #e8e8e8;
+    }
+
+    .tool-call-box.expanded {
+      background-color: #e0e0e0;
+      border-bottom-left-radius: 0;
+      border-bottom-right-radius: 0;
+      border-bottom: 1px solid #ccc;
+    }
+
+    .tool-call-input {
+      color: #666;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      font-family: monospace;
+      font-size: 11px;
+    }
+
+    .tool-call-card {
+      display: flex;
+      flex-direction: column;
+      background-color: white;
+      overflow: hidden;
+      cursor: pointer;
+    }
+
+    /* Compact view (default) */
+    .tool-call-compact-view {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      font-size: 0.9em;
+      white-space: nowrap;
+      overflow: visible; /* Don't hide overflow, we'll handle text truncation per element */
+      position: relative; /* For positioning the expand icon */
+    }
+
+    /* Expanded view (hidden by default) */
+    .tool-call-card.collapsed .tool-call-expanded-view {
+      display: none;
+    }
+
+    .tool-call-expanded-view {
+      display: flex;
+      flex-direction: column;
+      border-top: 1px solid #eee;
+    }
+
+    .tool-call-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 6px 10px;
+      background-color: #f0f0f0;
+      border-bottom: 1px solid #ddd;
+      font-weight: bold;
+    }
+
+    .tool-call-name {
+      color: gray;
+    }
+
+    .tool-call-status {
+      margin-right: 4px;
+      text-align: center;
+    }
+
+    .tool-call-status.spinner {
+      animation: spin 1s infinite linear;
+      display: inline-block;
+      width: 1em;
+    }
+
+    .tool-call-time {
+      margin-left: 8px;
+      font-size: 0.85em;
+      color: #666;
+      font-weight: normal;
+    }
+
+    .tool-call-input-preview {
+      color: #555;
+      font-family: var(--monospace-font);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      max-width: 30%;
+      background-color: rgba(240, 240, 240, 0.5);
+      padding: 2px 5px;
+      border-radius: 3px;
+      font-size: 0.9em;
+    }
+
+    .tool-call-result-preview {
+      color: #28a745;
+      font-family: var(--monospace-font);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      max-width: 40%;
+      background-color: rgba(240, 248, 240, 0.5);
+      padding: 2px 5px;
+      border-radius: 3px;
+      font-size: 0.9em;
+    }
+
+    .tool-call-expand-icon {
+      position: absolute;
+      right: 10px;
+      font-size: 0.8em;
+      color: #888;
+    }
+
+    .tool-call-input {
+      padding: 6px 10px;
+      border-bottom: 1px solid #eee;
+      font-family: var(--monospace-font);
+      font-size: 0.9em;
+      white-space: pre-wrap;
+      word-break: break-all;
+      background-color: #f5f5f5;
+    }
+
+    .tool-call-result {
+      padding: 6px 10px;
+      font-family: var(--monospace-font);
+      font-size: 0.9em;
+      white-space: pre-wrap;
+      max-height: 300px;
+      overflow-y: auto;
+    }
+
+    .tool-call-result pre {
+      margin: 0;
+      white-space: pre-wrap;
+    }
+
+    @keyframes spin {
+      0% {
+        transform: rotate(0deg);
+      }
+      100% {
+        transform: rotate(360deg);
+      }
+    }
+
+    /* Standalone tool messages (legacy/disconnected) */
+    .tool-details.standalone .tool-header {
+      border-radius: 4px;
+      background-color: #fff3cd;
+      border-color: #ffeeba;
+    }
+
+    .tool-details.standalone .tool-warning {
+      margin-left: 10px;
+      font-size: 0.85em;
+      color: #856404;
+      font-style: italic;
+    }
+
+    /* Tool call expanded view with sections */
+    .tool-call-section {
+      border-bottom: 1px solid #eee;
+    }
+
+    .tool-call-section:last-child {
+      border-bottom: none;
+    }
+
+    .tool-call-section-label {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 8px 10px;
+      background-color: #f5f5f5;
+      font-weight: bold;
+      font-size: 0.9em;
+    }
+
+    .tool-call-section-content {
+      padding: 0;
+    }
+
+    .tool-call-copy-btn {
+      background-color: #f0f0f0;
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      padding: 2px 8px;
+      font-size: 0.8em;
+      cursor: pointer;
+      transition: background-color 0.2s;
+    }
+
+    .tool-call-copy-btn:hover {
+      background-color: #e0e0e0;
+    }
+
+    /* Override for tool call input in expanded view */
+    .tool-call-section-content .tool-call-input {
+      margin: 0;
+      padding: 8px 10px;
+      border: none;
+      background-color: #fff;
+      max-height: 300px;
+      overflow-y: auto;
+    }
+
+    .tool-call-card .tool-call-input-preview,
+    .tool-call-card .tool-call-result-preview {
+      font-family: monospace;
+      background: black;
+      padding: 1em;
+    }
+    .tool-call-input-preview {
+      color: white;
+    }
+    .tool-call-result-preview {
+      color: gray;
+    }
+
+    .tool-call-card.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;
+    }
+
+    .thought-bubble {
+      position: relative;
+      background-color: #eee;
+      border-radius: 8px;
+      padding: 0.5em;
+      box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
+      margin-left: 24px;
+      margin-top: 24px;
+      margin-bottom: 12px;
+      max-width: 30%;
+      white-space: pre;
+    }
+    
+    .thought-bubble .preview {
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+
+    .thought-bubble:before {
+      content: '';
+      position: absolute;
+      top: -8px;
+      left: -8px;
+      width: 15px;
+      height: 15px;
+      background-color: #eee;
+      border-radius: 50%;
+      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+    }
+    
+    .thought-bubble:after {
+      content: '';
+      position: absolute;
+      top: -16px;
+      left: -16px;
+      width: 8px;
+      height: 8px;
+      background-color: #eee;
+      border-radius: 50%;
+      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+    }
+    
+
+    .patch-input-preview {
+      color: #555;
+      font-family: monospace;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      max-width: 30%;
+      background-color: rgba(240, 240, 240, 0.5);
+      padding: 2px 5px;
+      border-radius: 3px;
+      font-size: 0.9em;
+    }
+
+    .codereview-OK {
+      color: green;
+    }
+  `;
+
+  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;
+    }
+  }
+
+  _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);
+    }
+  };
+
+  toolCard(toolCall: ToolCall) {
+    const toolCallStatus = toolCall.result_message
+      ? toolCall.result_message.tool_error
+        ? "❌"
+        : ""
+      : "⏳";
+
+    const cancelButton = 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(toolCall.tool_call_id, button);
+          }}
+        >
+          Cancel
+        </button>`;
+
+    const status = html`<span
+      class="tool-call-status ${toolCall.result_message ? "" : "spinner"}"
+      >${toolCallStatus}</span
+    >`;
+
+    switch (toolCall.name) {
+      case "title":
+        const titleInput = JSON.parse(toolCall.input);
+        return html`
+        <div class="tool-call-compact-view">
+          I've set the title of this sketch to <b>"${titleInput.title}"</b>
+        </div>`;
+      case "bash":
+        const bashInput = JSON.parse(toolCall.input);
+        return html`
+        <div class="tool-call-compact-view">
+          ${status}
+          <span class="tool-call-name">${toolCall.name}</span>
+          <pre class="tool-call-input-preview">${bashInput.command}</pre>
+          ${toolCall.result_message
+            ? html`
+                ${toolCall.result_message.tool_result
+                  ? html`
+                      <pre class="tool-call-result-preview">
+${toolCall.result_message.tool_result}</pre>`
+                  : ""}`
+            : cancelButton}
+        </div>`;
+      case "codereview":
+        return html`
+        <div class="tool-call-compact-view">
+          ${status}
+          <span class="tool-call-name">${toolCall.name}</span>
+          ${cancelButton}
+          <code class="codereview-preview codereview-${toolCall.result_message?.tool_result}">${toolCall.result_message?.tool_result == 'OK' ? '✔️': '⛔ ' + toolCall.result_message?.tool_result}</code>
+        </div>`;
+      case "think":
+        const thinkInput = JSON.parse(toolCall.input);
+        return html`
+        <div class="tool-call-compact-view">
+          ${status}
+          <span class="tool-call-name">${toolCall.name}</span>
+          <div class="thought-bubble"><div class="preview">${thinkInput.thoughts}</div></div>
+          ${cancelButton}
+        </div>`;
+      case "patch":
+        const patchInput = JSON.parse(toolCall.input);
+        return html`
+        <div class="tool-call-compact-view">
+          ${status}
+          <span class="tool-call-name">${toolCall.name}</span>
+          <div class="patch-input-preview"><span class="patch-path">${patchInput.path}</span>: ${patchInput.patches.length} edit${patchInput.patches.length > 1 ? 's': ''}</div>
+          ${cancelButton}
+        </div>`;
+      case "done":
+        const doneInput = JSON.parse(toolCall.input);
+        return html`
+        <div class="tool-call-compact-view">
+          ${status}
+          <span class="tool-call-name">${toolCall.name}</span>
+          <div class="done-input-preview">
+            ${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>
+          ${cancelButton}
+        </div>`;
+
+      default: // Generic tool card:
+        return html`
+      <div class="tool-call-compact-view">
+        ${status}
+        <span class="tool-call-name">${toolCall.name}</span>
+        <code class="tool-call-input-preview">${toolCall.input}</code>
+        ${cancelButton}
+        <code class="tool-call-result-preview">${toolCall.result_message?.tool_result}</code>
+      </div>
+      ${toolCall.result_message?.tool_result}
+    `;
+    }
+  }
+  render() {
+    return html`
+    <div class="tool-calls-container">
+      <div class="tool-calls-header"></div>
+      <div class="tool-call-cards-container">
+        ${this.toolCalls?.map((toolCall) => {
+          return html`<div class="tool-call-card ${toolCall.name}">
+            ${this.toolCard(toolCall)}
+          </div>`;
+        })}
+      </div>
+    </div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-tool-calls": SketchToolCalls;
+  }
+}
diff --git a/loop/webui/src/web-components/sketch-view-mode-select.test.ts b/loop/webui/src/web-components/sketch-view-mode-select.test.ts
new file mode 100644
index 0000000..beb0b67
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-view-mode-select.test.ts
@@ -0,0 +1,99 @@
+import { html, fixture, expect, oneEvent, elementUpdated, fixtureCleanup } from "@open-wc/testing";
+import "./sketch-view-mode-select";
+import type { SketchViewModeSelect } from "./sketch-view-mode-select";
+
+describe("SketchViewModeSelect", () => {
+  afterEach(() => {
+    fixtureCleanup();
+  });
+
+  it("initializes with 'chat' as the default mode", async () => {
+    const el: SketchViewModeSelect = await fixture(html`
+      <sketch-view-mode-select></sketch-view-mode-select>
+    `);
+
+    expect(el.activeMode).to.equal("chat");
+    const chatButton = el.shadowRoot!.querySelector("#showConversationButton");
+    expect(chatButton!.classList.contains("active")).to.be.true;
+  });
+
+  it("displays all four view mode buttons", async () => {
+    const el: SketchViewModeSelect = await fixture(html`
+      <sketch-view-mode-select></sketch-view-mode-select>
+    `);
+
+    const buttons = el.shadowRoot!.querySelectorAll(".emoji-button");
+    expect(buttons.length).to.equal(4);
+
+    const chatButton = el.shadowRoot!.querySelector("#showConversationButton");
+    const diffButton = el.shadowRoot!.querySelector("#showDiffButton");
+    const chartsButton = el.shadowRoot!.querySelector("#showChartsButton");
+    const terminalButton = el.shadowRoot!.querySelector("#showTerminalButton");
+
+    expect(chatButton).to.exist;
+    expect(diffButton).to.exist;
+    expect(chartsButton).to.exist;
+    expect(terminalButton).to.exist;
+
+    expect(chatButton!.getAttribute("title")).to.equal("Conversation View");
+    expect(diffButton!.getAttribute("title")).to.equal("Diff View");
+    expect(chartsButton!.getAttribute("title")).to.equal("Charts View");
+    expect(terminalButton!.getAttribute("title")).to.equal("Terminal View");
+  });
+
+  it("dispatches view-mode-select event when clicking a mode button", async () => {
+    const el: SketchViewModeSelect = await fixture(html`
+      <sketch-view-mode-select></sketch-view-mode-select>
+    `);
+
+    const diffButton = el.shadowRoot!.querySelector("#showDiffButton") as HTMLButtonElement;
+    
+    // Setup listener for the view-mode-select event
+    setTimeout(() => diffButton.click());
+    const { detail } = await oneEvent(el, "view-mode-select");
+    
+    expect(detail.mode).to.equal("diff");
+  });
+
+  it("updates the active mode when receiving update-active-mode event", async () => {
+    const el: SketchViewModeSelect = await fixture(html`
+      <sketch-view-mode-select></sketch-view-mode-select>
+    `);
+
+    // Initially should be in chat mode
+    expect(el.activeMode).to.equal("chat");
+    
+    // Dispatch the update-active-mode event to change to diff mode
+    const updateEvent = new CustomEvent("update-active-mode", {
+      detail: { mode: "diff" },
+      bubbles: true
+    });
+    el.dispatchEvent(updateEvent);
+    
+    // Wait for the component to update
+    await elementUpdated(el);
+    
+    expect(el.activeMode).to.equal("diff");
+    const diffButton = el.shadowRoot!.querySelector("#showDiffButton");
+    expect(diffButton!.classList.contains("active")).to.be.true;
+  });
+
+  it("correctly marks the active button based on mode", async () => {
+    const el: SketchViewModeSelect = await fixture(html`
+      <sketch-view-mode-select activeMode="terminal"></sketch-view-mode-select>
+    `);
+
+    // Terminal button should be active
+    const terminalButton = el.shadowRoot!.querySelector("#showTerminalButton");
+    const chatButton = el.shadowRoot!.querySelector("#showConversationButton");
+    const diffButton = el.shadowRoot!.querySelector("#showDiffButton");
+    const chartsButton = el.shadowRoot!.querySelector("#showChartsButton");
+    
+    expect(terminalButton!.classList.contains("active")).to.be.true;
+    expect(chatButton!.classList.contains("active")).to.be.false;
+    expect(diffButton!.classList.contains("active")).to.be.false;
+    expect(chartsButton!.classList.contains("active")).to.be.false;
+  });
+
+
+});
diff --git a/loop/webui/src/web-components/sketch-view-mode-select.ts b/loop/webui/src/web-components/sketch-view-mode-select.ts
new file mode 100644
index 0000000..b55282a
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-view-mode-select.ts
@@ -0,0 +1,147 @@
+import {css, html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {DataManager, ConnectionStatus} from '../data';
+import {State, TimelineMessage} from '../types';
+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
+
+  // 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`
+/* 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;
+  }
+}
\ No newline at end of file
diff --git a/loop/webui/src/web-components/vega-embed.ts b/loop/webui/src/web-components/vega-embed.ts
new file mode 100644
index 0000000..04f0087
--- /dev/null
+++ b/loop/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;
+  }
+}