webui: Improve dx

For local development, switch to Vite and update web components for improved demo experience. Note that we haven't changed how we bundle when we're actually running in sketch; that's still the go/esbuild in-memory setup. This just changes demo dev setup to get breakpoints working and a functioning full sketch-app-shell.

We still need to add some mock data, but this is a start

- Introduced `vite.config.mts` for Vite setup with hot module reloading.
- Updated `package.json` and `package-lock.json` to include Vite and related plugins.
- Refactored demo scripts to utilize Vite for local development.
- Created `launch.json` for VSCode debugging configuration.
- Enhanced `Makefile` with a new demo task.
- Improved styling and structure in demo HTML and CSS files.
- Implemented `aggregateAgentMessages` function for message handling in web components.
diff --git a/loop/webui/Makefile b/loop/webui/Makefile
index 662cfe7..2ce27f5 100644
--- a/loop/webui/Makefile
+++ b/loop/webui/Makefile
@@ -3,6 +3,9 @@
 install:
 	npm ci
 
+demo:
+	npm run demo
+
 # TypeScript type checking
 # Note: The actual esbuild bundling happens in esbuild.go
 check:
diff --git a/loop/webui/package-lock.json b/loop/webui/package-lock.json
index e4b2748..8abb565 100644
--- a/loop/webui/package-lock.json
+++ b/loop/webui/package-lock.json
@@ -20,18 +20,18 @@
         "vega-lite": "^5.23.0"
       },
       "devDependencies": {
-        "@open-wc/dev-server-hmr": "^0.1.2-next.0",
         "@sand4rt/experimental-ct-web": "^1.51.1",
         "@types/marked": "^5.0.2",
         "@types/mocha": "^10.0.7",
         "@types/node": "^22.13.14",
-        "@web/dev-server": "^0.4.6",
         "@web/test-runner": "^0.18.2",
         "@web/test-runner-puppeteer": "^0.18.0",
         "autoprefixer": "^10.4.21",
         "esbuild": "^0.25.1",
         "prettier": "3.5.3",
-        "typescript": "^5.8.2"
+        "typescript": "^5.8.2",
+        "vite": "^6.3.2",
+        "vite-plugin-web-components-hmr": "^0.1.3"
       }
     },
     "node_modules/@ampproject/remapping": {
@@ -888,99 +888,6 @@
         "node": ">= 8"
       }
     },
-    "node_modules/@open-wc/dev-server-hmr": {
-      "version": "0.1.2-next.0",
-      "resolved": "https://registry.npmjs.org/@open-wc/dev-server-hmr/-/dev-server-hmr-0.1.2-next.0.tgz",
-      "integrity": "sha512-XgazcRuYE0J17X1LgZ/BumwMf81p7qR1h3ncc3ljA3PDqXIBSOYnt1SSoR/IqlJvDmbTopONYQ/w+qjEOtBrAg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.12.3",
-        "@babel/plugin-syntax-class-properties": "^7.12.13",
-        "@babel/plugin-syntax-import-assertions": "^7.12.1",
-        "@babel/plugin-syntax-top-level-await": "^7.12.1",
-        "@web/dev-server-core": "^0.3.10",
-        "@web/dev-server-hmr": "^0.1.6",
-        "picomatch": "^2.2.2"
-      }
-    },
-    "node_modules/@open-wc/dev-server-hmr/node_modules/@web/dev-server-core": {
-      "version": "0.3.19",
-      "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.3.19.tgz",
-      "integrity": "sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/koa": "^2.11.6",
-        "@types/ws": "^7.4.0",
-        "@web/parse5-utils": "^1.2.0",
-        "chokidar": "^3.4.3",
-        "clone": "^2.1.2",
-        "es-module-lexer": "^1.0.0",
-        "get-stream": "^6.0.0",
-        "is-stream": "^2.0.0",
-        "isbinaryfile": "^4.0.6",
-        "koa": "^2.13.0",
-        "koa-etag": "^4.0.0",
-        "koa-send": "^5.0.1",
-        "koa-static": "^5.0.0",
-        "lru-cache": "^6.0.0",
-        "mime-types": "^2.1.27",
-        "parse5": "^6.0.1",
-        "picomatch": "^2.2.2",
-        "ws": "^7.4.2"
-      },
-      "engines": {
-        "node": ">=10.0.0"
-      }
-    },
-    "node_modules/@open-wc/dev-server-hmr/node_modules/@web/parse5-utils": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-1.3.1.tgz",
-      "integrity": "sha512-haCgDchZrAOB9EhBJ5XqiIjBMsS/exsM5Ru7sCSyNkXVEJWskyyKuKMFk66BonnIGMPpDtqDrTUfYEis5Zi3XA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/parse5": "^6.0.1",
-        "parse5": "^6.0.1"
-      },
-      "engines": {
-        "node": ">=10.0.0"
-      }
-    },
-    "node_modules/@open-wc/dev-server-hmr/node_modules/isbinaryfile": {
-      "version": "4.0.10",
-      "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz",
-      "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 8.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/gjtorikian/"
-      }
-    },
-    "node_modules/@open-wc/dev-server-hmr/node_modules/lru-cache": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "yallist": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/@open-wc/dev-server-hmr/node_modules/yallist": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-      "dev": true,
-      "license": "ISC"
-    },
     "node_modules/@playwright/experimental-ct-core": {
       "version": "1.51.1",
       "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.51.1.tgz",
@@ -1756,83 +1663,6 @@
         "url": "https://paulmillr.com/funding/"
       }
     },
