blob: 1f1182ea3ab932a753909517668e17ee910b8208 [file] [log] [blame]
Sean McCullough71941bd2025-04-18 13:31:48 -07001import { css, html, LitElement } from "lit";
Sean McCullough2c5bba42025-04-20 19:33:17 -07002import { PropertyValues } from "lit";
Sean McCullough71941bd2025-04-18 13:31:48 -07003import { repeat } from "lit/directives/repeat.js";
Sean McCullough2c5bba42025-04-20 19:33:17 -07004import { customElement, property, state } from "lit/decorators.js";
philip.zeyliger6d3de482025-06-10 19:38:14 -07005import { AgentMessage, State } from "../types";
Sean McCullough71941bd2025-04-18 13:31:48 -07006import "./sketch-timeline-message";
Pokey Rule4097e532025-04-24 18:55:28 +01007import { Ref } from "lit/directives/ref";
Sean McCullough86b56862025-04-18 13:04:03 -07008
Sean McCullough71941bd2025-04-18 13:31:48 -07009@customElement("sketch-timeline")
Sean McCullough86b56862025-04-18 13:04:03 -070010export class SketchTimeline extends LitElement {
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010011 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -070012 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -070013
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000014 // Active state properties to show thinking indicator
15 @property({ attribute: false })
16 agentState: string | null = null;
17
18 @property({ attribute: false })
19 llmCalls: number = 0;
20
21 @property({ attribute: false })
22 toolCalls: string[] = [];
23
Sean McCullough2c5bba42025-04-20 19:33:17 -070024 // Track if we should scroll to the bottom
25 @state()
26 private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
27
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010028 @property({ attribute: false })
Pokey Rule4097e532025-04-24 18:55:28 +010029 scrollContainer: Ref<HTMLElement>;
Sean McCullough2c5bba42025-04-20 19:33:17 -070030
Sean McCulloughe68613d2025-06-18 14:48:53 +000031 // Keep track of current scroll container for cleanup
32 private currentScrollContainer: HTMLElement | null = null;
33
34 // Event-driven scroll handling without setTimeout
35 private scrollDebounceFrame: number | null = null;
36
37 // Loading operation management with proper cancellation
38 private loadingAbortController: AbortController | null = null;
39 private pendingScrollRestoration: (() => void) | null = null;
40
41 // Track current loading operation for cancellation
42 private currentLoadingOperation: Promise<void> | null = null;
43
44 // Observers for event-driven DOM updates
45 private resizeObserver: ResizeObserver | null = null;
46 private mutationObserver: MutationObserver | null = null;
47
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070048 @property({ attribute: false })
49 firstMessageIndex: number = 0;
50
philip.zeyliger6d3de482025-06-10 19:38:14 -070051 @property({ attribute: false })
52 state: State | null = null;
53
David Crawshaw4b644682025-06-26 17:15:10 +000054 @property({ attribute: false })
55 compactPadding: boolean = false;
56
banksean54777362025-06-19 16:38:30 +000057 // Track initial load completion for better rendering control
58 @state()
59 private isInitialLoadComplete: boolean = false;
60
61 @property({ attribute: false })
62 dataManager: any = null; // Reference to DataManager for event listening
63
Sean McCulloughe68613d2025-06-18 14:48:53 +000064 // Viewport rendering properties
65 @property({ attribute: false })
66 initialMessageCount: number = 30;
67
68 @property({ attribute: false })
69 loadChunkSize: number = 20;
70
71 @state()
72 private visibleMessageStartIndex: number = 0;
73
74 @state()
75 private isLoadingOlderMessages: boolean = false;
76
77 // Threshold for triggering load more (pixels from top)
78 private loadMoreThreshold: number = 100;
79
80 // Timeout ID for loading operations
81 private loadingTimeoutId: number | null = null;
82
Sean McCullough86b56862025-04-18 13:04:03 -070083 static styles = css`
banksean54777362025-06-19 16:38:30 +000084 /* Hide message content initially to prevent flash of incomplete content */
85 .timeline-container:not(.view-initialized) sketch-timeline-message {
86 opacity: 0;
87 transition: opacity 0.2s ease-in;
Sean McCullough71941bd2025-04-18 13:31:48 -070088 }
Sean McCullough86b56862025-04-18 13:04:03 -070089
banksean54777362025-06-19 16:38:30 +000090 /* Show content once initial load is complete */
91 .timeline-container.view-initialized sketch-timeline-message {
92 opacity: 1;
93 }
94
95 /* Always show loading indicators */
96 .timeline-container .loading-indicator {
97 opacity: 1;
Sean McCullough71941bd2025-04-18 13:31:48 -070098 }
99
100 .timeline-container {
101 width: 100%;
102 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000103 max-width: 100%;
104 margin: 0 auto;
105 padding: 0 15px;
106 box-sizing: border-box;
Philip Zeyligere31d2a92025-05-11 15:22:35 -0700107 overflow-x: hidden;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700108 flex: 1;
banksean54777362025-06-19 16:38:30 +0000109 min-height: 100px; /* Ensure container has height for loading indicator */
Sean McCullough71941bd2025-04-18 13:31:48 -0700110 }
111
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000112 /* Chat-like timeline styles */
Sean McCullough71941bd2025-04-18 13:31:48 -0700113 .timeline {
114 position: relative;
115 margin: 10px 0;
116 scroll-behavior: smooth;
117 }
118
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000119 /* Remove the vertical timeline line */
Sean McCullough2c5bba42025-04-20 19:33:17 -0700120
121 #scroll-container {
Philip Zeyligere31d2a92025-05-11 15:22:35 -0700122 overflow-y: auto;
123 overflow-x: hidden;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700124 padding-left: 1em;
Philip Zeyligere31d2a92025-05-11 15:22:35 -0700125 max-width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700126 width: 100%;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000127 height: 100%;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700128 }
David Crawshaw4b644682025-06-26 17:15:10 +0000129
130 :host([compactpadding]) #scroll-container {
131 padding-left: 0;
132 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700133 #jump-to-latest {
134 display: none;
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700135 position: fixed;
136 bottom: 80px; /* Position right on the boundary */
137 left: 50%;
138 transform: translateX(-50%);
139 background: rgba(0, 0, 0, 0.6);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700140 color: white;
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700141 border: none;
142 border-radius: 12px;
143 padding: 4px 8px;
144 font-size: 11px;
145 font-weight: 400;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700146 cursor: pointer;
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700147 box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
148 z-index: 1000;
149 transition: all 0.15s ease;
150 white-space: nowrap;
151 opacity: 0.8;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700152 }
153 #jump-to-latest:hover {
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700154 background-color: rgba(0, 0, 0, 0.8);
155 transform: translateX(-50%) translateY(-1px);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700156 opacity: 1;
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700157 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
158 }
159 #jump-to-latest:active {
160 transform: translateX(-50%) translateY(0);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700161 }
162 #jump-to-latest.floating {
163 display: block;
164 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000165
166 /* Welcome box styles for the empty chat state */
167 .welcome-box {
168 margin: 2rem auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700169 max-width: 90%;
170 width: 90%;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000171 padding: 2rem;
172 border: 2px solid #e0e0e0;
173 border-radius: 8px;
174 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
175 background-color: #ffffff;
176 text-align: center;
177 }
178
179 .welcome-box-title {
180 font-size: 1.5rem;
181 font-weight: 600;
182 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700183 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000184 color: #333;
185 }
186
187 .welcome-box-content {
188 color: #666; /* Slightly grey font color */
189 line-height: 1.6;
190 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700191 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000192 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000193
194 /* Thinking indicator styles */
195 .thinking-indicator {
196 padding-left: 85px;
197 margin-top: 5px;
198 margin-bottom: 15px;
199 display: flex;
200 }
201
202 .thinking-bubble {
203 background-color: #f1f1f1;
204 border-radius: 15px;
205 padding: 10px 15px;
206 max-width: 80px;
207 color: black;
208 position: relative;
209 border-bottom-left-radius: 5px;
210 }
211
212 .thinking-dots {
213 display: flex;
214 align-items: center;
215 justify-content: center;
216 gap: 4px;
217 height: 14px;
218 }
219
220 .dot {
221 width: 6px;
222 height: 6px;
223 background-color: #888;
224 border-radius: 50%;
225 opacity: 0.6;
226 }
227
228 .dot:nth-child(1) {
229 animation: pulse 1.5s infinite ease-in-out;
230 }
231
232 .dot:nth-child(2) {
233 animation: pulse 1.5s infinite ease-in-out 0.3s;
234 }
235
236 .dot:nth-child(3) {
237 animation: pulse 1.5s infinite ease-in-out 0.6s;
238 }
239
240 @keyframes pulse {
241 0%,
242 100% {
243 opacity: 0.4;
244 transform: scale(1);
245 }
246 50% {
247 opacity: 1;
248 transform: scale(1.2);
249 }
250 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000251
252 /* Loading indicator styles */
253 .loading-indicator {
254 display: flex;
255 align-items: center;
256 justify-content: center;
257 padding: 20px;
258 color: #666;
259 font-size: 14px;
260 gap: 10px;
261 }
262
263 .loading-spinner {
264 width: 20px;
265 height: 20px;
266 border: 2px solid #e0e0e0;
267 border-top: 2px solid #666;
268 border-radius: 50%;
269 animation: spin 1s linear infinite;
270 }
271
272 @keyframes spin {
273 0% {
274 transform: rotate(0deg);
275 }
276 100% {
277 transform: rotate(360deg);
278 }
279 }
Sean McCullough86b56862025-04-18 13:04:03 -0700280
philip.zeyligerffa94c62025-06-19 18:43:37 -0700281 /* Print styles for full timeline printing */
282 @media print {
283 .timeline-container {
284 height: auto !important;
285 max-height: none !important;
286 overflow: visible !important;
287 page-break-inside: avoid;
288 }
289
290 .timeline {
291 height: auto !important;
292 max-height: none !important;
293 overflow: visible !important;
294 }
295
296 #scroll-container {
297 height: auto !important;
298 max-height: none !important;
299 overflow: visible !important;
300 overflow-y: visible !important;
301 overflow-x: visible !important;
302 }
303
304 /* Hide the jump to latest button during printing */
305 #jump-to-latest {
306 display: none !important;
307 }
308
309 /* Hide the thinking indicator during printing */
310 .thinking-indicator {
311 display: none !important;
312 }
313
314 /* Hide the loading indicator during printing */
315 .loading-indicator {
316 display: none !important;
317 }
318
319 /* Ensure welcome box prints properly if visible */
320 .welcome-box {
321 page-break-inside: avoid;
322 }
323 }
324 `;
Sean McCullough86b56862025-04-18 13:04:03 -0700325 constructor() {
326 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700327
Sean McCullough86b56862025-04-18 13:04:03 -0700328 // Binding methods
329 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700330 this._handleScroll = this._handleScroll.bind(this);
331 }
332
333 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000334 * Safely add scroll event listener with proper cleanup tracking
335 */
336 private addScrollListener(container: HTMLElement): void {
337 // Remove any existing listener first
338 this.removeScrollListener();
339
340 // Add new listener and track the container
341 container.addEventListener("scroll", this._handleScroll);
342 this.currentScrollContainer = container;
343 }
344
345 /**
346 * Safely remove scroll event listener
347 */
348 private removeScrollListener(): void {
349 if (this.currentScrollContainer) {
350 this.currentScrollContainer.removeEventListener(
351 "scroll",
352 this._handleScroll,
353 );
354 this.currentScrollContainer = null;
355 }
356
357 // Clear any pending timeouts and operations
358 this.clearAllPendingOperations();
359 }
360
361 /**
362 * Clear all pending operations and observers to prevent race conditions
363 */
364 private clearAllPendingOperations(): void {
365 // Clear scroll debounce frame
366 if (this.scrollDebounceFrame) {
367 cancelAnimationFrame(this.scrollDebounceFrame);
368 this.scrollDebounceFrame = null;
369 }
370
371 // Abort loading operations
372 if (this.loadingAbortController) {
373 this.loadingAbortController.abort();
374 this.loadingAbortController = null;
375 }
376
377 // Cancel pending scroll restoration
378 if (this.pendingScrollRestoration) {
379 this.pendingScrollRestoration = null;
380 }
381
382 // Clean up observers
383 this.disconnectObservers();
384 }
385
386 /**
387 * Disconnect all observers
388 */
389 private disconnectObservers(): void {
390 if (this.resizeObserver) {
391 this.resizeObserver.disconnect();
392 this.resizeObserver = null;
393 }
394
395 if (this.mutationObserver) {
396 this.mutationObserver.disconnect();
397 this.mutationObserver = null;
398 }
399 }
400
401 /**
402 * Force a viewport reset to show the most recent messages
403 * Useful when loading a new session or when messages change significantly
404 */
405 public resetViewport(): void {
406 // Cancel any pending loading operations to prevent race conditions
407 this.cancelCurrentLoadingOperation();
408
409 // Reset viewport state
410 this.visibleMessageStartIndex = 0;
411 this.isLoadingOlderMessages = false;
412
413 // Clear all pending operations
414 this.clearAllPendingOperations();
415
416 this.requestUpdate();
417 }
418
419 /**
420 * Cancel current loading operation if in progress
421 */
422 private cancelCurrentLoadingOperation(): void {
423 if (this.isLoadingOlderMessages) {
424 this.isLoadingOlderMessages = false;
425
426 // Abort the loading operation
427 if (this.loadingAbortController) {
428 this.loadingAbortController.abort();
429 this.loadingAbortController = null;
430 }
431
432 // Cancel pending scroll restoration
433 this.pendingScrollRestoration = null;
434 }
435 }
436
437 /**
438 * Get the filtered messages (excluding hidden ones)
439 */
440 private get filteredMessages(): AgentMessage[] {
441 return this.messages.filter((msg) => !msg.hide_output);
442 }
443
444 /**
445 * Get the currently visible messages based on viewport rendering
446 * Race-condition safe implementation
447 */
448 private get visibleMessages(): AgentMessage[] {
449 const filtered = this.filteredMessages;
450 if (filtered.length === 0) return [];
451
452 // Always show the most recent messages first
453 // visibleMessageStartIndex represents how many additional older messages to show
454 const totalVisible =
455 this.initialMessageCount + this.visibleMessageStartIndex;
456 const startIndex = Math.max(0, filtered.length - totalVisible);
457
458 // Ensure we don't return an invalid slice during loading operations
459 const endIndex = filtered.length;
460 if (startIndex >= endIndex) {
461 return [];
462 }
463
464 return filtered.slice(startIndex, endIndex);
465 }
466
467 /**
468 * Check if the component is in a stable state for loading operations
469 */
470 private isStableForLoading(): boolean {
471 return (
472 this.scrollContainer.value !== null &&
473 this.scrollContainer.value === this.currentScrollContainer &&
474 this.scrollContainer.value.isConnected &&
475 !this.isLoadingOlderMessages &&
476 !this.currentLoadingOperation
477 );
478 }
479
480 /**
481 * Load more older messages by expanding the visible window
482 * Race-condition safe implementation
483 */
484 private async loadOlderMessages(): Promise<void> {
485 // Prevent concurrent loading operations
486 if (this.isLoadingOlderMessages || this.currentLoadingOperation) {
487 return;
488 }
489
490 const filtered = this.filteredMessages;
491 const currentVisibleCount = this.visibleMessages.length;
492 const totalAvailable = filtered.length;
493
494 // Check if there are more messages to load
495 if (currentVisibleCount >= totalAvailable) {
496 return;
497 }
498
499 // Start loading operation with proper state management
500 this.isLoadingOlderMessages = true;
501
502 // Store current scroll position for restoration
503 const container = this.scrollContainer.value;
504 const previousScrollHeight = container?.scrollHeight || 0;
505 const previousScrollTop = container?.scrollTop || 0;
506
507 // Validate scroll container hasn't changed during setup
508 if (!container || container !== this.currentScrollContainer) {
509 this.isLoadingOlderMessages = false;
510 return;
511 }
512
513 // Expand the visible window with bounds checking
514 const additionalMessages = Math.min(
515 this.loadChunkSize,
516 totalAvailable - currentVisibleCount,
517 );
518 const newStartIndex = this.visibleMessageStartIndex + additionalMessages;
519
520 // Ensure we don't exceed available messages
521 const boundedStartIndex = Math.min(
522 newStartIndex,
523 totalAvailable - this.initialMessageCount,
524 );
525 this.visibleMessageStartIndex = Math.max(0, boundedStartIndex);
526
527 // Create the loading operation with proper error handling and cleanup
528 const loadingOperation = this.executeScrollPositionRestoration(
529 container,
530 previousScrollHeight,
531 previousScrollTop,
532 );
533
534 this.currentLoadingOperation = loadingOperation;
535
536 try {
537 await loadingOperation;
538 } catch (error) {
539 console.warn("Loading operation failed:", error);
540 } finally {
541 // Ensure loading state is always cleared
542 this.isLoadingOlderMessages = false;
543 this.currentLoadingOperation = null;
544
545 // Clear the loading timeout if it exists
546 if (this.loadingTimeoutId) {
547 clearTimeout(this.loadingTimeoutId);
548 this.loadingTimeoutId = null;
549 }
550 }
551 }
552
553 /**
554 * Execute scroll position restoration with event-driven approach
555 */
556 private async executeScrollPositionRestoration(
557 container: HTMLElement,
558 previousScrollHeight: number,
559 previousScrollTop: number,
560 ): Promise<void> {
561 // Set up AbortController for proper cancellation
562 this.loadingAbortController = new AbortController();
563 const { signal } = this.loadingAbortController;
564
565 // Create scroll restoration function
566 const restoreScrollPosition = () => {
567 // Check if operation was aborted
568 if (signal.aborted) {
569 return;
570 }
571
572 // Double-check container is still valid and connected
573 if (
574 !container ||
575 !container.isConnected ||
576 container !== this.currentScrollContainer
577 ) {
578 return;
579 }
580
581 try {
582 const newScrollHeight = container.scrollHeight;
583 const scrollDifference = newScrollHeight - previousScrollHeight;
584 const newScrollTop = previousScrollTop + scrollDifference;
585
586 // Validate all scroll calculations before applying
587 const isValidRestoration =
588 scrollDifference > 0 && // Content was added
589 newScrollTop >= 0 && // New position is valid
590 newScrollTop <= newScrollHeight && // Don't exceed max scroll
591 previousScrollHeight > 0 && // Had valid previous height
592 newScrollHeight > previousScrollHeight; // Height actually increased
593
594 if (isValidRestoration) {
595 container.scrollTop = newScrollTop;
596 } else {
597 // Log invalid restoration attempts for debugging
598 console.debug("Skipped scroll restoration:", {
599 scrollDifference,
600 newScrollTop,
601 newScrollHeight,
602 previousScrollHeight,
603 previousScrollTop,
604 });
605 }
606 } catch (error) {
607 console.warn("Scroll position restoration failed:", error);
608 }
609 };
610
611 // Store the restoration function for potential cancellation
612 this.pendingScrollRestoration = restoreScrollPosition;
613
614 // Wait for DOM update and then restore scroll position
615 await this.updateComplete;
616
617 // Check if operation was cancelled during await
618 if (
619 !signal.aborted &&
620 this.pendingScrollRestoration === restoreScrollPosition
621 ) {
622 // Use ResizeObserver to detect when content is actually ready
623 await this.waitForContentReady(container, signal);
624
625 if (!signal.aborted) {
626 restoreScrollPosition();
627 this.pendingScrollRestoration = null;
628 }
629 }
630 }
631
632 /**
633 * Wait for content to be ready using ResizeObserver instead of setTimeout
634 */
635 private async waitForContentReady(
636 container: HTMLElement,
637 signal: AbortSignal,
638 ): Promise<void> {
639 return new Promise((resolve, reject) => {
640 if (signal.aborted) {
641 reject(new Error("Operation aborted"));
642 return;
643 }
644
645 // Resolve immediately if container already has content
646 if (container.scrollHeight > 0) {
647 resolve();
648 return;
649 }
650
651 // Set up ResizeObserver to detect content changes
652 const observer = new ResizeObserver((entries) => {
653 if (signal.aborted) {
654 observer.disconnect();
655 reject(new Error("Operation aborted"));
656 return;
657 }
658
659 // Content is ready when height increases
660 const entry = entries[0];
661 if (entry && entry.contentRect.height > 0) {
662 observer.disconnect();
663 resolve();
664 }
665 });
666
667 // Start observing
668 observer.observe(container);
669
670 // Clean up on abort
671 signal.addEventListener("abort", () => {
672 observer.disconnect();
673 reject(new Error("Operation aborted"));
674 });
675 });
676 }
677
678 /**
Sean McCullough2c5bba42025-04-20 19:33:17 -0700679 * Scroll to the bottom of the timeline
680 */
681 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000682 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000683
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000684 // Use instant scroll to ensure we reach the exact bottom
685 this.scrollContainer.value.scrollTo({
686 top: this.scrollContainer.value.scrollHeight,
687 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700688 });
689 }
Autoformatter71c73b52025-05-29 20:18:43 +0000690
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000691 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000692 * Scroll to bottom with event-driven approach using MutationObserver
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000693 */
Sean McCulloughe68613d2025-06-18 14:48:53 +0000694 private async scrollToBottomWithRetry(): Promise<void> {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000695 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000696
Sean McCulloughe68613d2025-06-18 14:48:53 +0000697 const container = this.scrollContainer.value;
Autoformatter71c73b52025-05-29 20:18:43 +0000698
Sean McCulloughe68613d2025-06-18 14:48:53 +0000699 // Try immediate scroll first
700 this.scrollToBottom();
Autoformatter71c73b52025-05-29 20:18:43 +0000701
Sean McCulloughe68613d2025-06-18 14:48:53 +0000702 // Check if we're at the bottom
703 const isAtBottom = () => {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000704 const targetScrollTop = container.scrollHeight - container.clientHeight;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000705 const actualScrollTop = container.scrollTop;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000706 return Math.abs(targetScrollTop - actualScrollTop) <= 1;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000707 };
Autoformatter71c73b52025-05-29 20:18:43 +0000708
Sean McCulloughe68613d2025-06-18 14:48:53 +0000709 // If already at bottom, we're done
710 if (isAtBottom()) {
711 return;
712 }
713
714 // Use MutationObserver to detect content changes and retry
715 return new Promise((resolve) => {
716 let scrollAttempted = false;
717
718 const observer = new MutationObserver(() => {
719 if (!scrollAttempted) {
720 scrollAttempted = true;
721
722 // Use requestAnimationFrame to ensure DOM is painted
723 requestAnimationFrame(() => {
724 this.scrollToBottom();
725
726 // Check if successful
727 if (isAtBottom()) {
728 observer.disconnect();
729 resolve();
730 } else {
731 // Try one more time after another frame
732 requestAnimationFrame(() => {
733 this.scrollToBottom();
734 observer.disconnect();
735 resolve();
736 });
737 }
738 });
739 }
740 });
741
742 // Observe changes to the timeline container
743 observer.observe(container, {
744 childList: true,
745 subtree: true,
746 attributes: false,
747 });
748
749 // Clean up after a reasonable time if no changes detected
750 requestAnimationFrame(() => {
751 requestAnimationFrame(() => {
752 if (!scrollAttempted) {
753 observer.disconnect();
754 resolve();
755 }
756 });
757 });
758 });
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000759 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700760
761 /**
762 * Called after the component's properties have been updated
763 */
764 updated(changedProperties: PropertyValues): void {
banksean54777362025-06-19 16:38:30 +0000765 // Handle DataManager changes to set up event listeners
766 if (changedProperties.has("dataManager")) {
767 const oldDataManager = changedProperties.get("dataManager");
768
769 // Remove old event listener if it exists
770 if (oldDataManager) {
771 oldDataManager.removeEventListener(
772 "initialLoadComplete",
773 this.handleInitialLoadComplete,
774 );
775 }
776
777 // Add new event listener if dataManager is available
778 if (this.dataManager) {
779 this.dataManager.addEventListener(
780 "initialLoadComplete",
781 this.handleInitialLoadComplete,
782 );
783
784 // Check if initial load is already complete
785 if (
786 this.dataManager.getIsInitialLoadComplete &&
787 this.dataManager.getIsInitialLoadComplete()
788 ) {
789 this.isInitialLoadComplete = true;
790 }
791 }
792 }
793
Sean McCulloughe68613d2025-06-18 14:48:53 +0000794 // Handle scroll container changes first to prevent race conditions
795 if (changedProperties.has("scrollContainer")) {
796 // Cancel any ongoing loading operations since container is changing
797 this.cancelCurrentLoadingOperation();
798
799 if (this.scrollContainer.value) {
800 this.addScrollListener(this.scrollContainer.value);
801 } else {
802 this.removeScrollListener();
Sean McCullough2c5bba42025-04-20 19:33:17 -0700803 }
804 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000805
806 // If messages have changed, handle viewport updates
807 if (changedProperties.has("messages")) {
808 const oldMessages =
809 (changedProperties.get("messages") as AgentMessage[]) || [];
810 const newMessages = this.messages || [];
811
812 // Cancel loading operations if messages changed significantly
813 const significantChange =
814 oldMessages.length === 0 ||
815 newMessages.length < oldMessages.length ||
816 Math.abs(newMessages.length - oldMessages.length) > 20;
817
818 if (significantChange) {
819 // Cancel any ongoing operations and reset viewport
820 this.cancelCurrentLoadingOperation();
821 this.visibleMessageStartIndex = 0;
822 }
823
824 // Scroll to bottom if needed (only if not loading to prevent race conditions)
825 if (
826 this.messages.length > 0 &&
827 this.scrollingState === "pinToLatest" &&
828 !this.isLoadingOlderMessages
829 ) {
830 // Use async scroll without setTimeout
831 this.scrollToBottomWithRetry().catch((error) => {
832 console.warn("Scroll to bottom failed:", error);
833 });
834 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700835 }
Sean McCullough86b56862025-04-18 13:04:03 -0700836 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700837
Sean McCullough86b56862025-04-18 13:04:03 -0700838 /**
839 * Handle showCommitDiff event
840 */
841 private _handleShowCommitDiff(event: CustomEvent) {
842 const { commitHash } = event.detail;
843 if (commitHash) {
844 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700845 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700846 detail: { commitHash },
847 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700848 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700849 });
850 this.dispatchEvent(newEvent);
851 }
852 }
853
Sean McCullough2c5bba42025-04-20 19:33:17 -0700854 private _handleScroll(event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000855 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000856
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000857 const container = this.scrollContainer.value;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000858
859 // Verify this is still our tracked container to prevent race conditions
860 if (container !== this.currentScrollContainer) {
861 return;
862 }
863
Sean McCullough2c5bba42025-04-20 19:33:17 -0700864 const isAtBottom =
865 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000866 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000867 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000868
Sean McCulloughe68613d2025-06-18 14:48:53 +0000869 const isNearTop = container.scrollTop <= this.loadMoreThreshold;
870
871 // Update scroll state immediately for responsive UI
Sean McCullough2c5bba42025-04-20 19:33:17 -0700872 if (isAtBottom) {
873 this.scrollingState = "pinToLatest";
874 } else {
Sean McCullough2c5bba42025-04-20 19:33:17 -0700875 this.scrollingState = "floating";
876 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000877
878 // Use requestAnimationFrame for smooth debouncing instead of setTimeout
879 if (this.scrollDebounceFrame) {
880 cancelAnimationFrame(this.scrollDebounceFrame);
881 }
882
883 this.scrollDebounceFrame = requestAnimationFrame(() => {
884 // Use stability check to ensure safe loading conditions
885 if (isNearTop && this.isStableForLoading()) {
886 this.loadOlderMessages().catch((error) => {
887 console.warn("Async loadOlderMessages failed:", error);
888 });
889 }
890 this.scrollDebounceFrame = null;
891 });
Sean McCullough2c5bba42025-04-20 19:33:17 -0700892 }
893
Sean McCullough86b56862025-04-18 13:04:03 -0700894 // See https://lit.dev/docs/components/lifecycle/
895 connectedCallback() {
896 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700897
Sean McCullough86b56862025-04-18 13:04:03 -0700898 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700899 document.addEventListener(
900 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700901 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700902 );
Pokey Rule4097e532025-04-24 18:55:28 +0100903
Sean McCulloughe68613d2025-06-18 14:48:53 +0000904 // Set up scroll listener if container is available
905 if (this.scrollContainer.value) {
906 this.addScrollListener(this.scrollContainer.value);
907 }
908
909 // Initialize observers for event-driven behavior
910 this.setupObservers();
Sean McCullough86b56862025-04-18 13:04:03 -0700911 }
912
Sean McCulloughe68613d2025-06-18 14:48:53 +0000913 /**
banksean54777362025-06-19 16:38:30 +0000914 * Handle initial load completion from DataManager
915 */
916 private handleInitialLoadComplete = (eventData: {
917 messageCount: number;
918 expectedCount: number;
919 }): void => {
920 console.log(
921 `Timeline: Initial load complete - ${eventData.messageCount}/${eventData.expectedCount} messages`,
922 );
923 this.isInitialLoadComplete = true;
924 this.requestUpdate();
925 };
926
927 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000928 * Set up observers for event-driven DOM monitoring
929 */
930 private setupObservers(): void {
931 // ResizeObserver will be created on-demand in loading operations
932 // MutationObserver will be created on-demand in scroll operations
933 // This avoids creating observers that may not be needed
934 }
935
936 // See https://lit.dev/docs/component/lifecycle/
Sean McCullough86b56862025-04-18 13:04:03 -0700937 disconnectedCallback() {
938 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700939
Sean McCulloughe68613d2025-06-18 14:48:53 +0000940 // Cancel any ongoing loading operations before cleanup
941 this.cancelCurrentLoadingOperation();
942
943 // Remove event listeners with guaranteed cleanup
Sean McCullough71941bd2025-04-18 13:31:48 -0700944 document.removeEventListener(
945 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700946 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700947 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700948
banksean54777362025-06-19 16:38:30 +0000949 // Remove DataManager event listener if connected
950 if (this.dataManager) {
951 this.dataManager.removeEventListener(
952 "initialLoadComplete",
953 this.handleInitialLoadComplete,
954 );
955 }
956
Sean McCulloughe68613d2025-06-18 14:48:53 +0000957 // Use our safe cleanup method
958 this.removeScrollListener();
Sean McCullough86b56862025-04-18 13:04:03 -0700959 }
960
Sean McCulloughd9f13372025-04-21 15:08:49 -0700961 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700962 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700963 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700964 // If the message has tool calls, and any of the tool_calls get a response, we need to
965 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700966 const toolCallResponses = message.tool_calls
967 ?.filter((tc) => tc.result_message)
968 .map((tc) => tc.tool_call_id)
969 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700970 return `message-${message.idx}-${toolCallResponses}`;
971 }
972
973 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000974 // Check if messages array is empty and render welcome box if it is
975 if (this.messages.length === 0) {
976 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700977 <div style="position: relative; height: 100%;">
978 <div id="scroll-container">
979 <div class="welcome-box">
980 <h2 class="welcome-box-title">How to use Sketch</h2>
981 <p class="welcome-box-content">
982 Sketch is an agentic coding assistant.
983 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700984
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700985 <p class="welcome-box-content">
986 Sketch has created a container with your repo.
987 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700988
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700989 <p class="welcome-box-content">
990 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000991 below. It can edit and run your code, all in the container.
992 Sketch will create commits in a newly created git branch, which
993 you can look at and comment on in the Diff tab. Once you're
994 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700995 </p>
996 <p class="welcome-box-content">
997 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000998 Sketch in parallel to work on multiple ideas or even the same
999 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001000 </p>
1001 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +00001002 </div>
1003 </div>
1004 `;
1005 }
1006
1007 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001008 const isThinking =
1009 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
1010
banksean54777362025-06-19 16:38:30 +00001011 // Apply view-initialized class when initial load is complete
1012 const containerClass = this.isInitialLoadComplete
1013 ? "timeline-container view-initialized"
1014 : "timeline-container";
1015
Sean McCullough86b56862025-04-18 13:04:03 -07001016 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001017 <div style="position: relative; height: 100%;">
1018 <div id="scroll-container">
banksean54777362025-06-19 16:38:30 +00001019 <div class="${containerClass}">
1020 ${!this.isInitialLoadComplete
1021 ? html`
1022 <div class="loading-indicator">
1023 <div class="loading-spinner"></div>
1024 <span>Loading conversation...</span>
1025 </div>
1026 `
1027 : ""}
Sean McCulloughe68613d2025-06-18 14:48:53 +00001028 ${this.isLoadingOlderMessages
1029 ? html`
1030 <div class="loading-indicator">
1031 <div class="loading-spinner"></div>
1032 <span>Loading older messages...</span>
1033 </div>
1034 `
1035 : ""}
banksean54777362025-06-19 16:38:30 +00001036 ${this.isInitialLoadComplete
1037 ? repeat(
1038 this.visibleMessages,
1039 this.messageKey,
1040 (message, index) => {
1041 // Find the previous message in the full filtered messages array
1042 const filteredMessages = this.filteredMessages;
1043 const messageIndex = filteredMessages.findIndex(
1044 (m) => m === message,
1045 );
1046 let previousMessage =
1047 messageIndex > 0
1048 ? filteredMessages[messageIndex - 1]
1049 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +00001050
banksean54777362025-06-19 16:38:30 +00001051 return html`<sketch-timeline-message
1052 .message=${message}
1053 .previousMessage=${previousMessage}
1054 .open=${false}
1055 .firstMessageIndex=${this.firstMessageIndex}
1056 .state=${this.state}
David Crawshaw4b644682025-06-26 17:15:10 +00001057 .compactPadding=${this.compactPadding}
banksean54777362025-06-19 16:38:30 +00001058 ></sketch-timeline-message>`;
1059 },
1060 )
1061 : ""}
1062 ${isThinking && this.isInitialLoadComplete
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001063 ? html`
1064 <div class="thinking-indicator">
1065 <div class="thinking-bubble">
1066 <div class="thinking-dots">
1067 <div class="dot"></div>
1068 <div class="dot"></div>
1069 <div class="dot"></div>
1070 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001071 </div>
1072 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001073 `
1074 : ""}
1075 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -07001076 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001077 <div
1078 id="jump-to-latest"
1079 class="${this.scrollingState}"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +00001080 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001081 >
Philip Zeyliger61a0f672025-06-21 15:33:18 -07001082 ↓ Jump to bottom
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001083 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -07001084 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001085 `;
1086 }
1087}
1088
1089declare global {
1090 interface HTMLElementTagNameMap {
1091 "sketch-timeline": SketchTimeline;
1092 }
Sean McCullough71941bd2025-04-18 13:31:48 -07001093}