blob: 9eca07ad2a54d5b4af35769a159873f1e5a66a0e [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
bankseane59a2e12025-06-28 01:38:19 +00002import { html } from "lit";
Sean McCullough2c5bba42025-04-20 19:33:17 -07003import { PropertyValues } from "lit";
Sean McCullough71941bd2025-04-18 13:31:48 -07004import { repeat } from "lit/directives/repeat.js";
Sean McCullough2c5bba42025-04-20 19:33:17 -07005import { customElement, property, state } from "lit/decorators.js";
philip.zeyliger6d3de482025-06-10 19:38:14 -07006import { AgentMessage, State } from "../types";
Sean McCullough71941bd2025-04-18 13:31:48 -07007import "./sketch-timeline-message";
bankseane59a2e12025-06-28 01:38:19 +00008import { SketchTailwindElement } from "./sketch-tailwind-element";
Pokey Rule4097e532025-04-24 18:55:28 +01009import { Ref } from "lit/directives/ref";
Sean McCullough86b56862025-04-18 13:04:03 -070010
Sean McCullough71941bd2025-04-18 13:31:48 -070011@customElement("sketch-timeline")
bankseane59a2e12025-06-28 01:38:19 +000012export class SketchTimeline extends SketchTailwindElement {
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010013 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -070014 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -070015
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000016 // Active state properties to show thinking indicator
17 @property({ attribute: false })
18 agentState: string | null = null;
19
20 @property({ attribute: false })
21 llmCalls: number = 0;
22
23 @property({ attribute: false })
24 toolCalls: string[] = [];
25
Sean McCullough2c5bba42025-04-20 19:33:17 -070026 // Track if we should scroll to the bottom
27 @state()
28 private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
29
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010030 @property({ attribute: false })
Pokey Rule4097e532025-04-24 18:55:28 +010031 scrollContainer: Ref<HTMLElement>;
Sean McCullough2c5bba42025-04-20 19:33:17 -070032
Sean McCulloughe68613d2025-06-18 14:48:53 +000033 // Keep track of current scroll container for cleanup
34 private currentScrollContainer: HTMLElement | null = null;
35
36 // Event-driven scroll handling without setTimeout
37 private scrollDebounceFrame: number | null = null;
38
39 // Loading operation management with proper cancellation
40 private loadingAbortController: AbortController | null = null;
41 private pendingScrollRestoration: (() => void) | null = null;
42
43 // Track current loading operation for cancellation
44 private currentLoadingOperation: Promise<void> | null = null;
45
46 // Observers for event-driven DOM updates
47 private resizeObserver: ResizeObserver | null = null;
48 private mutationObserver: MutationObserver | null = null;
49
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070050 @property({ attribute: false })
51 firstMessageIndex: number = 0;
52
philip.zeyliger6d3de482025-06-10 19:38:14 -070053 @property({ attribute: false })
54 state: State | null = null;
55
David Crawshaw4b644682025-06-26 17:15:10 +000056 @property({ attribute: false })
57 compactPadding: boolean = false;
58
banksean54777362025-06-19 16:38:30 +000059 // Track initial load completion for better rendering control
60 @state()
61 private isInitialLoadComplete: boolean = false;
62
63 @property({ attribute: false })
64 dataManager: any = null; // Reference to DataManager for event listening
65
Sean McCulloughe68613d2025-06-18 14:48:53 +000066 // Viewport rendering properties
67 @property({ attribute: false })
68 initialMessageCount: number = 30;
69
70 @property({ attribute: false })
71 loadChunkSize: number = 20;
72
73 @state()
74 private visibleMessageStartIndex: number = 0;
75
76 @state()
77 private isLoadingOlderMessages: boolean = false;
78
79 // Threshold for triggering load more (pixels from top)
80 private loadMoreThreshold: number = 100;
81
82 // Timeout ID for loading operations
83 private loadingTimeoutId: number | null = null;
84
Sean McCullough86b56862025-04-18 13:04:03 -070085 constructor() {
86 super();
Sean McCullough71941bd2025-04-18 13:31:48 -070087
Sean McCullough86b56862025-04-18 13:04:03 -070088 // Binding methods
89 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -070090 this._handleScroll = this._handleScroll.bind(this);
bankseane59a2e12025-06-28 01:38:19 +000091
92 // Add custom animations and styles that can't be easily done with Tailwind
93 this.addCustomStyles();
94 }
95
96 private addCustomStyles() {
97 const styleId = "sketch-timeline-custom-styles";
98 if (document.getElementById(styleId)) {
99 return; // Already added
100 }
101
102 const style = document.createElement("style");
103 style.id = styleId;
104 style.textContent = `
105 /* Hide message content initially to prevent flash of incomplete content */
106 .timeline-not-initialized sketch-timeline-message {
107 opacity: 0;
108 transition: opacity 0.2s ease-in;
109 }
110
111 /* Show content once initial load is complete */
112 .timeline-initialized sketch-timeline-message {
113 opacity: 1;
114 }
115
116 /* Custom animations for thinking dots */
117 @keyframes thinking-pulse {
118 0%, 100% {
119 opacity: 0.4;
120 transform: scale(1);
121 }
122 50% {
123 opacity: 1;
124 transform: scale(1.2);
125 }
126 }
127
128 .thinking-dot-1 {
129 animation: thinking-pulse 1.5s infinite ease-in-out;
130 }
131
132 .thinking-dot-2 {
133 animation: thinking-pulse 1.5s infinite ease-in-out 0.3s;
134 }
135
136 .thinking-dot-3 {
137 animation: thinking-pulse 1.5s infinite ease-in-out 0.6s;
138 }
139
140 /* Custom spinner animation */
141 @keyframes loading-spin {
142 0% {
143 transform: rotate(0deg);
144 }
145 100% {
146 transform: rotate(360deg);
147 }
148 }
149
150 .loading-spinner {
151 animation: loading-spin 1s linear infinite;
152 }
153
154 /* Custom compact padding styling */
155 .compact-padding .scroll-container {
156 padding-left: 0;
157 }
158 `;
159 document.head.appendChild(style);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700160 }
161
162 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000163 * Safely add scroll event listener with proper cleanup tracking
164 */
165 private addScrollListener(container: HTMLElement): void {
166 // Remove any existing listener first
167 this.removeScrollListener();
168
169 // Add new listener and track the container
170 container.addEventListener("scroll", this._handleScroll);
171 this.currentScrollContainer = container;
172 }
173
174 /**
175 * Safely remove scroll event listener
176 */
177 private removeScrollListener(): void {
178 if (this.currentScrollContainer) {
179 this.currentScrollContainer.removeEventListener(
180 "scroll",
181 this._handleScroll,
182 );
183 this.currentScrollContainer = null;
184 }
185
186 // Clear any pending timeouts and operations
187 this.clearAllPendingOperations();
188 }
189
190 /**
191 * Clear all pending operations and observers to prevent race conditions
192 */
193 private clearAllPendingOperations(): void {
194 // Clear scroll debounce frame
195 if (this.scrollDebounceFrame) {
196 cancelAnimationFrame(this.scrollDebounceFrame);
197 this.scrollDebounceFrame = null;
198 }
199
200 // Abort loading operations
201 if (this.loadingAbortController) {
202 this.loadingAbortController.abort();
203 this.loadingAbortController = null;
204 }
205
206 // Cancel pending scroll restoration
207 if (this.pendingScrollRestoration) {
208 this.pendingScrollRestoration = null;
209 }
210
211 // Clean up observers
212 this.disconnectObservers();
213 }
214
215 /**
216 * Disconnect all observers
217 */
218 private disconnectObservers(): void {
219 if (this.resizeObserver) {
220 this.resizeObserver.disconnect();
221 this.resizeObserver = null;
222 }
223
224 if (this.mutationObserver) {
225 this.mutationObserver.disconnect();
226 this.mutationObserver = null;
227 }
228 }
229
230 /**
231 * Force a viewport reset to show the most recent messages
232 * Useful when loading a new session or when messages change significantly
233 */
234 public resetViewport(): void {
235 // Cancel any pending loading operations to prevent race conditions
236 this.cancelCurrentLoadingOperation();
237
238 // Reset viewport state
239 this.visibleMessageStartIndex = 0;
240 this.isLoadingOlderMessages = false;
241
242 // Clear all pending operations
243 this.clearAllPendingOperations();
244
245 this.requestUpdate();
246 }
247
248 /**
249 * Cancel current loading operation if in progress
250 */
251 private cancelCurrentLoadingOperation(): void {
252 if (this.isLoadingOlderMessages) {
253 this.isLoadingOlderMessages = false;
254
255 // Abort the loading operation
256 if (this.loadingAbortController) {
257 this.loadingAbortController.abort();
258 this.loadingAbortController = null;
259 }
260
261 // Cancel pending scroll restoration
262 this.pendingScrollRestoration = null;
263 }
264 }
265
266 /**
267 * Get the filtered messages (excluding hidden ones)
268 */
269 private get filteredMessages(): AgentMessage[] {
270 return this.messages.filter((msg) => !msg.hide_output);
271 }
272
273 /**
274 * Get the currently visible messages based on viewport rendering
275 * Race-condition safe implementation
276 */
277 private get visibleMessages(): AgentMessage[] {
278 const filtered = this.filteredMessages;
279 if (filtered.length === 0) return [];
280
281 // Always show the most recent messages first
282 // visibleMessageStartIndex represents how many additional older messages to show
283 const totalVisible =
284 this.initialMessageCount + this.visibleMessageStartIndex;
285 const startIndex = Math.max(0, filtered.length - totalVisible);
286
287 // Ensure we don't return an invalid slice during loading operations
288 const endIndex = filtered.length;
289 if (startIndex >= endIndex) {
290 return [];
291 }
292
293 return filtered.slice(startIndex, endIndex);
294 }
295
296 /**
297 * Check if the component is in a stable state for loading operations
298 */
299 private isStableForLoading(): boolean {
300 return (
301 this.scrollContainer.value !== null &&
302 this.scrollContainer.value === this.currentScrollContainer &&
303 this.scrollContainer.value.isConnected &&
304 !this.isLoadingOlderMessages &&
305 !this.currentLoadingOperation
306 );
307 }
308
309 /**
310 * Load more older messages by expanding the visible window
311 * Race-condition safe implementation
312 */
313 private async loadOlderMessages(): Promise<void> {
314 // Prevent concurrent loading operations
315 if (this.isLoadingOlderMessages || this.currentLoadingOperation) {
316 return;
317 }
318
319 const filtered = this.filteredMessages;
320 const currentVisibleCount = this.visibleMessages.length;
321 const totalAvailable = filtered.length;
322
323 // Check if there are more messages to load
324 if (currentVisibleCount >= totalAvailable) {
325 return;
326 }
327
328 // Start loading operation with proper state management
329 this.isLoadingOlderMessages = true;
330
331 // Store current scroll position for restoration
332 const container = this.scrollContainer.value;
333 const previousScrollHeight = container?.scrollHeight || 0;
334 const previousScrollTop = container?.scrollTop || 0;
335
336 // Validate scroll container hasn't changed during setup
337 if (!container || container !== this.currentScrollContainer) {
338 this.isLoadingOlderMessages = false;
339 return;
340 }
341
342 // Expand the visible window with bounds checking
343 const additionalMessages = Math.min(
344 this.loadChunkSize,
345 totalAvailable - currentVisibleCount,
346 );
347 const newStartIndex = this.visibleMessageStartIndex + additionalMessages;
348
349 // Ensure we don't exceed available messages
350 const boundedStartIndex = Math.min(
351 newStartIndex,
352 totalAvailable - this.initialMessageCount,
353 );
354 this.visibleMessageStartIndex = Math.max(0, boundedStartIndex);
355
356 // Create the loading operation with proper error handling and cleanup
357 const loadingOperation = this.executeScrollPositionRestoration(
358 container,
359 previousScrollHeight,
360 previousScrollTop,
361 );
362
363 this.currentLoadingOperation = loadingOperation;
364
365 try {
366 await loadingOperation;
367 } catch (error) {
368 console.warn("Loading operation failed:", error);
369 } finally {
370 // Ensure loading state is always cleared
371 this.isLoadingOlderMessages = false;
372 this.currentLoadingOperation = null;
373
374 // Clear the loading timeout if it exists
375 if (this.loadingTimeoutId) {
376 clearTimeout(this.loadingTimeoutId);
377 this.loadingTimeoutId = null;
378 }
379 }
380 }
381
382 /**
383 * Execute scroll position restoration with event-driven approach
384 */
385 private async executeScrollPositionRestoration(
386 container: HTMLElement,
387 previousScrollHeight: number,
388 previousScrollTop: number,
389 ): Promise<void> {
390 // Set up AbortController for proper cancellation
391 this.loadingAbortController = new AbortController();
392 const { signal } = this.loadingAbortController;
393
394 // Create scroll restoration function
395 const restoreScrollPosition = () => {
396 // Check if operation was aborted
397 if (signal.aborted) {
398 return;
399 }
400
401 // Double-check container is still valid and connected
402 if (
403 !container ||
404 !container.isConnected ||
405 container !== this.currentScrollContainer
406 ) {
407 return;
408 }
409
410 try {
411 const newScrollHeight = container.scrollHeight;
412 const scrollDifference = newScrollHeight - previousScrollHeight;
413 const newScrollTop = previousScrollTop + scrollDifference;
414
415 // Validate all scroll calculations before applying
416 const isValidRestoration =
417 scrollDifference > 0 && // Content was added
418 newScrollTop >= 0 && // New position is valid
419 newScrollTop <= newScrollHeight && // Don't exceed max scroll
420 previousScrollHeight > 0 && // Had valid previous height
421 newScrollHeight > previousScrollHeight; // Height actually increased
422
423 if (isValidRestoration) {
424 container.scrollTop = newScrollTop;
425 } else {
426 // Log invalid restoration attempts for debugging
427 console.debug("Skipped scroll restoration:", {
428 scrollDifference,
429 newScrollTop,
430 newScrollHeight,
431 previousScrollHeight,
432 previousScrollTop,
433 });
434 }
435 } catch (error) {
436 console.warn("Scroll position restoration failed:", error);
437 }
438 };
439
440 // Store the restoration function for potential cancellation
441 this.pendingScrollRestoration = restoreScrollPosition;
442
443 // Wait for DOM update and then restore scroll position
444 await this.updateComplete;
445
446 // Check if operation was cancelled during await
447 if (
448 !signal.aborted &&
449 this.pendingScrollRestoration === restoreScrollPosition
450 ) {
451 // Use ResizeObserver to detect when content is actually ready
452 await this.waitForContentReady(container, signal);
453
454 if (!signal.aborted) {
455 restoreScrollPosition();
456 this.pendingScrollRestoration = null;
457 }
458 }
459 }
460
461 /**
462 * Wait for content to be ready using ResizeObserver instead of setTimeout
463 */
464 private async waitForContentReady(
465 container: HTMLElement,
466 signal: AbortSignal,
467 ): Promise<void> {
468 return new Promise((resolve, reject) => {
469 if (signal.aborted) {
470 reject(new Error("Operation aborted"));
471 return;
472 }
473
474 // Resolve immediately if container already has content
475 if (container.scrollHeight > 0) {
476 resolve();
477 return;
478 }
479
480 // Set up ResizeObserver to detect content changes
481 const observer = new ResizeObserver((entries) => {
482 if (signal.aborted) {
483 observer.disconnect();
484 reject(new Error("Operation aborted"));
485 return;
486 }
487
488 // Content is ready when height increases
489 const entry = entries[0];
490 if (entry && entry.contentRect.height > 0) {
491 observer.disconnect();
492 resolve();
493 }
494 });
495
496 // Start observing
497 observer.observe(container);
498
499 // Clean up on abort
500 signal.addEventListener("abort", () => {
501 observer.disconnect();
502 reject(new Error("Operation aborted"));
503 });
504 });
505 }
506
507 /**
Sean McCullough2c5bba42025-04-20 19:33:17 -0700508 * Scroll to the bottom of the timeline
509 */
510 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000511 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000512
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000513 // Use instant scroll to ensure we reach the exact bottom
514 this.scrollContainer.value.scrollTo({
515 top: this.scrollContainer.value.scrollHeight,
516 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700517 });
518 }
Autoformatter71c73b52025-05-29 20:18:43 +0000519
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000520 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000521 * Scroll to bottom with event-driven approach using MutationObserver
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000522 */
Sean McCulloughe68613d2025-06-18 14:48:53 +0000523 private async scrollToBottomWithRetry(): Promise<void> {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000524 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000525
Sean McCulloughe68613d2025-06-18 14:48:53 +0000526 const container = this.scrollContainer.value;
Autoformatter71c73b52025-05-29 20:18:43 +0000527
Sean McCulloughe68613d2025-06-18 14:48:53 +0000528 // Try immediate scroll first
529 this.scrollToBottom();
Autoformatter71c73b52025-05-29 20:18:43 +0000530
Sean McCulloughe68613d2025-06-18 14:48:53 +0000531 // Check if we're at the bottom
532 const isAtBottom = () => {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000533 const targetScrollTop = container.scrollHeight - container.clientHeight;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000534 const actualScrollTop = container.scrollTop;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000535 return Math.abs(targetScrollTop - actualScrollTop) <= 1;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000536 };
Autoformatter71c73b52025-05-29 20:18:43 +0000537
Sean McCulloughe68613d2025-06-18 14:48:53 +0000538 // If already at bottom, we're done
539 if (isAtBottom()) {
540 return;
541 }
542
543 // Use MutationObserver to detect content changes and retry
544 return new Promise((resolve) => {
545 let scrollAttempted = false;
546
547 const observer = new MutationObserver(() => {
548 if (!scrollAttempted) {
549 scrollAttempted = true;
550
551 // Use requestAnimationFrame to ensure DOM is painted
552 requestAnimationFrame(() => {
553 this.scrollToBottom();
554
555 // Check if successful
556 if (isAtBottom()) {
557 observer.disconnect();
558 resolve();
559 } else {
560 // Try one more time after another frame
561 requestAnimationFrame(() => {
562 this.scrollToBottom();
563 observer.disconnect();
564 resolve();
565 });
566 }
567 });
568 }
569 });
570
571 // Observe changes to the timeline container
572 observer.observe(container, {
573 childList: true,
574 subtree: true,
575 attributes: false,
576 });
577
578 // Clean up after a reasonable time if no changes detected
579 requestAnimationFrame(() => {
580 requestAnimationFrame(() => {
581 if (!scrollAttempted) {
582 observer.disconnect();
583 resolve();
584 }
585 });
586 });
587 });
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000588 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700589
590 /**
591 * Called after the component's properties have been updated
592 */
593 updated(changedProperties: PropertyValues): void {
banksean54777362025-06-19 16:38:30 +0000594 // Handle DataManager changes to set up event listeners
595 if (changedProperties.has("dataManager")) {
596 const oldDataManager = changedProperties.get("dataManager");
597
598 // Remove old event listener if it exists
599 if (oldDataManager) {
600 oldDataManager.removeEventListener(
601 "initialLoadComplete",
602 this.handleInitialLoadComplete,
603 );
604 }
605
606 // Add new event listener if dataManager is available
607 if (this.dataManager) {
608 this.dataManager.addEventListener(
609 "initialLoadComplete",
610 this.handleInitialLoadComplete,
611 );
612
613 // Check if initial load is already complete
614 if (
615 this.dataManager.getIsInitialLoadComplete &&
616 this.dataManager.getIsInitialLoadComplete()
617 ) {
618 this.isInitialLoadComplete = true;
619 }
620 }
621 }
622
Sean McCulloughe68613d2025-06-18 14:48:53 +0000623 // Handle scroll container changes first to prevent race conditions
624 if (changedProperties.has("scrollContainer")) {
625 // Cancel any ongoing loading operations since container is changing
626 this.cancelCurrentLoadingOperation();
627
628 if (this.scrollContainer.value) {
629 this.addScrollListener(this.scrollContainer.value);
630 } else {
631 this.removeScrollListener();
Sean McCullough2c5bba42025-04-20 19:33:17 -0700632 }
633 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000634
635 // If messages have changed, handle viewport updates
636 if (changedProperties.has("messages")) {
637 const oldMessages =
638 (changedProperties.get("messages") as AgentMessage[]) || [];
639 const newMessages = this.messages || [];
640
641 // Cancel loading operations if messages changed significantly
642 const significantChange =
643 oldMessages.length === 0 ||
644 newMessages.length < oldMessages.length ||
645 Math.abs(newMessages.length - oldMessages.length) > 20;
646
647 if (significantChange) {
648 // Cancel any ongoing operations and reset viewport
649 this.cancelCurrentLoadingOperation();
650 this.visibleMessageStartIndex = 0;
651 }
652
653 // Scroll to bottom if needed (only if not loading to prevent race conditions)
654 if (
655 this.messages.length > 0 &&
656 this.scrollingState === "pinToLatest" &&
657 !this.isLoadingOlderMessages
658 ) {
659 // Use async scroll without setTimeout
660 this.scrollToBottomWithRetry().catch((error) => {
661 console.warn("Scroll to bottom failed:", error);
662 });
663 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700664 }
Sean McCullough86b56862025-04-18 13:04:03 -0700665 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700666
Sean McCullough86b56862025-04-18 13:04:03 -0700667 /**
668 * Handle showCommitDiff event
669 */
670 private _handleShowCommitDiff(event: CustomEvent) {
671 const { commitHash } = event.detail;
672 if (commitHash) {
673 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700674 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700675 detail: { commitHash },
676 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700677 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700678 });
679 this.dispatchEvent(newEvent);
680 }
681 }
682
philip.zeyliger26bc6592025-06-30 20:15:30 -0700683 private _handleScroll(_event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000684 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000685
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000686 const container = this.scrollContainer.value;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000687
688 // Verify this is still our tracked container to prevent race conditions
689 if (container !== this.currentScrollContainer) {
690 return;
691 }
692
Sean McCullough2c5bba42025-04-20 19:33:17 -0700693 const isAtBottom =
694 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000695 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000696 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000697
Sean McCulloughe68613d2025-06-18 14:48:53 +0000698 const isNearTop = container.scrollTop <= this.loadMoreThreshold;
699
700 // Update scroll state immediately for responsive UI
Sean McCullough2c5bba42025-04-20 19:33:17 -0700701 if (isAtBottom) {
702 this.scrollingState = "pinToLatest";
703 } else {
Sean McCullough2c5bba42025-04-20 19:33:17 -0700704 this.scrollingState = "floating";
705 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000706
707 // Use requestAnimationFrame for smooth debouncing instead of setTimeout
708 if (this.scrollDebounceFrame) {
709 cancelAnimationFrame(this.scrollDebounceFrame);
710 }
711
712 this.scrollDebounceFrame = requestAnimationFrame(() => {
713 // Use stability check to ensure safe loading conditions
714 if (isNearTop && this.isStableForLoading()) {
715 this.loadOlderMessages().catch((error) => {
716 console.warn("Async loadOlderMessages failed:", error);
717 });
718 }
719 this.scrollDebounceFrame = null;
720 });
Sean McCullough2c5bba42025-04-20 19:33:17 -0700721 }
722
Sean McCullough86b56862025-04-18 13:04:03 -0700723 // See https://lit.dev/docs/components/lifecycle/
724 connectedCallback() {
725 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700726
Sean McCullough86b56862025-04-18 13:04:03 -0700727 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700728 document.addEventListener(
729 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700730 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700731 );
Pokey Rule4097e532025-04-24 18:55:28 +0100732
Sean McCulloughe68613d2025-06-18 14:48:53 +0000733 // Set up scroll listener if container is available
734 if (this.scrollContainer.value) {
735 this.addScrollListener(this.scrollContainer.value);
736 }
737
738 // Initialize observers for event-driven behavior
739 this.setupObservers();
Sean McCullough86b56862025-04-18 13:04:03 -0700740 }
741
Sean McCulloughe68613d2025-06-18 14:48:53 +0000742 /**
banksean54777362025-06-19 16:38:30 +0000743 * Handle initial load completion from DataManager
744 */
745 private handleInitialLoadComplete = (eventData: {
746 messageCount: number;
747 expectedCount: number;
748 }): void => {
749 console.log(
750 `Timeline: Initial load complete - ${eventData.messageCount}/${eventData.expectedCount} messages`,
751 );
752 this.isInitialLoadComplete = true;
753 this.requestUpdate();
754 };
755
756 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000757 * Set up observers for event-driven DOM monitoring
758 */
759 private setupObservers(): void {
760 // ResizeObserver will be created on-demand in loading operations
761 // MutationObserver will be created on-demand in scroll operations
762 // This avoids creating observers that may not be needed
763 }
764
765 // See https://lit.dev/docs/component/lifecycle/
Sean McCullough86b56862025-04-18 13:04:03 -0700766 disconnectedCallback() {
767 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700768
Sean McCulloughe68613d2025-06-18 14:48:53 +0000769 // Cancel any ongoing loading operations before cleanup
770 this.cancelCurrentLoadingOperation();
771
772 // Remove event listeners with guaranteed cleanup
Sean McCullough71941bd2025-04-18 13:31:48 -0700773 document.removeEventListener(
774 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700775 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700776 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700777
banksean54777362025-06-19 16:38:30 +0000778 // Remove DataManager event listener if connected
779 if (this.dataManager) {
780 this.dataManager.removeEventListener(
781 "initialLoadComplete",
782 this.handleInitialLoadComplete,
783 );
784 }
785
Sean McCulloughe68613d2025-06-18 14:48:53 +0000786 // Use our safe cleanup method
787 this.removeScrollListener();
Sean McCullough86b56862025-04-18 13:04:03 -0700788 }
789
Sean McCulloughd9f13372025-04-21 15:08:49 -0700790 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700791 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700792 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700793 // If the message has tool calls, and any of the tool_calls get a response, we need to
794 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700795 const toolCallResponses = message.tool_calls
796 ?.filter((tc) => tc.result_message)
797 .map((tc) => tc.tool_call_id)
798 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700799 return `message-${message.idx}-${toolCallResponses}`;
800 }
801
802 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000803 // Check if messages array is empty and render welcome box if it is
David Crawshawe5b2fc02025-07-06 16:33:46 +0000804 // Skip welcome box in newsessions (compactPadding) context
805 if (this.messages.length === 0 && !this.compactPadding) {
bankseane59a2e12025-06-28 01:38:19 +0000806 const compactClass = this.compactPadding ? "compact-padding" : "";
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000807 return html`
bankseane59a2e12025-06-28 01:38:19 +0000808 <div class="relative h-full">
809 <div
810 id="scroll-container"
811 class="overflow-y-auto overflow-x-hidden pl-4 max-w-full w-full h-full ${compactClass} scroll-container print:h-auto print:max-h-none print:overflow-visible"
812 >
813 <div
814 class="my-8 mx-auto max-w-[90%] w-[90%] p-8 border-2 border-gray-300 rounded-lg shadow-sm bg-white text-center print:break-inside-avoid"
815 data-testid="welcome-box"
816 >
817 <h2
818 class="text-2xl font-semibold mb-6 text-center text-gray-800"
819 data-testid="welcome-box-title"
820 >
821 How to use Sketch
822 </h2>
823 <p class="text-gray-600 leading-relaxed text-base text-left">
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700824 Sketch is an agentic coding assistant.
825 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700826
bankseane59a2e12025-06-28 01:38:19 +0000827 <p class="text-gray-600 leading-relaxed text-base text-left">
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700828 Sketch has created a container with your repo.
829 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700830
bankseane59a2e12025-06-28 01:38:19 +0000831 <p class="text-gray-600 leading-relaxed text-base text-left">
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700832 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000833 below. It can edit and run your code, all in the container.
834 Sketch will create commits in a newly created git branch, which
835 you can look at and comment on in the Diff tab. Once you're
836 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700837 </p>
bankseane59a2e12025-06-28 01:38:19 +0000838 <p class="text-gray-600 leading-relaxed text-base text-left">
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700839 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000840 Sketch in parallel to work on multiple ideas or even the same
841 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700842 </p>
843 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000844 </div>
845 </div>
846 `;
847 }
848
849 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000850 const isThinking =
851 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
852
banksean54777362025-06-19 16:38:30 +0000853 // Apply view-initialized class when initial load is complete
bankseane59a2e12025-06-28 01:38:19 +0000854 const timelineStateClass = this.isInitialLoadComplete
855 ? "timeline-initialized"
856 : "timeline-not-initialized";
857
858 // Compact padding class
859 const compactClass = this.compactPadding ? "compact-padding" : "";
banksean54777362025-06-19 16:38:30 +0000860
Sean McCullough86b56862025-04-18 13:04:03 -0700861 return html`
bankseane59a2e12025-06-28 01:38:19 +0000862 <div class="relative h-full">
863 <div
864 id="scroll-container"
865 class="overflow-y-auto overflow-x-hidden pl-4 max-w-full w-full h-full ${compactClass} scroll-container print:h-auto print:max-h-none print:overflow-visible"
866 >
867 <div
868 class="w-full relative max-w-full mx-auto px-[15px] box-border overflow-x-hidden flex-1 min-h-[100px] ${timelineStateClass} print:h-auto print:max-h-none print:overflow-visible print:break-inside-avoid"
869 data-testid="timeline-container"
870 >
banksean54777362025-06-19 16:38:30 +0000871 ${!this.isInitialLoadComplete
872 ? html`
bankseane59a2e12025-06-28 01:38:19 +0000873 <div
874 class="flex items-center justify-center p-5 text-gray-600 text-sm gap-2.5 opacity-100 print:hidden"
875 data-testid="loading-indicator"
876 >
877 <div
878 class="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full loading-spinner"
879 data-testid="loading-spinner"
880 ></div>
banksean54777362025-06-19 16:38:30 +0000881 <span>Loading conversation...</span>
882 </div>
883 `
884 : ""}
Sean McCulloughe68613d2025-06-18 14:48:53 +0000885 ${this.isLoadingOlderMessages
886 ? html`
bankseane59a2e12025-06-28 01:38:19 +0000887 <div
888 class="flex items-center justify-center p-5 text-gray-600 text-sm gap-2.5 opacity-100 print:hidden"
889 data-testid="loading-indicator"
890 >
891 <div
892 class="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full loading-spinner"
893 data-testid="loading-spinner"
894 ></div>
Sean McCulloughe68613d2025-06-18 14:48:53 +0000895 <span>Loading older messages...</span>
896 </div>
897 `
898 : ""}
banksean54777362025-06-19 16:38:30 +0000899 ${this.isInitialLoadComplete
900 ? repeat(
901 this.visibleMessages,
902 this.messageKey,
philip.zeyliger26bc6592025-06-30 20:15:30 -0700903 (message, _index) => {
banksean54777362025-06-19 16:38:30 +0000904 // Find the previous message in the full filtered messages array
905 const filteredMessages = this.filteredMessages;
906 const messageIndex = filteredMessages.findIndex(
907 (m) => m === message,
908 );
philip.zeyliger26bc6592025-06-30 20:15:30 -0700909 const previousMessage =
banksean54777362025-06-19 16:38:30 +0000910 messageIndex > 0
911 ? filteredMessages[messageIndex - 1]
912 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000913
banksean54777362025-06-19 16:38:30 +0000914 return html`<sketch-timeline-message
915 .message=${message}
916 .previousMessage=${previousMessage}
917 .open=${false}
918 .firstMessageIndex=${this.firstMessageIndex}
919 .state=${this.state}
David Crawshaw4b644682025-06-26 17:15:10 +0000920 .compactPadding=${this.compactPadding}
banksean54777362025-06-19 16:38:30 +0000921 ></sketch-timeline-message>`;
922 },
923 )
924 : ""}
925 ${isThinking && this.isInitialLoadComplete
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700926 ? html`
bankseane59a2e12025-06-28 01:38:19 +0000927 <div
928 class="pl-[85px] mt-1.5 mb-4 flex"
929 data-testid="thinking-indicator"
930 style="display: flex; padding-left: 85px; margin-top: 6px; margin-bottom: 16px;"
931 >
932 <div
933 class="bg-gray-100 rounded-2xl px-4 py-2.5 max-w-20 text-black relative rounded-bl-[5px]"
934 data-testid="thinking-bubble"
935 >
936 <div
937 class="flex items-center justify-center gap-1 h-3.5"
938 data-testid="thinking-dots"
939 >
940 <div
941 class="w-1.5 h-1.5 bg-gray-500 rounded-full opacity-60 thinking-dot-1"
942 data-testid="thinking-dot"
943 ></div>
944 <div
945 class="w-1.5 h-1.5 bg-gray-500 rounded-full opacity-60 thinking-dot-2"
946 data-testid="thinking-dot"
947 ></div>
948 <div
949 class="w-1.5 h-1.5 bg-gray-500 rounded-full opacity-60 thinking-dot-3"
950 data-testid="thinking-dot"
951 ></div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700952 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000953 </div>
954 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700955 `
956 : ""}
957 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -0700958 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700959 <div
960 id="jump-to-latest"
bankseane59a2e12025-06-28 01:38:19 +0000961 class="${this.scrollingState === "floating"
962 ? "block floating"
963 : "hidden"} fixed bottom-20 left-1/2 -translate-x-1/2 bg-black/60 text-white border-none rounded-xl px-2 py-1 text-xs font-normal cursor-pointer shadow-md z-[1000] transition-all duration-150 ease-out whitespace-nowrap opacity-80 hover:bg-black/80 hover:-translate-y-0.5 hover:opacity-100 hover:shadow-lg active:translate-y-0 print:hidden"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000964 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700965 >
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700966 ↓ Jump to bottom
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700967 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -0700968 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700969 `;
970 }
971}
972
973declare global {
974 interface HTMLElementTagNameMap {
975 "sketch-timeline": SketchTimeline;
976 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700977}