-    "node_modules/@web/dev-server-hmr": {
-      "version": "0.1.12",
-      "resolved": "https://registry.npmjs.org/@web/dev-server-hmr/-/dev-server-hmr-0.1.12.tgz",
-      "integrity": "sha512-oqEYVFAh9D74GUigQqxPN5izhocc+A02tZ7Y4QCIHLe6qttjD5R+Hpj8CAObySslfH1X/IGSsWhB8TGctCxlPA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@web/dev-server-core": "^0.4.1"
-      },
-      "engines": {
-        "node": ">=10.0.0"
-      }
-    },
-    "node_modules/@web/dev-server-hmr/node_modules/@web/dev-server-core": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.4.1.tgz",
-      "integrity": "sha512-KdYwejXZwIZvb6tYMCqU7yBiEOPfKLQ3V9ezqqEz8DA9V9R3oQWaowckvCpFB9IxxPfS/P8/59OkdzGKQjcIUw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/koa": "^2.11.6",
-        "@types/ws": "^7.4.0",
-        "@web/parse5-utils": "^1.3.1",
-        "chokidar": "^3.4.3",
-        "clone": "^2.1.2",
-        "es-module-lexer": "^1.0.0",
-        "get-stream": "^6.0.0",
-        "is-stream": "^2.0.0",
-        "isbinaryfile": "^5.0.0",
-        "koa": "^2.13.0",
-        "koa-etag": "^4.0.0",
-        "koa-send": "^5.0.1",
-        "koa-static": "^5.0.0",
-        "lru-cache": "^6.0.0",
-        "mime-types": "^2.1.27",
-        "parse5": "^6.0.1",
-        "picomatch": "^2.2.2",
-        "ws": "^7.4.2"
-      },
-      "engines": {
-        "node": ">=10.0.0"
-      }
-    },
-    "node_modules/@web/dev-server-hmr/node_modules/@web/parse5-utils": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-1.3.1.tgz",
-      "integrity": "sha512-haCgDchZrAOB9EhBJ5XqiIjBMsS/exsM5Ru7sCSyNkXVEJWskyyKuKMFk66BonnIGMPpDtqDrTUfYEis5Zi3XA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/parse5": "^6.0.1",
-        "parse5": "^6.0.1"
-      },
-      "engines": {
-        "node": ">=10.0.0"
-      }
-    },
-    "node_modules/@web/dev-server-hmr/node_modules/lru-cache": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "yallist": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/@web/dev-server-hmr/node_modules/yallist": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-      "dev": true,
-      "license": "ISC"
-    },
     "node_modules/@web/dev-server-rollup": {
       "version": "0.6.4",
       "resolved": "https://registry.npmjs.org/@web/dev-server-rollup/-/dev-server-rollup-0.6.4.tgz",
@@ -2271,20 +2101,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/anymatch": {
-      "version": "3.1.3",
-      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
-      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "normalize-path": "^3.0.0",
-        "picomatch": "^2.0.4"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
     "node_modules/argparse": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2491,19 +2307,6 @@
         "node": ">=10.0.0"
       }
     },
