webui: implement explicit initial render detection using State.message_count

Add explicit initial load completion detection to SketchTimeline component using
State.message_count to determine when all existing messages have been loaded
and the timeline is ready for initial render.

Implementation Changes:

1. DataManager Enhancement (data.ts):
   - Add expectedMessageCount and isInitialLoadComplete state tracking
   - Add 'initialLoadComplete' event type to DataManagerEventType union
   - Add checkInitialLoadComplete() method to validate completion state
   - Add handleInitialLoadComplete() event emission with message counts
   - Handle empty conversation edge case (message_count: 0) with immediate completion
   - Reset initial load state on connection establishment to handle reconnection
   - Add getIsInitialLoadComplete() and getExpectedMessageCount() getters

2. Timeline Component Enhancement (sketch-timeline.ts):
   - Add isInitialLoadComplete state property for render control
   - Add dataManager property reference for event listener setup
   - Add handleInitialLoadComplete() event handler with console logging
   - Update render logic to show loading indicator until initial load complete
   - Apply 'view-initialized' CSS class when initial load completes
   - Only render messages and thinking indicator after initial load completion
   - Set up DataManager event listeners in updated() lifecycle hook
   - Clean up event listeners in disconnectedCallback() lifecycle hook

3. App Shell Integration (sketch-app-shell.ts):
   - Pass dataManager reference to sketch-timeline component property
   - Enable timeline component to receive initial load completion events
   - Maintain existing data flow while adding explicit completion detection

4. Demo Mock Enhancement (handlers.ts):
   - Initialize currentState with correct message_count based on initial messages
   - Ensure proper message_count synchronization in SSE stream simulation
   - Handle empty conversation demo scenario with accurate state

5. Enhanced CSS Styling (sketch-timeline.ts):
   - Add opacity-based transitions for message appearance
   - Show loading indicator before initial completion
   - Hide message content until view-initialized class is applied
   - Smooth transition from loading to content display

Technical Benefits:
- Eliminates reliance on implicit 'first message means streaming started' detection
- Provides explicit completion signal when all existing messages are loaded
- Handles edge cases like empty conversations (0 messages) immediately
- Prevents flash of incomplete content during initial load
- Enables proper loading states and smooth transitions
- Supports reconnection scenarios with state reset

User Experience Improvements:
- Clear loading indicator until conversation is fully loaded
- Smooth transition from loading to content display
- No flash of partial message lists during initial load
- Consistent behavior across different conversation sizes
- Better feedback during network delays or large conversation loads

Edge Case Handling:
- Empty conversations (message_count: 0) marked complete immediately
- Messages arriving before state handled gracefully
- Reconnection scenarios reset initial load detection
- Race conditions between state and message delivery resolved

This replaces the implicit initial load detection with explicit State.message_count
based completion detection, providing more reliable initial render timing and
better user experience during conversation loading.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s5126c2705d6ad6bak
diff --git a/webui/src/data.ts b/webui/src/data.ts
index 0a02159..2b3d7ae 100644
--- a/webui/src/data.ts
+++ b/webui/src/data.ts
@@ -4,7 +4,10 @@
 /**
  * Event types for data manager
  */
-export type DataManagerEventType = "dataChanged" | "connectionStatusChanged";
+export type DataManagerEventType =
+  | "dataChanged"
+  | "connectionStatusChanged"
+  | "initialLoadComplete";
 
 /**
  * Connection status types
@@ -31,6 +34,10 @@
   private maxReconnectDelayMs: number = 60000; // Max delay of 60 seconds
   private baseReconnectDelayMs: number = 1000; // Start with 1 second
 
+  // Initial load completion tracking
+  private expectedMessageCount: number | null = null;
+  private isInitialLoadComplete: boolean = false;
+
   // Event listeners
   private eventListeners: Map<
     DataManagerEventType,
@@ -41,6 +48,7 @@
     // Initialize empty arrays for each event type
     this.eventListeners.set("dataChanged", []);
     this.eventListeners.set("connectionStatusChanged", []);
+    this.eventListeners.set("initialLoadComplete", []);
 
     // Check connection status periodically
     setInterval(() => this.checkConnectionStatus(), 5000);
@@ -70,6 +78,10 @@
     // Close any existing connection
     this.closeEventSource();
 
+    // Reset initial load state for new connection
+    this.expectedMessageCount = null;
+    this.isInitialLoadComplete = false;
+
     // Update connection status to connecting
     this.updateConnectionStatus("connecting", "Connecting...");
 
@@ -107,6 +119,26 @@
     this.eventSource.addEventListener("state", (event) => {
       const state = JSON.parse(event.data) as State;
       this.timelineState = state;
+
+      // Store expected message count for initial load detection
+      if (this.expectedMessageCount === null) {
+        this.expectedMessageCount = state.message_count;
+        console.log(
+          `Initial load expects ${this.expectedMessageCount} messages`,
+        );
+
+        // Handle empty conversation case - immediately mark as complete
+        if (this.expectedMessageCount === 0) {
+          this.isInitialLoadComplete = true;
+          console.log(`Initial load complete: Empty conversation (0 messages)`);
+          this.emitEvent("initialLoadComplete", {
+            messageCount: 0,
+            expectedCount: 0,
+          });
+        }
+      }
+
+      this.checkInitialLoadComplete();
       this.emitEvent("dataChanged", { state, newMessages: [] });
     });
 
@@ -183,6 +215,28 @@
   }
 
   /**
+   * Check if initial load is complete based on expected message count
+   */
+  private checkInitialLoadComplete(): void {
+    if (
+      this.expectedMessageCount !== null &&
+      this.expectedMessageCount > 0 &&
+      this.messages.length >= this.expectedMessageCount &&
+      !this.isInitialLoadComplete
+    ) {
+      this.isInitialLoadComplete = true;
+      console.log(
+        `Initial load complete: ${this.messages.length}/${this.expectedMessageCount} messages loaded`,
+      );
+
+      this.emitEvent("initialLoadComplete", {
+        messageCount: this.messages.length,
+        expectedCount: this.expectedMessageCount,
+      });
+    }
+  }
+
+  /**
    * Process a new message from the SSE stream
    */
   private processNewMessage(message: AgentMessage): void {
@@ -208,11 +262,14 @@
       this.isFirstLoad = false;
     }
 
+    // Check if initial load is now complete
+    this.checkInitialLoadComplete();
+
     // Emit an event that data has changed
     this.emitEvent("dataChanged", {
       state: this.timelineState,
       newMessages: [message],
-      isFirstFetch: false,
+      isFirstFetch: this.isInitialLoadComplete,
     });
   }
 
@@ -245,6 +302,20 @@
   }
 
   /**
+   * Get the initial load completion status
+   */
+  public getIsInitialLoadComplete(): boolean {
+    return this.isInitialLoadComplete;
+  }
+
+  /**
+   * Get the expected message count for initial load
+   */
+  public getExpectedMessageCount(): number | null {
+    return this.expectedMessageCount;
+  }
+
+  /**
    * Add an event listener
    */
   public addEventListener(