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(
diff --git a/webui/src/web-components/demo/mocks/handlers.ts b/webui/src/web-components/demo/mocks/handlers.ts
index ad3426e..29f0710 100644
--- a/webui/src/web-components/demo/mocks/handlers.ts
+++ b/webui/src/web-components/demo/mocks/handlers.ts
@@ -3,7 +3,6 @@
 import { AgentMessage, State } from "../../../types";
 
 // Mock state updates for SSE simulation
-let currentState = { ...initialState };
 const EMPTY_CONVERSATION =
   new URL(window.location.href).searchParams.get("emptyConversation") === "1";
 const ADD_NEW_MESSAGES =
@@ -11,6 +10,12 @@
 
 const messages = EMPTY_CONVERSATION ? [] : [...initialMessages];
 
+// Initialize state with correct message_count
+let currentState = {
+  ...initialState,
+  message_count: messages.length,
+};
+
 // Text encoder for SSE messages
 const encoder = new TextEncoder();
 
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
index d3bf00d..b0177a0 100644
--- a/webui/src/web-components/sketch-app-shell.ts
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -1408,6 +1408,7 @@
                 .firstMessageIndex=${this.containerState?.first_message_index ||
                 0}
                 .state=${this.containerState}
+                .dataManager=${this.dataManager}
               ></sketch-timeline>
             </div>
           </div>
diff --git a/webui/src/web-components/sketch-timeline.test.ts b/webui/src/web-components/sketch-timeline.test.ts
index 8384453..e803a2e 100644
--- a/webui/src/web-components/sketch-timeline.test.ts
+++ b/webui/src/web-components/sketch-timeline.test.ts
@@ -2,6 +2,51 @@
 import { SketchTimeline } from "./sketch-timeline";
 import { AgentMessage } from "../types";
 
+// Mock DataManager class that mimics the real DataManager interface
+class MockDataManager {
+  private eventListeners: Map<string, Array<(...args: any[]) => void>> =
+    new Map();
+  private isInitialLoadComplete: boolean = false;
+
+  constructor() {
+    this.eventListeners.set("initialLoadComplete", []);
+  }
+
+  addEventListener(event: string, callback: (...args: any[]) => void): void {
+    const listeners = this.eventListeners.get(event) || [];
+    listeners.push(callback);
+    this.eventListeners.set(event, listeners);
+  }
+
+  removeEventListener(event: string, callback: (...args: any[]) => void): void {
+    const listeners = this.eventListeners.get(event) || [];
+    const index = listeners.indexOf(callback);
+    if (index > -1) {
+      listeners.splice(index, 1);
+    }
+  }
+
+  getIsInitialLoadComplete(): boolean {
+    return this.isInitialLoadComplete;
+  }
+
+  triggerInitialLoadComplete(
+    messageCount: number = 0,
+    expectedCount: number = 0,
+  ): void {
+    this.isInitialLoadComplete = true;
+    const listeners = this.eventListeners.get("initialLoadComplete") || [];
+    // Call each listener with the event data object as expected by the component
+    listeners.forEach((listener) => {
+      try {
+        listener({ messageCount, expectedCount });
+      } catch (e) {
+        console.error("Error in event listener:", e);
+      }
+    });
+  }
+}
+
 // Helper function to create mock timeline messages
 function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
   return {
@@ -42,9 +87,12 @@
 }
 
 test("renders empty state when no messages", async ({ mount }) => {
+  const mockDataManager = new MockDataManager();
+
   const timeline = await mount(SketchTimeline, {
     props: {
       messages: [],
+      dataManager: mockDataManager,
     },
   });
 
@@ -56,28 +104,46 @@
 
 test("renders messages when provided", async ({ mount }) => {
   const messages = createMockMessages(5);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   await expect(timeline.locator(".timeline-container")).toBeVisible();
   await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
 });
 
 test("shows thinking indicator when agent is active", async ({ mount }) => {
   const messages = createMockMessages(3);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
       llmCalls: 1,
       toolCalls: ["thinking"],
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   await expect(timeline.locator(".thinking-indicator")).toBeVisible();
   await expect(timeline.locator(".thinking-bubble")).toBeVisible();
   await expect(timeline.locator(".thinking-dots .dot")).toHaveCount(3);
@@ -89,13 +155,22 @@
     createMockMessage({ idx: 1, content: "Hidden message", hide_output: true }),
     createMockMessage({ idx: 2, content: "Visible message 2" }),
   ];
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Should only show 2 visible messages
   await expect(timeline.locator("sketch-timeline-message")).toHaveCount(2);
 
@@ -117,14 +192,23 @@
   mount,
 }) => {
   const messages = createMockMessages(50);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
       initialMessageCount: 10,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Should only render the most recent 10 messages initially
   await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
 
@@ -139,15 +223,24 @@
 
 test("handles viewport expansion correctly", async ({ mount }) => {
   const messages = createMockMessages(50);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
       initialMessageCount: 10,
       loadChunkSize: 5,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Initially shows 10 messages
   await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
 
@@ -174,14 +267,23 @@
   mount,
 }) => {
   const messages = createMockMessages(50);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
       initialMessageCount: 10,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Expand viewport
   await timeline.evaluate((element: SketchTimeline) => {
     (element as any).visibleMessageStartIndex = 10;
@@ -210,13 +312,22 @@
   mount,
 }) => {
   const messages = createMockMessages(10);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Initially should be pinned to latest (button hidden)
   await expect(timeline.locator("#jump-to-latest.floating")).not.toBeVisible();
 
@@ -233,13 +344,22 @@
 
 test("jump-to-latest button calls scroll method", async ({ mount }) => {
   const messages = createMockMessages(10);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Initialize the scroll tracking flag and set to floating state to show button
   await timeline.evaluate((element: SketchTimeline) => {
     // Initialize tracking flag
@@ -277,15 +397,18 @@
   mount,
 }) => {
   const messages = createMockMessages(10);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
-  // Simulate loading state
+  // Set initial load complete first, then simulate loading older messages
   await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
     (element as any).isLoadingOlderMessages = true;
     element.requestUpdate();
     return element.updateComplete;
@@ -300,13 +423,22 @@
 
 test("hides loading indicator when not loading", async ({ mount }) => {
   const messages = createMockMessages(10);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Set initial load complete so no loading indicator is shown
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Should not show loading indicator by default
   await expect(timeline.locator(".loading-indicator")).not.toBeVisible();
 });
@@ -385,23 +517,28 @@
 
 test("cancels loading operations on viewport reset", async ({ mount }) => {
   const messages = createMockMessages(50);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
-  // Set loading state
+  // Set initial load complete and then loading older messages state
   await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
     (element as any).isLoadingOlderMessages = true;
     (element as any).loadingAbortController = new AbortController();
     element.requestUpdate();
     return element.updateComplete;
   });
 
-  // Verify loading state
-  await expect(timeline.locator(".loading-indicator")).toBeVisible();
+  // Verify loading state - should show only the "loading older messages" indicator
+  await expect(timeline.locator(".loading-indicator")).toContainText(
+    "Loading older messages...",
+  );
 
   // Reset viewport (should cancel loading)
   await timeline.evaluate((element: SketchTimeline) => {
@@ -440,13 +577,22 @@
       timestamp: "2023-01-01T12:00:00Z",
     }),
   ];
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   const messageElements = timeline.locator("sketch-timeline-message");
 
   // Check order
@@ -463,13 +609,22 @@
     createMockMessage({ idx: 1, content: "Second message", type: "agent" }),
     createMockMessage({ idx: 2, content: "Third message", type: "user" }),
   ];
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Check that messages have the expected structure
   // The first message should not have a previous message context
   // The second message should have the first as previous, etc.
@@ -544,13 +699,22 @@
     createMockMessage({ idx: 0, content: "Hidden 1", hide_output: true }),
     createMockMessage({ idx: 1, content: "Hidden 2", hide_output: true }),
   ];
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   // Should render the timeline structure but with no visible messages
   await expect(timeline.locator("sketch-timeline-message")).toHaveCount(0);
 
@@ -566,13 +730,22 @@
 
 test("handles message array updates correctly", async ({ mount }) => {
   const initialMessages = createMockMessages(5);
+  const mockDataManager = new MockDataManager();
 
   const timeline = await mount(SketchTimeline, {
     props: {
       messages: initialMessages,
+      dataManager: mockDataManager,
     },
   });
 
+  // Directly set the isInitialLoadComplete state to bypass the event system for testing
+  await timeline.evaluate((element: SketchTimeline) => {
+    (element as any).isInitialLoadComplete = true;
+    element.requestUpdate();
+    return element.updateComplete;
+  });
+
   await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
 
   // Update with more messages
diff --git a/webui/src/web-components/sketch-timeline.ts b/webui/src/web-components/sketch-timeline.ts
index 5dae38a..2f8e5e3 100644
--- a/webui/src/web-components/sketch-timeline.ts
+++ b/webui/src/web-components/sketch-timeline.ts
@@ -51,6 +51,13 @@
   @property({ attribute: false })
   state: State | null = null;
 
+  // Track initial load completion for better rendering control
+  @state()
+  private isInitialLoadComplete: boolean = false;
+
+  @property({ attribute: false })
+  dataManager: any = null; // Reference to DataManager for event listening
+
   // Viewport rendering properties
   @property({ attribute: false })
   initialMessageCount: number = 30;
@@ -71,20 +78,20 @@
   private loadingTimeoutId: number | null = null;
 
   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;
+    /* Hide message content initially to prevent flash of incomplete content */
+    .timeline-container:not(.view-initialized) sketch-timeline-message {
+      opacity: 0;
+      transition: opacity 0.2s ease-in;
     }
 
-    /* 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;
+    /* Show content once initial load is complete */
+    .timeline-container.view-initialized sketch-timeline-message {
+      opacity: 1;
+    }
+
+    /* Always show loading indicators */
+    .timeline-container .loading-indicator {
+      opacity: 1;
     }
 
     .timeline-container {
@@ -96,6 +103,7 @@
       box-sizing: border-box;
       overflow-x: hidden;
       flex: 1;
+      min-height: 100px; /* Ensure container has height for loading indicator */
     }
 
     /* Chat-like timeline styles */
@@ -693,6 +701,35 @@
    * Called after the component's properties have been updated
    */
   updated(changedProperties: PropertyValues): void {
+    // Handle DataManager changes to set up event listeners
+    if (changedProperties.has("dataManager")) {
+      const oldDataManager = changedProperties.get("dataManager");
+
+      // Remove old event listener if it exists
+      if (oldDataManager) {
+        oldDataManager.removeEventListener(
+          "initialLoadComplete",
+          this.handleInitialLoadComplete,
+        );
+      }
+
+      // Add new event listener if dataManager is available
+      if (this.dataManager) {
+        this.dataManager.addEventListener(
+          "initialLoadComplete",
+          this.handleInitialLoadComplete,
+        );
+
+        // Check if initial load is already complete
+        if (
+          this.dataManager.getIsInitialLoadComplete &&
+          this.dataManager.getIsInitialLoadComplete()
+        ) {
+          this.isInitialLoadComplete = true;
+        }
+      }
+    }
+
     // Handle scroll container changes first to prevent race conditions
     if (changedProperties.has("scrollContainer")) {
       // Cancel any ongoing loading operations since container is changing
@@ -813,6 +850,20 @@
   }
 
   /**
+   * Handle initial load completion from DataManager
+   */
+  private handleInitialLoadComplete = (eventData: {
+    messageCount: number;
+    expectedCount: number;
+  }): void => {
+    console.log(
+      `Timeline: Initial load complete - ${eventData.messageCount}/${eventData.expectedCount} messages`,
+    );
+    this.isInitialLoadComplete = true;
+    this.requestUpdate();
+  };
+
+  /**
    * Set up observers for event-driven DOM monitoring
    */
   private setupObservers(): void {
@@ -834,6 +885,14 @@
       this._handleShowCommitDiff as EventListener,
     );
 
+    // Remove DataManager event listener if connected
+    if (this.dataManager) {
+      this.dataManager.removeEventListener(
+        "initialLoadComplete",
+        this.handleInitialLoadComplete,
+      );
+    }
+
     // Use our safe cleanup method
     this.removeScrollListener();
   }
@@ -888,10 +947,23 @@
     const isThinking =
       this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
 
+    // Apply view-initialized class when initial load is complete
+    const containerClass = this.isInitialLoadComplete
+      ? "timeline-container view-initialized"
+      : "timeline-container";
+
     return html`
       <div style="position: relative; height: 100%;">
         <div id="scroll-container">
-          <div class="timeline-container">
+          <div class="${containerClass}">
+            ${!this.isInitialLoadComplete
+              ? html`
+                  <div class="loading-indicator">
+                    <div class="loading-spinner"></div>
+                    <span>Loading conversation...</span>
+                  </div>
+                `
+              : ""}
             ${this.isLoadingOlderMessages
               ? html`
                   <div class="loading-indicator">
@@ -900,30 +972,32 @@
                   </div>
                 `
               : ""}
-            ${repeat(
-              this.visibleMessages,
-              this.messageKey,
-              (message, index) => {
-                // Find the previous message in the full filtered messages array
-                const filteredMessages = this.filteredMessages;
-                const messageIndex = filteredMessages.findIndex(
-                  (m) => m === message,
-                );
-                let previousMessage =
-                  messageIndex > 0
-                    ? filteredMessages[messageIndex - 1]
-                    : undefined;
+            ${this.isInitialLoadComplete
+              ? repeat(
+                  this.visibleMessages,
+                  this.messageKey,
+                  (message, index) => {
+                    // Find the previous message in the full filtered messages array
+                    const filteredMessages = this.filteredMessages;
+                    const messageIndex = filteredMessages.findIndex(
+                      (m) => m === message,
+                    );
+                    let previousMessage =
+                      messageIndex > 0
+                        ? filteredMessages[messageIndex - 1]
+                        : undefined;
 
-                return html`<sketch-timeline-message
-                  .message=${message}
-                  .previousMessage=${previousMessage}
-                  .open=${false}
-                  .firstMessageIndex=${this.firstMessageIndex}
-                  .state=${this.state}
-                ></sketch-timeline-message>`;
-              },
-            )}
-            ${isThinking
+                    return html`<sketch-timeline-message
+                      .message=${message}
+                      .previousMessage=${previousMessage}
+                      .open=${false}
+                      .firstMessageIndex=${this.firstMessageIndex}
+                      .state=${this.state}
+                    ></sketch-timeline-message>`;
+                  },
+                )
+              : ""}
+            ${isThinking && this.isInitialLoadComplete
               ? html`
                   <div class="thinking-indicator">
                     <div class="thinking-bubble">