-    "node_modules/binary-extensions": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
-      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/braces": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -2723,31 +2526,6 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
-    "node_modules/chokidar": {
-      "version": "3.6.0",
-      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
-      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "anymatch": "~3.1.2",
-        "braces": "~3.0.2",
-        "glob-parent": "~5.1.2",
-        "is-binary-path": "~2.1.0",
-        "is-glob": "~4.0.1",
-        "normalize-path": "~3.0.0",
-        "readdirp": "~3.6.0"
-      },
-      "engines": {
-        "node": ">= 8.10.0"
-      },
-      "funding": {
-        "url": "https://paulmillr.com/funding/"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.2"
-      }
-    },
     "node_modules/chrome-launcher": {
       "version": "0.15.2",
       "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
@@ -4432,19 +4210,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/is-binary-path": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
-      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "binary-extensions": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/is-core-module": {
       "version": "2.16.1",
       "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -5218,16 +4983,6 @@
         "node": "*"
       }
     },
-    "node_modules/normalize-path": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
-      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
     "node_modules/normalize-range": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
@@ -5915,19 +5670,6 @@
         "node": ">= 0.8"
       }
     },
-    "node_modules/readdirp": {
-      "version": "3.6.0",
-      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
-      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "picomatch": "^2.2.1"
-      },
-      "engines": {
-        "node": ">=8.10.0"
-      }
-    },
     "node_modules/require-directory": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -7225,7 +6967,6 @@
       "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz",
       "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.4.3",
@@ -7295,6 +7036,22 @@
         }
       }
     },
+    "node_modules/vite-plugin-web-components-hmr": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/vite-plugin-web-components-hmr/-/vite-plugin-web-components-hmr-0.1.3.tgz",
+      "integrity": "sha512-UF+YYOFyaie6cT7XPatRz2Xo1Fh3TdTwOdCq1Z0EjzMfdAlUiUdF3crkN0DdEhctWBvq18CHxJZUTKw7x1zSRQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.12.3",
+        "@babel/plugin-syntax-class-properties": "^7.12.13",
+        "@babel/plugin-syntax-import-assertions": "^7.12.1",
+        "@babel/plugin-syntax-top-level-await": "^7.12.1",
+        "picomatch": "^2.2.2"
+      },
+      "peerDependencies": {
+        "vite": ">=2"
+      }
+    },
     "node_modules/vite/node_modules/fdir": {
       "version": "6.4.4",
       "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
diff --git a/loop/webui/package.json b/loop/webui/package.json
index a06362f..262a4fc 100644
--- a/loop/webui/package.json
+++ b/loop/webui/package.json
@@ -11,7 +11,8 @@
   },
   "scripts": {
     "check": "tsc --noEmit",
-    "demo": "web-dev-server -config ./web-dev-server.config.mjs --node-resolve --open /src/web-components/demo/",
+    "demo": "vite --open src/web-components/demo/index.html",
+    "dev": "vite",
     "format": "prettier ./src --write",
     "gentypes": "go run ../../cmd/go2ts -o src/types.ts",
     "build": "go run ../../cmd/go2ts -o src/types.ts && tsc",
@@ -30,18 +31,18 @@
     "vega-lite": "^5.23.0"
   },
   "devDependencies": {
-    "@open-wc/dev-server-hmr": "^0.1.2-next.0",
     "@sand4rt/experimental-ct-web": "^1.51.1",
     "@types/marked": "^5.0.2",
     "@types/mocha": "^10.0.7",
     "@types/node": "^22.13.14",
-    "@web/dev-server": "^0.4.6",
     "@web/test-runner": "^0.18.2",
     "@web/test-runner-puppeteer": "^0.18.0",
     "autoprefixer": "^10.4.21",
     "esbuild": "^0.25.1",
     "prettier": "3.5.3",
-    "typescript": "^5.8.2"
+    "typescript": "^5.8.2",
+    "vite": "^6.3.2",
+    "vite-plugin-web-components-hmr": "^0.1.3"
   },
   "keywords": []
 }
