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">