blob: 964574e2c390f0eae3104a48aefc6dce1452d750 [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
banksean54777362025-06-19 16:38:30 +000054 // Track initial load completion for better rendering control
55 @state()
56 private isInitialLoadComplete: boolean = false;
57
58 @property({ attribute: false })
59 dataManager: any = null; // Reference to DataManager for event listening
60
Sean McCulloughe68613d2025-06-18 14:48:53 +000061 // Viewport rendering properties
62 @property({ attribute: false })
63 initialMessageCount: number = 30;
64
65 @property({ attribute: false })
66 loadChunkSize: number = 20;
67
68 @state()
69 private visibleMessageStartIndex: number = 0;
70
71 @state()
72 private isLoadingOlderMessages: boolean = false;
73
74 // Threshold for triggering load more (pixels from top)
75 private loadMoreThreshold: number = 100;
76
77 // Timeout ID for loading operations
78 private loadingTimeoutId: number | null = null;
79
Sean McCullough86b56862025-04-18 13:04:03 -070080 static styles = css`
banksean54777362025-06-19 16:38:30 +000081 /* Hide message content initially to prevent flash of incomplete content */
82 .timeline-container:not(.view-initialized) sketch-timeline-message {
83 opacity: 0;
84 transition: opacity 0.2s ease-in;
Sean McCullough71941bd2025-04-18 13:31:48 -070085 }
Sean McCullough86b56862025-04-18 13:04:03 -070086
banksean54777362025-06-19 16:38:30 +000087 /* Show content once initial load is complete */
88 .timeline-container.view-initialized sketch-timeline-message {
89 opacity: 1;
90 }
91
92 /* Always show loading indicators */
93 .timeline-container .loading-indicator {
94 opacity: 1;
Sean McCullough71941bd2025-04-18 13:31:48 -070095 }
96
97 .timeline-container {
98 width: 100%;
99 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000100 max-width: 100%;
101 margin: 0 auto;
102 padding: 0 15px;
103 box-sizing: border-box;
Philip Zeyligere31d2a92025-05-11 15:22:35 -0700104 overflow-x: hidden;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700105 flex: 1;
banksean54777362025-06-19 16:38:30 +0000106 min-height: 100px; /* Ensure container has height for loading indicator */
Sean McCullough71941bd2025-04-18 13:31:48 -0700107 }
108
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000109 /* Chat-like timeline styles */
Sean McCullough71941bd2025-04-18 13:31:48 -0700110 .timeline {
111 position: relative;
112 margin: 10px 0;
113 scroll-behavior: smooth;
114 }
115
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000116 /* Remove the vertical timeline line */
Sean McCullough2c5bba42025-04-20 19:33:17 -0700117
118 #scroll-container {
Philip Zeyligere31d2a92025-05-11 15:22:35 -0700119 overflow-y: auto;
120 overflow-x: hidden;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700121 padding-left: 1em;
Philip Zeyligere31d2a92025-05-11 15:22:35 -0700122 max-width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700123 width: 100%;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000124 height: 100%;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700125 }
126 #jump-to-latest {
127 display: none;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700128 position: absolute;
129 bottom: 20px;
130 right: 20px;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700131 background: rgb(33, 150, 243);
132 color: white;
133 border-radius: 8px;
134 padding: 0.5em;
135 margin: 0.5em;
136 font-size: x-large;
137 opacity: 0.5;
138 cursor: pointer;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700139 z-index: 50;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700140 }
141 #jump-to-latest:hover {
142 opacity: 1;
143 }
144 #jump-to-latest.floating {
145 display: block;
146 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000147
148 /* Welcome box styles for the empty chat state */
149 .welcome-box {
150 margin: 2rem auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700151 max-width: 90%;
152 width: 90%;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000153 padding: 2rem;
154 border: 2px solid #e0e0e0;
155 border-radius: 8px;
156 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
157 background-color: #ffffff;
158 text-align: center;
159 }
160
161 .welcome-box-title {
162 font-size: 1.5rem;
163 font-weight: 600;
164 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700165 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000166 color: #333;
167 }
168
169 .welcome-box-content {
170 color: #666; /* Slightly grey font color */
171 line-height: 1.6;
172 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700173 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000174 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000175
176 /* Thinking indicator styles */
177 .thinking-indicator {
178 padding-left: 85px;
179 margin-top: 5px;
180 margin-bottom: 15px;
181 display: flex;
182 }
183
184 .thinking-bubble {
185 background-color: #f1f1f1;
186 border-radius: 15px;
187 padding: 10px 15px;
188 max-width: 80px;
189 color: black;
190 position: relative;
191 border-bottom-left-radius: 5px;
192 }
193
194 .thinking-dots {
195 display: flex;
196 align-items: center;
197 justify-content: center;
198 gap: 4px;
199 height: 14px;
200 }
201
202 .dot {
203 width: 6px;
204 height: 6px;
205 background-color: #888;
206 border-radius: 50%;
207 opacity: 0.6;
208 }
209
210 .dot:nth-child(1) {
211 animation: pulse 1.5s infinite ease-in-out;
212 }
213
214 .dot:nth-child(2) {
215 animation: pulse 1.5s infinite ease-in-out 0.3s;
216 }
217
218 .dot:nth-child(3) {
219 animation: pulse 1.5s infinite ease-in-out 0.6s;
220 }
221
222 @keyframes pulse {
223 0%,
224 100% {
225 opacity: 0.4;
226 transform: scale(1);
227 }
228 50% {
229 opacity: 1;
230 transform: scale(1.2);
231 }
232 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000233
234 /* Loading indicator styles */
235 .loading-indicator {
236 display: flex;
237 align-items: center;
238 justify-content: center;
239 padding: 20px;
240 color: #666;
241 font-size: 14px;
242 gap: 10px;
243 }
244
245 .loading-spinner {
246 width: 20px;
247 height: 20px;
248 border: 2px solid #e0e0e0;
249 border-top: 2px solid #666;
250 border-radius: 50%;
251 animation: spin 1s linear infinite;
252 }
253
254 @keyframes spin {
255 0% {
256 transform: rotate(0deg);
257 }
258 100% {
259 transform: rotate(360deg);
260 }
261 }
Sean McCullough86b56862025-04-18 13:04:03 -0700262
philip.zeyligerffa94c62025-06-19 18:43:37 -0700263 /* Print styles for full timeline printing */
264 @media print {
265 .timeline-container {
266 height: auto !important;
267 max-height: none !important;
268 overflow: visible !important;
269 page-break-inside: avoid;
270 }
271
272 .timeline {
273 height: auto !important;
274 max-height: none !important;
275 overflow: visible !important;
276 }
277
278 #scroll-container {
279 height: auto !important;
280 max-height: none !important;
281 overflow: visible !important;
282 overflow-y: visible !important;
283 overflow-x: visible !important;
284 }
285
286 /* Hide the jump to latest button during printing */
287 #jump-to-latest {
288 display: none !important;
289 }
290
291 /* Hide the thinking indicator during printing */
292 .thinking-indicator {
293 display: none !important;
294 }
295
296 /* Hide the loading indicator during printing */
297 .loading-indicator {
298 display: none !important;
299 }
300
301 /* Ensure welcome box prints properly if visible */
302 .welcome-box {
303 page-break-inside: avoid;
304 }
305 }
306 `;
Sean McCullough86b56862025-04-18 13:04:03 -0700307 constructor() {
308 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700309
Sean McCullough86b56862025-04-18 13:04:03 -0700310 // Binding methods
311 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700312 this._handleScroll = this._handleScroll.bind(this);
313 }
314
315 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000316 * Safely add scroll event listener with proper cleanup tracking
317 */
318 private addScrollListener(container: HTMLElement): void {
319 // Remove any existing listener first
320 this.removeScrollListener();
321
322 // Add new listener and track the container
323 container.addEventListener("scroll", this._handleScroll);
324 this.currentScrollContainer = container;
325 }
326
327 /**
328 * Safely remove scroll event listener
329 */
330 private removeScrollListener(): void {
331 if (this.currentScrollContainer) {
332 this.currentScrollContainer.removeEventListener(
333 "scroll",
334 this._handleScroll,
335 );
336 this.currentScrollContainer = null;
337 }
338
339 // Clear any pending timeouts and operations
340 this.clearAllPendingOperations();
341 }
342
343 /**
344 * Clear all pending operations and observers to prevent race conditions
345 */
346 private clearAllPendingOperations(): void {
347 // Clear scroll debounce frame
348 if (this.scrollDebounceFrame) {
349 cancelAnimationFrame(this.scrollDebounceFrame);
350 this.scrollDebounceFrame = null;
351 }
352
353 // Abort loading operations
354 if (this.loadingAbortController) {
355 this.loadingAbortController.abort();
356 this.loadingAbortController = null;
357 }
358
359 // Cancel pending scroll restoration
360 if (this.pendingScrollRestoration) {
361 this.pendingScrollRestoration = null;
362 }
363
364 // Clean up observers
365 this.disconnectObservers();
366 }
367
368 /**
369 * Disconnect all observers
370 */
371 private disconnectObservers(): void {
372 if (this.resizeObserver) {
373 this.resizeObserver.disconnect();
374 this.resizeObserver = null;
375 }
376
377 if (this.mutationObserver) {
378 this.mutationObserver.disconnect();
379 this.mutationObserver = null;
380 }
381 }
382
383 /**
384 * Force a viewport reset to show the most recent messages
385 * Useful when loading a new session or when messages change significantly
386 */
387 public resetViewport(): void {
388 // Cancel any pending loading operations to prevent race conditions
389 this.cancelCurrentLoadingOperation();
390
391 // Reset viewport state
392 this.visibleMessageStartIndex = 0;
393 this.isLoadingOlderMessages = false;
394
395 // Clear all pending operations
396 this.clearAllPendingOperations();
397
398 this.requestUpdate();
399 }
400
401 /**
402 * Cancel current loading operation if in progress
403 */
404 private cancelCurrentLoadingOperation(): void {
405 if (this.isLoadingOlderMessages) {
406 this.isLoadingOlderMessages = false;
407
408 // Abort the loading operation
409 if (this.loadingAbortController) {
410 this.loadingAbortController.abort();
411 this.loadingAbortController = null;
412 }
413
414 // Cancel pending scroll restoration
415 this.pendingScrollRestoration = null;
416 }
417 }
418
419 /**
420 * Get the filtered messages (excluding hidden ones)
421 */
422 private get filteredMessages(): AgentMessage[] {
423 return this.messages.filter((msg) => !msg.hide_output);
424 }
425
426 /**
427 * Get the currently visible messages based on viewport rendering
428 * Race-condition safe implementation
429 */
430 private get visibleMessages(): AgentMessage[] {
431 const filtered = this.filteredMessages;
432 if (filtered.length === 0) return [];
433
434 // Always show the most recent messages first
435 // visibleMessageStartIndex represents how many additional older messages to show
436 const totalVisible =
437 this.initialMessageCount + this.visibleMessageStartIndex;
438 const startIndex = Math.max(0, filtered.length - totalVisible);
439
440 // Ensure we don't return an invalid slice during loading operations
441 const endIndex = filtered.length;
442 if (startIndex >= endIndex) {
443 return [];
444 }
445
446 return filtered.slice(startIndex, endIndex);
447 }
448
449 /**
450 * Check if the component is in a stable state for loading operations
451 */
452 private isStableForLoading(): boolean {
453 return (
454 this.scrollContainer.value !== null &&
455 this.scrollContainer.value === this.currentScrollContainer &&
456 this.scrollContainer.value.isConnected &&
457 !this.isLoadingOlderMessages &&
458 !this.currentLoadingOperation
459 );
460 }
461
462 /**
463 * Load more older messages by expanding the visible window
464 * Race-condition safe implementation
465 */
466 private async loadOlderMessages(): Promise<void> {
467 // Prevent concurrent loading operations
468 if (this.isLoadingOlderMessages || this.currentLoadingOperation) {
469 return;
470 }
471
472 const filtered = this.filteredMessages;
473 const currentVisibleCount = this.visibleMessages.length;
474 const totalAvailable = filtered.length;
475
476 // Check if there are more messages to load
477 if (currentVisibleCount >= totalAvailable) {
478 return;
479 }
480
481 // Start loading operation with proper state management
482 this.isLoadingOlderMessages = true;
483
484 // Store current scroll position for restoration
485 const container = this.scrollContainer.value;
486 const previousScrollHeight = container?.scrollHeight || 0;
487 const previousScrollTop = container?.scrollTop || 0;
488
489 // Validate scroll container hasn't changed during setup
490 if (!container || container !== this.currentScrollContainer) {
491 this.isLoadingOlderMessages = false;
492 return;
493 }
494
495 // Expand the visible window with bounds checking
496 const additionalMessages = Math.min(
497 this.loadChunkSize,
498 totalAvailable - currentVisibleCount,
499 );
500 const newStartIndex = this.visibleMessageStartIndex + additionalMessages;
501
502 // Ensure we don't exceed available messages
503 const boundedStartIndex = Math.min(
504 newStartIndex,
505 totalAvailable - this.initialMessageCount,
506 );
507 this.visibleMessageStartIndex = Math.max(0, boundedStartIndex);
508
509 // Create the loading operation with proper error handling and cleanup
510 const loadingOperation = this.executeScrollPositionRestoration(
511 container,
512 previousScrollHeight,
513 previousScrollTop,
514 );
515
516 this.currentLoadingOperation = loadingOperation;
517
518 try {
519 await loadingOperation;
520 } catch (error) {
521 console.warn("Loading operation failed:", error);
522 } finally {
523 // Ensure loading state is always cleared
524 this.isLoadingOlderMessages = false;
525 this.currentLoadingOperation = null;
526
527 // Clear the loading timeout if it exists
528 if (this.loadingTimeoutId) {
529 clearTimeout(this.loadingTimeoutId);
530 this.loadingTimeoutId = null;
531 }
532 }
533 }
534
535 /**
536 * Execute scroll position restoration with event-driven approach
537 */
538 private async executeScrollPositionRestoration(
539 container: HTMLElement,
540 previousScrollHeight: number,
541 previousScrollTop: number,
542 ): Promise<void> {
543 // Set up AbortController for proper cancellation
544 this.loadingAbortController = new AbortController();
545 const { signal } = this.loadingAbortController;
546
547 // Create scroll restoration function
548 const restoreScrollPosition = () => {
549 // Check if operation was aborted
550 if (signal.aborted) {
551 return;
552 }
553
554 // Double-check container is still valid and connected
555 if (
556 !container ||
557 !container.isConnected ||
558 container !== this.currentScrollContainer
559 ) {
560 return;
561 }
562
563 try {
564 const newScrollHeight = container.scrollHeight;
565 const scrollDifference = newScrollHeight - previousScrollHeight;
566 const newScrollTop = previousScrollTop + scrollDifference;
567
568 // Validate all scroll calculations before applying
569 const isValidRestoration =
570 scrollDifference > 0 && // Content was added
571 newScrollTop >= 0 && // New position is valid
572 newScrollTop <= newScrollHeight && // Don't exceed max scroll
573 previousScrollHeight > 0 && // Had valid previous height
574 newScrollHeight > previousScrollHeight; // Height actually increased
575
576 if (isValidRestoration) {
577 container.scrollTop = newScrollTop;
578 } else {
579 // Log invalid restoration attempts for debugging
580 console.debug("Skipped scroll restoration:", {
581 scrollDifference,
582 newScrollTop,
583 newScrollHeight,
584 previousScrollHeight,
585 previousScrollTop,
586 });
587 }
588 } catch (error) {
589 console.warn("Scroll position restoration failed:", error);
590 }
591 };
592
593 // Store the restoration function for potential cancellation
594 this.pendingScrollRestoration = restoreScrollPosition;
595
596 // Wait for DOM update and then restore scroll position
597 await this.updateComplete;
598
599 // Check if operation was cancelled during await
600 if (
601 !signal.aborted &&
602 this.pendingScrollRestoration === restoreScrollPosition
603 ) {
604 // Use ResizeObserver to detect when content is actually ready
605 await this.waitForContentReady(container, signal);
606
607 if (!signal.aborted) {
608 restoreScrollPosition();
609 this.pendingScrollRestoration = null;
610 }
611 }
612 }
613
614 /**
615 * Wait for content to be ready using ResizeObserver instead of setTimeout
616 */
617 private async waitForContentReady(
618 container: HTMLElement,
619 signal: AbortSignal,
620 ): Promise<void> {
621 return new Promise((resolve, reject) => {
622 if (signal.aborted) {
623 reject(new Error("Operation aborted"));
624 return;
625 }
626
627 // Resolve immediately if container already has content
628 if (container.scrollHeight > 0) {
629 resolve();
630 return;
631 }
632
633 // Set up ResizeObserver to detect content changes
634 const observer = new ResizeObserver((entries) => {
635 if (signal.aborted) {
636 observer.disconnect();
637 reject(new Error("Operation aborted"));
638 return;
639 }
640
641 // Content is ready when height increases
642 const entry = entries[0];
643 if (entry && entry.contentRect.height > 0) {
644 observer.disconnect();
645 resolve();
646 }
647 });
648
649 // Start observing
650 observer.observe(container);
651
652 // Clean up on abort
653 signal.addEventListener("abort", () => {
654 observer.disconnect();
655 reject(new Error("Operation aborted"));
656 });
657 });
658 }
659
660 /**
Sean McCullough2c5bba42025-04-20 19:33:17 -0700661 * Scroll to the bottom of the timeline
662 */
663 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000664 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000665
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000666 // Use instant scroll to ensure we reach the exact bottom
667 this.scrollContainer.value.scrollTo({
668 top: this.scrollContainer.value.scrollHeight,
669 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700670 });
671 }
Autoformatter71c73b52025-05-29 20:18:43 +0000672
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000673 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000674 * Scroll to bottom with event-driven approach using MutationObserver
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000675 */
Sean McCulloughe68613d2025-06-18 14:48:53 +0000676 private async scrollToBottomWithRetry(): Promise<void> {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000677 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000678
Sean McCulloughe68613d2025-06-18 14:48:53 +0000679 const container = this.scrollContainer.value;
Autoformatter71c73b52025-05-29 20:18:43 +0000680
Sean McCulloughe68613d2025-06-18 14:48:53 +0000681 // Try immediate scroll first
682 this.scrollToBottom();
Autoformatter71c73b52025-05-29 20:18:43 +0000683
Sean McCulloughe68613d2025-06-18 14:48:53 +0000684 // Check if we're at the bottom
685 const isAtBottom = () => {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000686 const targetScrollTop = container.scrollHeight - container.clientHeight;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000687 const actualScrollTop = container.scrollTop;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000688 return Math.abs(targetScrollTop - actualScrollTop) <= 1;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000689 };
Autoformatter71c73b52025-05-29 20:18:43 +0000690
Sean McCulloughe68613d2025-06-18 14:48:53 +0000691 // If already at bottom, we're done
692 if (isAtBottom()) {
693 return;
694 }
695
696 // Use MutationObserver to detect content changes and retry
697 return new Promise((resolve) => {
698 let scrollAttempted = false;
699
700 const observer = new MutationObserver(() => {
701 if (!scrollAttempted) {
702 scrollAttempted = true;
703
704 // Use requestAnimationFrame to ensure DOM is painted
705 requestAnimationFrame(() => {
706 this.scrollToBottom();
707
708 // Check if successful
709 if (isAtBottom()) {
710 observer.disconnect();
711 resolve();
712 } else {
713 // Try one more time after another frame
714 requestAnimationFrame(() => {
715 this.scrollToBottom();
716 observer.disconnect();
717 resolve();
718 });
719 }
720 });
721 }
722 });
723
724 // Observe changes to the timeline container
725 observer.observe(container, {
726 childList: true,
727 subtree: true,
728 attributes: false,
729 });
730
731 // Clean up after a reasonable time if no changes detected
732 requestAnimationFrame(() => {
733 requestAnimationFrame(() => {
734 if (!scrollAttempted) {
735 observer.disconnect();
736 resolve();
737 }
738 });
739 });
740 });
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000741 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700742
743 /**
744 * Called after the component's properties have been updated
745 */
746 updated(changedProperties: PropertyValues): void {
banksean54777362025-06-19 16:38:30 +0000747 // Handle DataManager changes to set up event listeners
748 if (changedProperties.has("dataManager")) {
749 const oldDataManager = changedProperties.get("dataManager");
750
751 // Remove old event listener if it exists
752 if (oldDataManager) {
753 oldDataManager.removeEventListener(
754 "initialLoadComplete",
755 this.handleInitialLoadComplete,
756 );
757 }
758
759 // Add new event listener if dataManager is available
760 if (this.dataManager) {
761 this.dataManager.addEventListener(
762 "initialLoadComplete",
763 this.handleInitialLoadComplete,
764 );
765
766 // Check if initial load is already complete
767 if (
768 this.dataManager.getIsInitialLoadComplete &&
769 this.dataManager.getIsInitialLoadComplete()
770 ) {
771 this.isInitialLoadComplete = true;
772 }
773 }
774 }
775
Sean McCulloughe68613d2025-06-18 14:48:53 +0000776 // Handle scroll container changes first to prevent race conditions
777 if (changedProperties.has("scrollContainer")) {
778 // Cancel any ongoing loading operations since container is changing
779 this.cancelCurrentLoadingOperation();
780
781 if (this.scrollContainer.value) {
782 this.addScrollListener(this.scrollContainer.value);
783 } else {
784 this.removeScrollListener();
Sean McCullough2c5bba42025-04-20 19:33:17 -0700785 }
786 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000787
788 // If messages have changed, handle viewport updates
789 if (changedProperties.has("messages")) {
790 const oldMessages =
791 (changedProperties.get("messages") as AgentMessage[]) || [];
792 const newMessages = this.messages || [];
793
794 // Cancel loading operations if messages changed significantly
795 const significantChange =
796 oldMessages.length === 0 ||
797 newMessages.length < oldMessages.length ||
798 Math.abs(newMessages.length - oldMessages.length) > 20;
799
800 if (significantChange) {
801 // Cancel any ongoing operations and reset viewport
802 this.cancelCurrentLoadingOperation();
803 this.visibleMessageStartIndex = 0;
804 }
805
806 // Scroll to bottom if needed (only if not loading to prevent race conditions)
807 if (
808 this.messages.length > 0 &&
809 this.scrollingState === "pinToLatest" &&
810 !this.isLoadingOlderMessages
811 ) {
812 // Use async scroll without setTimeout
813 this.scrollToBottomWithRetry().catch((error) => {
814 console.warn("Scroll to bottom failed:", error);
815 });
816 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700817 }
Sean McCullough86b56862025-04-18 13:04:03 -0700818 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700819
Sean McCullough86b56862025-04-18 13:04:03 -0700820 /**
821 * Handle showCommitDiff event
822 */
823 private _handleShowCommitDiff(event: CustomEvent) {
824 const { commitHash } = event.detail;
825 if (commitHash) {
826 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700827 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700828 detail: { commitHash },
829 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700830 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700831 });
832 this.dispatchEvent(newEvent);
833 }
834 }
835
Sean McCullough2c5bba42025-04-20 19:33:17 -0700836 private _handleScroll(event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000837 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000838
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000839 const container = this.scrollContainer.value;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000840
841 // Verify this is still our tracked container to prevent race conditions
842 if (container !== this.currentScrollContainer) {
843 return;
844 }
845
Sean McCullough2c5bba42025-04-20 19:33:17 -0700846 const isAtBottom =
847 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000848 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000849 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000850
Sean McCulloughe68613d2025-06-18 14:48:53 +0000851 const isNearTop = container.scrollTop <= this.loadMoreThreshold;
852
853 // Update scroll state immediately for responsive UI
Sean McCullough2c5bba42025-04-20 19:33:17 -0700854 if (isAtBottom) {
855 this.scrollingState = "pinToLatest";
856 } else {
Sean McCullough2c5bba42025-04-20 19:33:17 -0700857 this.scrollingState = "floating";
858 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000859
860 // Use requestAnimationFrame for smooth debouncing instead of setTimeout
861 if (this.scrollDebounceFrame) {
862 cancelAnimationFrame(this.scrollDebounceFrame);
863 }
864
865 this.scrollDebounceFrame = requestAnimationFrame(() => {
866 // Use stability check to ensure safe loading conditions
867 if (isNearTop && this.isStableForLoading()) {
868 this.loadOlderMessages().catch((error) => {
869 console.warn("Async loadOlderMessages failed:", error);
870 });
871 }
872 this.scrollDebounceFrame = null;
873 });
Sean McCullough2c5bba42025-04-20 19:33:17 -0700874 }
875
Sean McCullough86b56862025-04-18 13:04:03 -0700876 // See https://lit.dev/docs/components/lifecycle/
877 connectedCallback() {
878 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700879
Sean McCullough86b56862025-04-18 13:04:03 -0700880 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700881 document.addEventListener(
882 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700883 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700884 );
Pokey Rule4097e532025-04-24 18:55:28 +0100885
Sean McCulloughe68613d2025-06-18 14:48:53 +0000886 // Set up scroll listener if container is available
887 if (this.scrollContainer.value) {
888 this.addScrollListener(this.scrollContainer.value);
889 }
890
891 // Initialize observers for event-driven behavior
892 this.setupObservers();
Sean McCullough86b56862025-04-18 13:04:03 -0700893 }
894
Sean McCulloughe68613d2025-06-18 14:48:53 +0000895 /**
banksean54777362025-06-19 16:38:30 +0000896 * Handle initial load completion from DataManager
897 */
898 private handleInitialLoadComplete = (eventData: {
899 messageCount: number;
900 expectedCount: number;
901 }): void => {
902 console.log(
903 `Timeline: Initial load complete - ${eventData.messageCount}/${eventData.expectedCount} messages`,
904 );
905 this.isInitialLoadComplete = true;
906 this.requestUpdate();
907 };
908
909 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000910 * Set up observers for event-driven DOM monitoring
911 */
912 private setupObservers(): void {
913 // ResizeObserver will be created on-demand in loading operations
914 // MutationObserver will be created on-demand in scroll operations
915 // This avoids creating observers that may not be needed
916 }
917
918 // See https://lit.dev/docs/component/lifecycle/
Sean McCullough86b56862025-04-18 13:04:03 -0700919 disconnectedCallback() {
920 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700921
Sean McCulloughe68613d2025-06-18 14:48:53 +0000922 // Cancel any ongoing loading operations before cleanup
923 this.cancelCurrentLoadingOperation();
924
925 // Remove event listeners with guaranteed cleanup
Sean McCullough71941bd2025-04-18 13:31:48 -0700926 document.removeEventListener(
927 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700928 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700929 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700930
banksean54777362025-06-19 16:38:30 +0000931 // Remove DataManager event listener if connected
932 if (this.dataManager) {
933 this.dataManager.removeEventListener(
934 "initialLoadComplete",
935 this.handleInitialLoadComplete,
936 );
937 }
938
Sean McCulloughe68613d2025-06-18 14:48:53 +0000939 // Use our safe cleanup method
940 this.removeScrollListener();
Sean McCullough86b56862025-04-18 13:04:03 -0700941 }
942
Sean McCulloughd9f13372025-04-21 15:08:49 -0700943 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700944 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700945 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700946 // If the message has tool calls, and any of the tool_calls get a response, we need to
947 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700948 const toolCallResponses = message.tool_calls
949 ?.filter((tc) => tc.result_message)
950 .map((tc) => tc.tool_call_id)
951 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700952 return `message-${message.idx}-${toolCallResponses}`;
953 }
954
955 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000956 // Check if messages array is empty and render welcome box if it is
957 if (this.messages.length === 0) {
958 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700959 <div style="position: relative; height: 100%;">
960 <div id="scroll-container">
961 <div class="welcome-box">
962 <h2 class="welcome-box-title">How to use Sketch</h2>
963 <p class="welcome-box-content">
964 Sketch is an agentic coding assistant.
965 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700966
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700967 <p class="welcome-box-content">
968 Sketch has created a container with your repo.
969 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700970
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700971 <p class="welcome-box-content">
972 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000973 below. It can edit and run your code, all in the container.
974 Sketch will create commits in a newly created git branch, which
975 you can look at and comment on in the Diff tab. Once you're
976 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700977 </p>
978 <p class="welcome-box-content">
979 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000980 Sketch in parallel to work on multiple ideas or even the same
981 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700982 </p>
983 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000984 </div>
985 </div>
986 `;
987 }
988
989 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000990 const isThinking =
991 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
992
banksean54777362025-06-19 16:38:30 +0000993 // Apply view-initialized class when initial load is complete
994 const containerClass = this.isInitialLoadComplete
995 ? "timeline-container view-initialized"
996 : "timeline-container";
997
Sean McCullough86b56862025-04-18 13:04:03 -0700998 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700999 <div style="position: relative; height: 100%;">
1000 <div id="scroll-container">
banksean54777362025-06-19 16:38:30 +00001001 <div class="${containerClass}">
1002 ${!this.isInitialLoadComplete
1003 ? html`
1004 <div class="loading-indicator">
1005 <div class="loading-spinner"></div>
1006 <span>Loading conversation...</span>
1007 </div>
1008 `
1009 : ""}
Sean McCulloughe68613d2025-06-18 14:48:53 +00001010 ${this.isLoadingOlderMessages
1011 ? html`
1012 <div class="loading-indicator">
1013 <div class="loading-spinner"></div>
1014 <span>Loading older messages...</span>
1015 </div>
1016 `
1017 : ""}
banksean54777362025-06-19 16:38:30 +00001018 ${this.isInitialLoadComplete
1019 ? repeat(
1020 this.visibleMessages,
1021 this.messageKey,
1022 (message, index) => {
1023 // Find the previous message in the full filtered messages array
1024 const filteredMessages = this.filteredMessages;
1025 const messageIndex = filteredMessages.findIndex(
1026 (m) => m === message,
1027 );
1028 let previousMessage =
1029 messageIndex > 0
1030 ? filteredMessages[messageIndex - 1]
1031 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +00001032
banksean54777362025-06-19 16:38:30 +00001033 return html`<sketch-timeline-message
1034 .message=${message}
1035 .previousMessage=${previousMessage}
1036 .open=${false}
1037 .firstMessageIndex=${this.firstMessageIndex}
1038 .state=${this.state}
1039 ></sketch-timeline-message>`;
1040 },
1041 )
1042 : ""}
1043 ${isThinking && this.isInitialLoadComplete
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001044 ? html`
1045 <div class="thinking-indicator">
1046 <div class="thinking-bubble">
1047 <div class="thinking-dots">
1048 <div class="dot"></div>
1049 <div class="dot"></div>
1050 <div class="dot"></div>
1051 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001052 </div>
1053 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001054 `
1055 : ""}
1056 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -07001057 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001058 <div
1059 id="jump-to-latest"
1060 class="${this.scrollingState}"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +00001061 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001062 >
1063
1064 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -07001065 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001066 `;
1067 }
1068}
1069
1070declare global {
1071 interface HTMLElementTagNameMap {
1072 "sketch-timeline": SketchTimeline;
1073 }
Sean McCullough71941bd2025-04-18 13:31:48 -07001074}