diff --git a/loop/webui/readme.md b/loop/webui/readme.md
index b904934..49ca0e8 100644
--- a/loop/webui/readme.md
+++ b/loop/webui/readme.md
@@ -20,9 +20,6 @@
 # Install dependencies
 make install
 
-# Build the TypeScript code
-make build
-
 # Type checking only
 make check
 ```
@@ -32,10 +29,15 @@
 For development, you can use watch mode:
 
 ```bash
-make dev
+make demo
 ```
 
-This will rebuild the TypeScript files whenever they change.
+This will launch a local web server that serves the demo pages for the web components. You can edit the TypeScript files, and the changes will be reflected in real-time.
+p
+
+#### VSCode
+
+If you are using Visual Studio Code, you can use the `Launch Chrome against localhost` launch configuration to run the demo server. This configuration is set up to automatically open a sketch page with dummy data in Chrome when you start debugging, supporting hot module reloading and breakpoints.
 
 ## Integration with Go Server
 
diff --git a/loop/webui/src/sketch-app-shell.css b/loop/webui/src/sketch-app-shell.css
new file mode 100644
index 0000000..57c96df
--- /dev/null
+++ b/loop/webui/src/sketch-app-shell.css
@@ -0,0 +1,22 @@
+html,
+body {
+  height: 100%;
+  overflow-y: auto;
+}
+
+body {
+  font-family:
+    system-ui,
+    -apple-system,
+    BlinkMacSystemFont,
+    "Segoe UI",
+    Roboto,
+    sans-serif;
+  margin: 0;
+  padding: 0;
+  color: #333;
+  line-height: 1.4;
+  overflow-x: hidden; /* Prevent horizontal scrolling */
+  display: flex;
+  flex-direction: column;
+}
diff --git a/loop/webui/src/sketch-app-shell.html b/loop/webui/src/sketch-app-shell.html
index 8d1a30c..c12ce8c 100644
--- a/loop/webui/src/sketch-app-shell.html
+++ b/loop/webui/src/sketch-app-shell.html
@@ -4,30 +4,7 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>sketch coding assistant</title>
-    <!-- We only need basic body styling; all component styles are encapsulated -->
-    <style>
-      html,
-      body {
-        height: 100%;
-        overflow-y: auto;
-      }
-      body {
-        font-family:
-          system-ui,
-          -apple-system,
-          BlinkMacSystemFont,
-          "Segoe UI",
-          Roboto,
-          sans-serif;
-        margin: 0;
-        padding: 0;
-        color: #333;
-        line-height: 1.4;
-        overflow-x: hidden; /* Prevent horizontal scrolling */
-        display: flex;
-        flex-direction: column;
-      }
-    </style>
+    <link rel="stylesheet" href="sketch-app-shell.css" />
     <script src="static/sketch-app-shell.js" async type="module"></script>
   </head>
   <body>
diff --git a/loop/webui/src/web-components/aggregateAgentMessages.ts b/loop/webui/src/web-components/aggregateAgentMessages.ts
new file mode 100644
index 0000000..2fbd435
--- /dev/null
+++ b/loop/webui/src/web-components/aggregateAgentMessages.ts
@@ -0,0 +1,34 @@
+import { AgentMessage } from "../types";
+
+export function aggregateAgentMessages(
+  arr1: AgentMessage[],
+  arr2: AgentMessage[]): AgentMessage[] {
+  const mergedArray = [...arr1, ...arr2];
+  const seenIds = new Set<number>();
+  const toolCallResults = new Map<string, AgentMessage>();
+
+  let ret: AgentMessage[] = mergedArray
+    .filter((msg) => {
+      if (msg.type == "tool") {
+        toolCallResults.set(msg.tool_call_id, msg);
+        return false;
+      }
+      if (seenIds.has(msg.idx)) {
+        return false; // Skip if idx is already seen
+      }
+
+      seenIds.add(msg.idx);
+      return true;
+    })
+    .sort((a: AgentMessage, b: AgentMessage) => a.idx - b.idx);
+
+  // Attach any tool_call result messages to the original message's tool_call object.
+  ret.forEach((msg) => {
+    msg.tool_calls?.forEach((toolCall) => {
+      if (toolCallResults.has(toolCall.tool_call_id)) {
+        toolCall.result_message = toolCallResults.get(toolCall.tool_call_id);
+      }
+    });
+  });
+  return ret;
+}
diff --git a/loop/webui/src/web-components/demo/readme.md b/loop/webui/src/web-components/demo/readme.md
index 8e3c33c..324d077 100644
--- a/loop/webui/src/web-components/demo/readme.md
+++ b/loop/webui/src/web-components/demo/readme.md
@@ -2,13 +2,4 @@
 
 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.
+See [README](../../../readme.md#development-mode) for more information on how to run the demo pages.
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
index ef335ed..48fc100 100644
--- a/loop/webui/src/web-components/demo/sketch-app-shell.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-app-shell.demo.html
@@ -1,15 +1,13 @@
-<html>
+<!doctype html>
+<html lang="en">
   <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>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>sketch coding assistant</title>
+    <link rel="stylesheet" href="sketch-app-shell.css" />
+    <script src="../sketch-app-shell.ts" type="module"></script>
   </head>
   <body>
-    <h1>sketch-app-shell demo</h1>
-
     <sketch-app-shell></sketch-app-shell>
   </body>
 </html>
diff --git a/loop/webui/src/web-components/demo/sketch-charts.demo.html b/loop/webui/src/web-components/demo/sketch-charts.demo.html
index d9b714d..64a9bd2 100644
--- a/loop/webui/src/web-components/demo/sketch-charts.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-charts.demo.html
@@ -3,7 +3,7 @@
   <head>
     <meta charset="utf-8" />
     <title>Sketch Charts Demo</title>
-    <script type="module" src="/dist/web-components/sketch-charts.js"></script>
+    <script type="module" src="../sketch-charts.ts"></script>
     <link rel="stylesheet" href="demo.css" />
     <style>
       sketch-charts {
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
index 99d581b..e76aed7 100644
--- a/loop/webui/src/web-components/demo/sketch-chat-input.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-chat-input.demo.html
@@ -2,9 +2,7 @@
   <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 type="module" src="../sketch-chat-input.ts"
     ></script>
 
     <script>
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
index a35e881..e18440d 100644
--- a/loop/webui/src/web-components/demo/sketch-container-status.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-container-status.demo.html
@@ -2,9 +2,7 @@
   <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 type="module" src="../sketch-container-status.ts"
     ></script>
 
     <script>
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
index 3a6cb35..1dc9337 100644
--- a/loop/webui/src/web-components/demo/sketch-diff-view.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-diff-view.demo.html
@@ -9,8 +9,7 @@
       href="../../../node_modules/diff2html/bundles/css/diff2html.min.css"
     />
     <script
-      type="module"
-      src="/dist/web-components/sketch-diff-view.js"
+      type="module" src="../sketch-diff-view.ts"
     ></script>
     <style>
       body {
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
index d645840..04c118c 100644
--- a/loop/webui/src/web-components/demo/sketch-network-status.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-network-status.demo.html
@@ -2,9 +2,7 @@
   <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 type="module" src="../sketch-network-status.ts"
     ></script>
   </head>
   <body>
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
index cb2bdf3..a97145e 100644
--- a/loop/webui/src/web-components/demo/sketch-timeline-message.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-timeline-message.demo.html
@@ -2,9 +2,7 @@
   <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 type="module" src="../sketch-timeline-message.ts"
     ></script>
 
     <script>
diff --git a/loop/webui/src/web-components/demo/sketch-timeline.demo.html b/loop/webui/src/web-components/demo/sketch-timeline.demo.html
index be8ab8e..58abdb2 100644
--- a/loop/webui/src/web-components/demo/sketch-timeline.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-timeline.demo.html
@@ -2,9 +2,7 @@
   <head>
     <title>sketch-timeline demo</title>
     <link rel="stylesheet" href="demo.css" />
-    <script
-      src="/dist/web-components/sketch-timeline.js"
-      type="module"
+    <script type="module" src="../sketch-timeline.ts"
     ></script>
     <script>
       const messages = [
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
index 44b598a..7bedf11 100644
--- a/loop/webui/src/web-components/demo/sketch-tool-calls.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-tool-calls.demo.html
@@ -3,9 +3,7 @@
     <title>sketch-tool-calls demo</title>
     <link rel="stylesheet" href="demo.css" />
 
-    <script
-      src="/dist/web-components/sketch-tool-calls.js"
-      type="module"
+    <script type="module" src="../sketch-tool-calls.ts"
     ></script>
 
     <script>
diff --git a/loop/webui/src/web-components/demo/sketch-tool-card.demo.html b/loop/webui/src/web-components/demo/sketch-tool-card.demo.html
index 17c64ae..3926f2e 100644
--- a/loop/webui/src/web-components/demo/sketch-tool-card.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-tool-card.demo.html
@@ -3,9 +3,7 @@
     <title>sketch-tool-card demo</title>
     <link rel="stylesheet" href="demo.css" />
 
-    <script
-      src="/dist/web-components/sketch-tool-card.js"
-      type="module"
+    <script type="module" src="../sketch-tool-card.ts"
     ></script>
 
     <script>
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
index 7f795fc..af2f1fb 100644
--- 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
@@ -3,9 +3,7 @@
     <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 type="module" src="../sketch-view-mode-select.ts"
     ></script>
 
     <script>
diff --git a/loop/webui/src/web-components/sketch-app-shell.ts b/loop/webui/src/web-components/sketch-app-shell.ts
index 6ef9232..8f57d75 100644
--- a/loop/webui/src/web-components/sketch-app-shell.ts
+++ b/loop/webui/src/web-components/sketch-app-shell.ts
@@ -11,6 +11,7 @@
 import "./sketch-charts";
 import "./sketch-terminal";
 import { SketchDiffView } from "./sketch-diff-view";
+import { aggregateAgentMessages } from "./aggregateAgentMessages";
 
 type ViewMode = "chat" | "diff" | "charts" | "terminal";
 
@@ -24,9 +25,6 @@
   @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
@@ -173,7 +171,7 @@
   messageStatus: string = "";
 
   // Chat messages
-  @property()
+  @property({ attribute: false })
   messages: AgentMessage[] = [];
 
   @property()
@@ -184,7 +182,7 @@
 
   private dataManager = new DataManager();
 
-  @property()
+  @property({ attribute: false })
   containerState: State = {
     title: "",
     os: "",
@@ -194,15 +192,12 @@
     initial_commit: "",
   };
 
-  // Track if this is the first load of messages
-  @state()
-  private isFirstLoad: boolean = true;
-
   // Mutation observer to detect when new messages are added
   private mutationObserver: MutationObserver | null = null;
 
   constructor() {
     super();
+    console.log("Hello!");
 
     // Binding methods to this
     this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
@@ -222,30 +217,21 @@
 
     this.toggleViewMode(mode as ViewMode, false);
     // Add popstate event listener to handle browser back/forward navigation
-    window.addEventListener("popstate", this._handlePopState as EventListener);
+    window.addEventListener("popstate", this._handlePopState);
 
     // 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,
-    );
+    window.addEventListener("view-mode-select", this._handleViewModeSelect);
+    window.addEventListener("diff-comment", this._handleDiffComment);
+    window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
 
     // register event listeners
     this.dataManager.addEventListener(
       "dataChanged",
-      this.handleDataChanged.bind(this),
+      this.handleDataChanged.bind(this)
     );
     this.dataManager.addEventListener(
       "connectionStatusChanged",
-      this.handleConnectionStatusChanged.bind(this),
+      this.handleConnectionStatusChanged.bind(this)
     );
 
     // Initialize the data manager
@@ -255,33 +241,21 @@
   // See https://lit.dev/docs/components/lifecycle/
   disconnectedCallback() {
     super.disconnectedCallback();
-    window.removeEventListener(
-      "popstate",
-      this._handlePopState as EventListener,
-    );
+    window.removeEventListener("popstate", this._handlePopState);
 
     // 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,
-    );
+    window.removeEventListener("view-mode-select", this._handleViewModeSelect);
+    window.removeEventListener("diff-comment", this._handleDiffComment);
+    window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
 
     // unregister data manager event listeners
     this.dataManager.removeEventListener(
       "dataChanged",
-      this.handleDataChanged.bind(this),
+      this.handleDataChanged.bind(this)
     );
     this.dataManager.removeEventListener(
       "connectionStatusChanged",
-      this.handleConnectionStatusChanged.bind(this),
+      this.handleConnectionStatusChanged.bind(this)
     );
 
     // Disconnect mutation observer if it exists
@@ -303,7 +277,7 @@
     if (mode !== "chat") {
       url.searchParams.set("view", mode);
       const diffView = this.shadowRoot?.querySelector(
-        ".diff-view",
+        ".diff-view"
       ) as SketchDiffView;
 
       // If in diff view and there's a commit hash, include that too
@@ -316,7 +290,7 @@
     window.history.pushState({ mode }, "", url.toString());
   }
 
-  _handlePopState(event) {
+  private _handlePopState(event: PopStateEvent) {
     if (event.state && event.state.mode) {
       this.toggleViewMode(event.state.mode, false);
     } else {
@@ -376,7 +350,7 @@
    * Listen for commit diff event
    * @param commitHash The commit hash to show diff for
    */
-  public showCommitDiff(commitHash: string): void {
+  private showCommitDiff(commitHash: string): void {
     // Store the commit hash
     this.currentCommitHash = commitHash;
 
@@ -397,7 +371,7 @@
   /**
    * Toggle between different view modes: chat, diff, charts, terminal
    */
-  public toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
+  private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
     // Don't do anything if the mode is already active
     if (this.viewMode === mode) return;
 
@@ -457,7 +431,7 @@
 
       // Update view mode buttons
       const viewModeSelect = this.shadowRoot?.querySelector(
-        "sketch-view-mode-select",
+        "sketch-view-mode-select"
       );
       if (viewModeSelect) {
         const event = new CustomEvent("update-active-mode", {
@@ -476,37 +450,6 @@
     });
   }
 
-  mergeAndDedupe(arr1: AgentMessage[], arr2: AgentMessage[]): AgentMessage[] {
-    const mergedArray = [...arr1, ...arr2];
-    const seenIds = new Set<number>();
-    const toolCallResults = new Map<string, AgentMessage>();
-
-    let ret: AgentMessage[] = mergedArray
-      .filter((msg) => {
-        if (msg.type == "tool") {
-          toolCallResults.set(msg.tool_call_id, msg);
-          return false;
-        }
-        if (seenIds.has(msg.idx)) {
-          return false; // Skip if idx is already seen
-        }
-
-        seenIds.add(msg.idx);
-        return true;
-      })
-      .sort((a: AgentMessage, b: AgentMessage) => a.idx - b.idx);
-
-    // Attach any tool_call result messages to the original message's tool_call object.
-    ret.forEach((msg) => {
-      msg.tool_calls?.forEach((toolCall) => {
-        if (toolCallResults.has(toolCall.tool_call_id)) {
-          toolCall.result_message = toolCallResults.get(toolCall.tool_call_id);
-        }
-      });
-    });
-    return ret;
-  }
-
   private handleDataChanged(eventData: {
     state: State;
     newMessages: AgentMessage[];
@@ -516,11 +459,8 @@
 
     // 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.messageStatus = "Initial messages loaded";
     } else if (newMessages && newMessages.length > 0) {
-      console.log(`Auto-scroll: Received ${newMessages.length} new messages`);
       this.messageStatus = "Updated just now";
     } else {
       this.messageStatus = "No new messages";
@@ -536,19 +476,19 @@
     const oldMessageCount = this.messages.length;
 
     // Update messages
-    this.messages = this.mergeAndDedupe(this.messages, newMessages);
+    this.messages = aggregateAgentMessages(this.messages, newMessages);
 
     // Log information about the message update
     if (this.messages.length > oldMessageCount) {
       console.log(
-        `Auto-scroll: Messages updated from ${oldMessageCount} to ${this.messages.length}`,
+        `Auto-scroll: Messages updated from ${oldMessageCount} to ${this.messages.length}`
       );
     }
   }
 
   private handleConnectionStatusChanged(
     status: ConnectionStatus,
-    errorMessage?: string,
+    errorMessage?: string
   ): void {
     this.connectionStatus = status;
     this.connectionErrorMessage = errorMessage || "";
@@ -678,11 +618,11 @@
     // Initial scroll to bottom when component is first rendered
     setTimeout(
       () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
-      50,
+      50
     );
 
     const pollToggleCheckbox = this.renderRoot?.querySelector(
-      "#pollToggle",
+      "#pollToggle"
     ) as HTMLInputElement;
     pollToggleCheckbox?.addEventListener("change", () => {
       this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
diff --git a/loop/webui/src/web-components/sketch-chat-input.ts b/loop/webui/src/web-components/sketch-chat-input.ts
index c181724..d5ec75e 100644
--- a/loop/webui/src/web-components/sketch-chat-input.ts
+++ b/loop/webui/src/web-components/sketch-chat-input.ts
@@ -79,7 +79,7 @@
 
       // Update the textarea value directly, otherwise it won't update until next render
       const textarea = this.shadowRoot?.querySelector(
-        "#chatInput",
+        "#chatInput"
       ) as HTMLTextAreaElement;
       if (textarea) {
         textarea.value = content;
@@ -96,7 +96,7 @@
     // Listen for update-content events
     this.addEventListener(
       "update-content",
-      this._handleUpdateContent as EventListener,
+      this._handleUpdateContent as EventListener
     );
   }
 
@@ -107,7 +107,7 @@
     // Remove event listeners
     this.removeEventListener(
       "update-content",
-      this._handleUpdateContent as EventListener,
+      this._handleUpdateContent as EventListener
     );
   }
 
diff --git a/loop/webui/src/web-components/sketch-timeline.ts b/loop/webui/src/web-components/sketch-timeline.ts
index b630679..1e63f32 100644
--- a/loop/webui/src/web-components/sketch-timeline.ts
+++ b/loop/webui/src/web-components/sketch-timeline.ts
@@ -7,15 +7,15 @@
 
 @customElement("sketch-timeline")
 export class SketchTimeline extends LitElement {
-  @property()
+  @property({ attribute: false })
   messages: AgentMessage[] = [];
 
   // Track if we should scroll to the bottom
   @state()
   private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
 
-  @property()
-  scrollContainer: HTMLDivElement;
+  @property({ attribute: false })
+  scrollContainer: HTMLElement;
 
   static styles = css`
     /* Hide views initially to prevent flash of content */
