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/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);