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(