@@ -142,7 +142,7 @@
       Math.abs(
         this.scrollContainer.scrollHeight -
           this.scrollContainer.clientHeight -
-          this.scrollContainer.scrollTop,
+          this.scrollContainer.scrollTop
       ) <= 1;
     if (isAtBottom) {
       this.scrollingState = "pinToLatest";
@@ -159,7 +159,7 @@
     // Listen for showCommitDiff events from the renderer
     document.addEventListener(
       "showCommitDiff",
-      this._handleShowCommitDiff as EventListener,
+      this._handleShowCommitDiff as EventListener
     );
     this.scrollContainer?.addEventListener("scroll", this._handleScroll);
   }
@@ -171,7 +171,7 @@
     // Remove event listeners
     document.removeEventListener(
       "showCommitDiff",
-      this._handleShowCommitDiff as EventListener,
+      this._handleShowCommitDiff as EventListener
     );
 
     this.scrollContainer?.removeEventListener("scroll", this._handleScroll);
diff --git a/loop/webui/vite.config.mts b/loop/webui/vite.config.mts
new file mode 100644
index 0000000..74508a5
--- /dev/null
+++ b/loop/webui/vite.config.mts
@@ -0,0 +1,15 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { hmrPlugin, presets } from "vite-plugin-web-components-hmr";
+import { defineConfig } from "vite";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+  plugins: [
+    hmrPlugin({
+      include: ["./src/**/*.ts"],
+      presets: [presets.lit],
+    }),
+  ],
+});
diff --git a/loop/webui/web-dev-server.config.mjs b/loop/webui/web-dev-server.config.mjs
deleted file mode 100644
index a1f598b..0000000
--- a/loop/webui/web-dev-server.config.mjs
+++ /dev/null
@@ -1,13 +0,0 @@
-import { hmrPlugin, presets } from "@open-wc/dev-server-hmr";
-
-export default {
-  port: 8000,
-  nodeResolve: true,
-
-  plugins: [
-    hmrPlugin({
-      include: ["../**/*"],
-      presets: [presets.lit],
-    }),
-  ],
-};