blob: 77aff74122d90d18a8395e28660f3c2d13b2701e [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;
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700128 position: fixed;
129 bottom: 80px; /* Position right on the boundary */
130 left: 50%;
131 transform: translateX(-50%);
132 background: rgba(0, 0, 0, 0.6);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700133 color: white;
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700134 border: none;
135 border-radius: 12px;
136 padding: 4px 8px;
137 font-size: 11px;
138 font-weight: 400;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700139 cursor: pointer;
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700140 box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
141 z-index: 1000;
142 transition: all 0.15s ease;
143 white-space: nowrap;
144 opacity: 0.8;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700145 }
146 #jump-to-latest:hover {
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700147 background-color: rgba(0, 0, 0, 0.8);
148 transform: translateX(-50%) translateY(-1px);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700149 opacity: 1;
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700150 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
151 }
152 #jump-to-latest:active {
153 transform: translateX(-50%) translateY(0);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700154 }
155 #jump-to-latest.floating {
156 display: block;
157 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000158
159 /* Welcome box styles for the empty chat state */
160 .welcome-box {
161 margin: 2rem auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700162 max-width: 90%;
163 width: 90%;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000164 padding: 2rem;
165 border: 2px solid #e0e0e0;
166 border-radius: 8px;
167 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
168 background-color: #ffffff;
169 text-align: center;
170 }
171
172 .welcome-box-title {
173 font-size: 1.5rem;
174 font-weight: 600;
175 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700176 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000177 color: #333;
178 }
179
180 .welcome-box-content {
181 color: #666; /* Slightly grey font color */
182 line-height: 1.6;
183 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700184 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000185 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000186
187 /* Thinking indicator styles */
188 .thinking-indicator {
189 padding-left: 85px;
190 margin-top: 5px;
191 margin-bottom: 15px;
192 display: flex;
193 }
194
195 .thinking-bubble {
196 background-color: #f1f1f1;
197 border-radius: 15px;
198 padding: 10px 15px;
199 max-width: 80px;
200 color: black;
201 position: relative;
202 border-bottom-left-radius: 5px;
203 }
204
205 .thinking-dots {
206 display: flex;
207 align-items: center;
208 justify-content: center;
209 gap: 4px;
210 height: 14px;
211 }
212
213 .dot {
214 width: 6px;
215 height: 6px;
216 background-color: #888;
217 border-radius: 50%;
218 opacity: 0.6;
219 }
220
221 .dot:nth-child(1) {
222 animation: pulse 1.5s infinite ease-in-out;
223 }
224
225 .dot:nth-child(2) {
226 animation: pulse 1.5s infinite ease-in-out 0.3s;
227 }
228
229 .dot:nth-child(3) {
230 animation: pulse 1.5s infinite ease-in-out 0.6s;
231 }
232
233 @keyframes pulse {
234 0%,
235 100% {
236 opacity: 0.4;
237 transform: scale(1);
238 }
239 50% {
240 opacity: 1;
241 transform: scale(1.2);
242 }
243 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000244
245 /* Loading indicator styles */
246 .loading-indicator {
247 display: flex;
248 align-items: center;
249 justify-content: center;
250 padding: 20px;
251 color: #666;
252 font-size: 14px;
253 gap: 10px;
254 }
255
256 .loading-spinner {
257 width: 20px;
258 height: 20px;
259 border: 2px solid #e0e0e0;
260 border-top: 2px solid #666;
261 border-radius: 50%;
262 animation: spin 1s linear infinite;
263 }
264
265 @keyframes spin {
266 0% {
267 transform: rotate(0deg);
268 }
269 100% {
270 transform: rotate(360deg);
271 }
272 }
Sean McCullough86b56862025-04-18 13:04:03 -0700273
philip.zeyligerffa94c62025-06-19 18:43:37 -0700274 /* Print styles for full timeline printing */
275 @media print {
276 .timeline-container {
277 height: auto !important;
278 max-height: none !important;
279 overflow: visible !important;
280 page-break-inside: avoid;
281 }
282
283 .timeline {
284 height: auto !important;
285 max-height: none !important;
286 overflow: visible !important;
287 }
288
289 #scroll-container {
290 height: auto !important;
291 max-height: none !important;
292 overflow: visible !important;
293 overflow-y: visible !important;
294 overflow-x: visible !important;
295 }
296
297 /* Hide the jump to latest button during printing */
298 #jump-to-latest {
299 display: none !important;
300 }
301
302 /* Hide the thinking indicator during printing */
303 .thinking-indicator {
304 display: none !important;
305 }
306
307 /* Hide the loading indicator during printing */
308 .loading-indicator {
309 display: none !important;
310 }
311
312 /* Ensure welcome box prints properly if visible */
313 .welcome-box {
314 page-break-inside: avoid;
315 }
316 }
317 `;
Sean McCullough86b56862025-04-18 13:04:03 -0700318 constructor() {
319 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700320
Sean McCullough86b56862025-04-18 13:04:03 -0700321 // Binding methods
322 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700323 this._handleScroll = this._handleScroll.bind(this);
324 }
325
326 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000327 * Safely add scroll event listener with proper cleanup tracking
328 */
329 private addScrollListener(container: HTMLElement): void {
330 // Remove any existing listener first
331 this.removeScrollListener();
332
333 // Add new listener and track the container
334 container.addEventListener("scroll", this._handleScroll);
335 this.currentScrollContainer = container;
336 }
337
338 /**
339 * Safely remove scroll event listener
340 */
341 private removeScrollListener(): void {
342 if (this.currentScrollContainer) {
343 this.currentScrollContainer.removeEventListener(
344 "scroll",
345 this._handleScroll,
346 );
347 this.currentScrollContainer = null;
348 }
349
350 // Clear any pending timeouts and operations
351 this.clearAllPendingOperations();
352 }
353
354 /**
355 * Clear all pending operations and observers to prevent race conditions
356 */
357 private clearAllPendingOperations(): void {
358 // Clear scroll debounce frame
359 if (this.scrollDebounceFrame) {
360 cancelAnimationFrame(this.scrollDebounceFrame);
361 this.scrollDebounceFrame = null;
362 }
363
364 // Abort loading operations
365 if (this.loadingAbortController) {
366 this.loadingAbortController.abort();
367 this.loadingAbortController = null;
368 }
369
370 // Cancel pending scroll restoration
371 if (this.pendingScrollRestoration) {
372 this.pendingScrollRestoration = null;
373 }
374
375 // Clean up observers
376 this.disconnectObservers();
377 }
378
379 /**
380 * Disconnect all observers
381 */
382 private disconnectObservers(): void {
383 if (this.resizeObserver) {
384 this.resizeObserver.disconnect();
385 this.resizeObserver = null;
386 }
387
388 if (this.mutationObserver) {
389 this.mutationObserver.disconnect();
390 this.mutationObserver = null;
391 }
392 }
393
394 /**
395 * Force a viewport reset to show the most recent messages
396 * Useful when loading a new session or when messages change significantly
397 */
398 public resetViewport(): void {
399 // Cancel any pending loading operations to prevent race conditions
400 this.cancelCurrentLoadingOperation();
401
402 // Reset viewport state
403 this.visibleMessageStartIndex = 0;
404 this.isLoadingOlderMessages = false;
405
406 // Clear all pending operations
407 this.clearAllPendingOperations();
408
409 this.requestUpdate();
410 }
411
412 /**
413 * Cancel current loading operation if in progress
414 */
415 private cancelCurrentLoadingOperation(): void {
416 if (this.isLoadingOlderMessages) {
417 this.isLoadingOlderMessages = false;
418
419 // Abort the loading operation
420 if (this.loadingAbortController) {
421 this.loadingAbortController.abort();
422 this.loadingAbortController = null;
423 }
424
425 // Cancel pending scroll restoration
426 this.pendingScrollRestoration = null;
427 }
428 }
429
430 /**
431 * Get the filtered messages (excluding hidden ones)
432 */
433 private get filteredMessages(): AgentMessage[] {
434 return this.messages.filter((msg) => !msg.hide_output);
435 }
436
437 /**
438 * Get the currently visible messages based on viewport rendering
439 * Race-condition safe implementation
440 */
441 private get visibleMessages(): AgentMessage[] {
442 const filtered = this.filteredMessages;
443 if (filtered.length === 0) return [];
444
445 // Always show the most recent messages first
446 // visibleMessageStartIndex represents how many additional older messages to show
447 const totalVisible =
448 this.initialMessageCount + this.visibleMessageStartIndex;
449 const startIndex = Math.max(0, filtered.length - totalVisible);
450
451 // Ensure we don't return an invalid slice during loading operations
452 const endIndex = filtered.length;
453 if (startIndex >= endIndex) {
454 return [];
455 }
456
457 return filtered.slice(startIndex, endIndex);
458 }
459
460 /**
461 * Check if the component is in a stable state for loading operations
462 */
463 private isStableForLoading(): boolean {
464 return (
465 this.scrollContainer.value !== null &&
466 this.scrollContainer.value === this.currentScrollContainer &&
467 this.scrollContainer.value.isConnected &&
468 !this.isLoadingOlderMessages &&
469 !this.currentLoadingOperation
470 );
471 }
472
473 /**
474 * Load more older messages by expanding the visible window
475 * Race-condition safe implementation
476 */
477 private async loadOlderMessages(): Promise<void> {
478 // Prevent concurrent loading operations
479 if (this.isLoadingOlderMessages || this.currentLoadingOperation) {
480 return;
481 }
482
483 const filtered = this.filteredMessages;
484 const currentVisibleCount = this.visibleMessages.length;
485 const totalAvailable = filtered.length;
486
487 // Check if there are more messages to load
488 if (currentVisibleCount >= totalAvailable) {
489 return;
490 }
491
492 // Start loading operation with proper state management
493 this.isLoadingOlderMessages = true;
494
495 // Store current scroll position for restoration
496 const container = this.scrollContainer.value;
497 const previousScrollHeight = container?.scrollHeight || 0;
498 const previousScrollTop = container?.scrollTop || 0;
499
500 // Validate scroll container hasn't changed during setup
501 if (!container || container !== this.currentScrollContainer) {
502 this.isLoadingOlderMessages = false;
503 return;
504 }
505
506 // Expand the visible window with bounds checking
507 const additionalMessages = Math.min(
508 this.loadChunkSize,
509 totalAvailable - currentVisibleCount,
510 );
511 const newStartIndex = this.visibleMessageStartIndex + additionalMessages;
512
513 // Ensure we don't exceed available messages
514 const boundedStartIndex = Math.min(
515 newStartIndex,
516 totalAvailable - this.initialMessageCount,
517 );
518 this.visibleMessageStartIndex = Math.max(0, boundedStartIndex);
519
520 // Create the loading operation with proper error handling and cleanup
521 const loadingOperation = this.executeScrollPositionRestoration(
522 container,
523 previousScrollHeight,
524 previousScrollTop,
525 );
526
527 this.currentLoadingOperation = loadingOperation;
528
529 try {
530 await loadingOperation;
531 } catch (error) {
532 console.warn("Loading operation failed:", error);
533 } finally {
534 // Ensure loading state is always cleared
535 this.isLoadingOlderMessages = false;
536 this.currentLoadingOperation = null;
537
538 // Clear the loading timeout if it exists
539 if (this.loadingTimeoutId) {
540 clearTimeout(this.loadingTimeoutId);
541 this.loadingTimeoutId = null;
542 }
543 }
544 }
545
546 /**
547 * Execute scroll position restoration with event-driven approach
548 */
549 private async executeScrollPositionRestoration(
550 container: HTMLElement,
551 previousScrollHeight: number,
552 previousScrollTop: number,
553 ): Promise<void> {
554 // Set up AbortController for proper cancellation
555 this.loadingAbortController = new AbortController();
556 const { signal } = this.loadingAbortController;
557
558 // Create scroll restoration function
559 const restoreScrollPosition = () => {
560 // Check if operation was aborted
561 if (signal.aborted) {
562 return;
563 }
564
565 // Double-check container is still valid and connected
566 if (
567 !container ||
568 !container.isConnected ||
569 container !== this.currentScrollContainer
570 ) {
571 return;
572 }
573
574 try {
575 const newScrollHeight = container.scrollHeight;
576 const scrollDifference = newScrollHeight - previousScrollHeight;
577 const newScrollTop = previousScrollTop + scrollDifference;
578
579 // Validate all scroll calculations before applying
580 const isValidRestoration =
581 scrollDifference > 0 && // Content was added
582 newScrollTop >= 0 && // New position is valid
583 newScrollTop <= newScrollHeight && // Don't exceed max scroll
584 previousScrollHeight > 0 && // Had valid previous height
585 newScrollHeight > previousScrollHeight; // Height actually increased
586
587 if (isValidRestoration) {
588 container.scrollTop = newScrollTop;
589 } else {
590 // Log invalid restoration attempts for debugging
591 console.debug("Skipped scroll restoration:", {
592 scrollDifference,
593 newScrollTop,
594 newScrollHeight,
595 previousScrollHeight,
596 previousScrollTop,
597 });
598 }
599 } catch (error) {
600 console.warn("Scroll position restoration failed:", error);
601 }
602 };
603
604 // Store the restoration function for potential cancellation
605 this.pendingScrollRestoration = restoreScrollPosition;
606
607 // Wait for DOM update and then restore scroll position
608 await this.updateComplete;
609
610 // Check if operation was cancelled during await
611 if (
612 !signal.aborted &&
613 this.pendingScrollRestoration === restoreScrollPosition
614 ) {
615 // Use ResizeObserver to detect when content is actually ready
616 await this.waitForContentReady(container, signal);
617
618 if (!signal.aborted) {
619 restoreScrollPosition();
620 this.pendingScrollRestoration = null;
621 }
622 }
623 }
624
625 /**
626 * Wait for content to be ready using ResizeObserver instead of setTimeout
627 */
628 private async waitForContentReady(
629 container: HTMLElement,
630 signal: AbortSignal,
631 ): Promise<void> {
632 return new Promise((resolve, reject) => {
633 if (signal.aborted) {
634 reject(new Error("Operation aborted"));
635 return;
636 }
637
638 // Resolve immediately if container already has content
639 if (container.scrollHeight > 0) {
640 resolve();
641 return;
642 }
643
644 // Set up ResizeObserver to detect content changes
645 const observer = new ResizeObserver((entries) => {
646 if (signal.aborted) {
647 observer.disconnect();
648 reject(new Error("Operation aborted"));
649 return;
650 }
651
652 // Content is ready when height increases
653 const entry = entries[0];
654 if (entry && entry.contentRect.height > 0) {
655 observer.disconnect();
656 resolve();
657 }
658 });
659
660 // Start observing
661 observer.observe(container);
662
663 // Clean up on abort
664 signal.addEventListener("abort", () => {
665 observer.disconnect();
666 reject(new Error("Operation aborted"));
667 });
668 });
669 }
670
671 /**
Sean McCullough2c5bba42025-04-20 19:33:17 -0700672 * Scroll to the bottom of the timeline
673 */
674 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000675 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000676
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000677 // Use instant scroll to ensure we reach the exact bottom
678 this.scrollContainer.value.scrollTo({
679 top: this.scrollContainer.value.scrollHeight,
680 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700681 });
682 }
Autoformatter71c73b52025-05-29 20:18:43 +0000683
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000684 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000685 * Scroll to bottom with event-driven approach using MutationObserver
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000686 */
Sean McCulloughe68613d2025-06-18 14:48:53 +0000687 private async scrollToBottomWithRetry(): Promise<void> {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000688 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000689
Sean McCulloughe68613d2025-06-18 14:48:53 +0000690 const container = this.scrollContainer.value;
Autoformatter71c73b52025-05-29 20:18:43 +0000691
Sean McCulloughe68613d2025-06-18 14:48:53 +0000692 // Try immediate scroll first
693 this.scrollToBottom();
Autoformatter71c73b52025-05-29 20:18:43 +0000694
Sean McCulloughe68613d2025-06-18 14:48:53 +0000695 // Check if we're at the bottom
696 const isAtBottom = () => {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000697 const targetScrollTop = container.scrollHeight - container.clientHeight;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000698 const actualScrollTop = container.scrollTop;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000699 return Math.abs(targetScrollTop - actualScrollTop) <= 1;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000700 };
Autoformatter71c73b52025-05-29 20:18:43 +0000701
Sean McCulloughe68613d2025-06-18 14:48:53 +0000702 // If already at bottom, we're done
703 if (isAtBottom()) {
704 return;
705 }
706
707 // Use MutationObserver to detect content changes and retry
708 return new Promise((resolve) => {
709 let scrollAttempted = false;
710
711 const observer = new MutationObserver(() => {
712 if (!scrollAttempted) {
713 scrollAttempted = true;
714
715 // Use requestAnimationFrame to ensure DOM is painted
716 requestAnimationFrame(() => {
717 this.scrollToBottom();
718
719 // Check if successful
720 if (isAtBottom()) {
721 observer.disconnect();
722 resolve();
723 } else {
724 // Try one more time after another frame
725 requestAnimationFrame(() => {
726 this.scrollToBottom();
727 observer.disconnect();
728 resolve();
729 });
730 }
731 });
732 }
733 });
734
735 // Observe changes to the timeline container
736 observer.observe(container, {
737 childList: true,
738 subtree: true,
739 attributes: false,
740 });
741
742 // Clean up after a reasonable time if no changes detected
743 requestAnimationFrame(() => {
744 requestAnimationFrame(() => {
745 if (!scrollAttempted) {
746 observer.disconnect();
747 resolve();
748 }
749 });
750 });
751 });
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000752 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700753
754 /**
755 * Called after the component's properties have been updated
756 */
757 updated(changedProperties: PropertyValues): void {
banksean54777362025-06-19 16:38:30 +0000758 // Handle DataManager changes to set up event listeners
759 if (changedProperties.has("dataManager")) {
760 const oldDataManager = changedProperties.get("dataManager");
761
762 // Remove old event listener if it exists
763 if (oldDataManager) {
764 oldDataManager.removeEventListener(
765 "initialLoadComplete",
766 this.handleInitialLoadComplete,
767 );
768 }
769
770 // Add new event listener if dataManager is available
771 if (this.dataManager) {
772 this.dataManager.addEventListener(
773 "initialLoadComplete",
774 this.handleInitialLoadComplete,
775 );
776
777 // Check if initial load is already complete
778 if (
779 this.dataManager.getIsInitialLoadComplete &&
780 this.dataManager.getIsInitialLoadComplete()
781 ) {
782 this.isInitialLoadComplete = true;
783 }
784 }
785 }
786
Sean McCulloughe68613d2025-06-18 14:48:53 +0000787 // Handle scroll container changes first to prevent race conditions
788 if (changedProperties.has("scrollContainer")) {
789 // Cancel any ongoing loading operations since container is changing
790 this.cancelCurrentLoadingOperation();
791
792 if (this.scrollContainer.value) {
793 this.addScrollListener(this.scrollContainer.value);
794 } else {
795 this.removeScrollListener();
Sean McCullough2c5bba42025-04-20 19:33:17 -0700796 }
797 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000798
799 // If messages have changed, handle viewport updates
800 if (changedProperties.has("messages")) {
801 const oldMessages =
802 (changedProperties.get("messages") as AgentMessage[]) || [];
803 const newMessages = this.messages || [];
804
805 // Cancel loading operations if messages changed significantly
806 const significantChange =
807 oldMessages.length === 0 ||
808 newMessages.length < oldMessages.length ||
809 Math.abs(newMessages.length - oldMessages.length) > 20;
810
811 if (significantChange) {
812 // Cancel any ongoing operations and reset viewport
813 this.cancelCurrentLoadingOperation();
814 this.visibleMessageStartIndex = 0;
815 }
816
817 // Scroll to bottom if needed (only if not loading to prevent race conditions)
818 if (
819 this.messages.length > 0 &&
820 this.scrollingState === "pinToLatest" &&
821 !this.isLoadingOlderMessages
822 ) {
823 // Use async scroll without setTimeout
824 this.scrollToBottomWithRetry().catch((error) => {
825 console.warn("Scroll to bottom failed:", error);
826 });
827 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700828 }
Sean McCullough86b56862025-04-18 13:04:03 -0700829 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700830
Sean McCullough86b56862025-04-18 13:04:03 -0700831 /**
832 * Handle showCommitDiff event
833 */
834 private _handleShowCommitDiff(event: CustomEvent) {
835 const { commitHash } = event.detail;
836 if (commitHash) {
837 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700838 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700839 detail: { commitHash },
840 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700841 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700842 });
843 this.dispatchEvent(newEvent);
844 }
845 }
846
Sean McCullough2c5bba42025-04-20 19:33:17 -0700847 private _handleScroll(event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000848 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000849
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000850 const container = this.scrollContainer.value;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000851
852 // Verify this is still our tracked container to prevent race conditions
853 if (container !== this.currentScrollContainer) {
854 return;
855 }
856
Sean McCullough2c5bba42025-04-20 19:33:17 -0700857 const isAtBottom =
858 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000859 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000860 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000861
Sean McCulloughe68613d2025-06-18 14:48:53 +0000862 const isNearTop = container.scrollTop <= this.loadMoreThreshold;
863
864 // Update scroll state immediately for responsive UI
Sean McCullough2c5bba42025-04-20 19:33:17 -0700865 if (isAtBottom) {
866 this.scrollingState = "pinToLatest";
867 } else {
Sean McCullough2c5bba42025-04-20 19:33:17 -0700868 this.scrollingState = "floating";
869 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000870
871 // Use requestAnimationFrame for smooth debouncing instead of setTimeout
872 if (this.scrollDebounceFrame) {
873 cancelAnimationFrame(this.scrollDebounceFrame);
874 }
875
876 this.scrollDebounceFrame = requestAnimationFrame(() => {
877 // Use stability check to ensure safe loading conditions
878 if (isNearTop && this.isStableForLoading()) {
879 this.loadOlderMessages().catch((error) => {
880 console.warn("Async loadOlderMessages failed:", error);
881 });
882 }
883 this.scrollDebounceFrame = null;
884 });
Sean McCullough2c5bba42025-04-20 19:33:17 -0700885 }
886
Sean McCullough86b56862025-04-18 13:04:03 -0700887 // See https://lit.dev/docs/components/lifecycle/
888 connectedCallback() {
889 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700890
Sean McCullough86b56862025-04-18 13:04:03 -0700891 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700892 document.addEventListener(
893 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700894 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700895 );
Pokey Rule4097e532025-04-24 18:55:28 +0100896
Sean McCulloughe68613d2025-06-18 14:48:53 +0000897 // Set up scroll listener if container is available
898 if (this.scrollContainer.value) {
899 this.addScrollListener(this.scrollContainer.value);
900 }
901
902 // Initialize observers for event-driven behavior
903 this.setupObservers();
Sean McCullough86b56862025-04-18 13:04:03 -0700904 }
905
Sean McCulloughe68613d2025-06-18 14:48:53 +0000906 /**
banksean54777362025-06-19 16:38:30 +0000907 * Handle initial load completion from DataManager
908 */
909 private handleInitialLoadComplete = (eventData: {
910 messageCount: number;
911 expectedCount: number;
912 }): void => {
913 console.log(
914 `Timeline: Initial load complete - ${eventData.messageCount}/${eventData.expectedCount} messages`,
915 );
916 this.isInitialLoadComplete = true;
917 this.requestUpdate();
918 };
919
920 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000921 * Set up observers for event-driven DOM monitoring
922 */
923 private setupObservers(): void {
924 // ResizeObserver will be created on-demand in loading operations
925 // MutationObserver will be created on-demand in scroll operations
926 // This avoids creating observers that may not be needed
927 }
928
929 // See https://lit.dev/docs/component/lifecycle/
Sean McCullough86b56862025-04-18 13:04:03 -0700930 disconnectedCallback() {
931 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700932
Sean McCulloughe68613d2025-06-18 14:48:53 +0000933 // Cancel any ongoing loading operations before cleanup
934 this.cancelCurrentLoadingOperation();
935
936 // Remove event listeners with guaranteed cleanup
Sean McCullough71941bd2025-04-18 13:31:48 -0700937 document.removeEventListener(
938 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700939 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700940 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700941
banksean54777362025-06-19 16:38:30 +0000942 // Remove DataManager event listener if connected
943 if (this.dataManager) {
944 this.dataManager.removeEventListener(
945 "initialLoadComplete",
946 this.handleInitialLoadComplete,
947 );
948 }
949
Sean McCulloughe68613d2025-06-18 14:48:53 +0000950 // Use our safe cleanup method
951 this.removeScrollListener();
Sean McCullough86b56862025-04-18 13:04:03 -0700952 }
953
Sean McCulloughd9f13372025-04-21 15:08:49 -0700954 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700955 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700956 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700957 // If the message has tool calls, and any of the tool_calls get a response, we need to
958 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700959 const toolCallResponses = message.tool_calls
960 ?.filter((tc) => tc.result_message)
961 .map((tc) => tc.tool_call_id)
962 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700963 return `message-${message.idx}-${toolCallResponses}`;
964 }
965
966 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000967 // Check if messages array is empty and render welcome box if it is
968 if (this.messages.length === 0) {
969 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700970 <div style="position: relative; height: 100%;">
971 <div id="scroll-container">
972 <div class="welcome-box">
973 <h2 class="welcome-box-title">How to use Sketch</h2>
974 <p class="welcome-box-content">
975 Sketch is an agentic coding assistant.
976 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700977
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700978 <p class="welcome-box-content">
979 Sketch has created a container with your repo.
980 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700981
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700982 <p class="welcome-box-content">
983 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000984 below. It can edit and run your code, all in the container.
985 Sketch will create commits in a newly created git branch, which
986 you can look at and comment on in the Diff tab. Once you're
987 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700988 </p>
989 <p class="welcome-box-content">
990 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000991 Sketch in parallel to work on multiple ideas or even the same
992 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700993 </p>
994 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000995 </div>
996 </div>
997 `;
998 }
999
1000 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001001 const isThinking =
1002 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
1003
banksean54777362025-06-19 16:38:30 +00001004 // Apply view-initialized class when initial load is complete
1005 const containerClass = this.isInitialLoadComplete
1006 ? "timeline-container view-initialized"
1007 : "timeline-container";
1008
Sean McCullough86b56862025-04-18 13:04:03 -07001009 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001010 <div style="position: relative; height: 100%;">
1011 <div id="scroll-container">
banksean54777362025-06-19 16:38:30 +00001012 <div class="${containerClass}">
1013 ${!this.isInitialLoadComplete
1014 ? html`
1015 <div class="loading-indicator">
1016 <div class="loading-spinner"></div>
1017 <span>Loading conversation...</span>
1018 </div>
1019 `
1020 : ""}
Sean McCulloughe68613d2025-06-18 14:48:53 +00001021 ${this.isLoadingOlderMessages
1022 ? html`
1023 <div class="loading-indicator">
1024 <div class="loading-spinner"></div>
1025 <span>Loading older messages...</span>
1026 </div>
1027 `
1028 : ""}
banksean54777362025-06-19 16:38:30 +00001029 ${this.isInitialLoadComplete
1030 ? repeat(
1031 this.visibleMessages,
1032 this.messageKey,
1033 (message, index) => {
1034 // Find the previous message in the full filtered messages array
1035 const filteredMessages = this.filteredMessages;
1036 const messageIndex = filteredMessages.findIndex(
1037 (m) => m === message,
1038 );
1039 let previousMessage =
1040 messageIndex > 0
1041 ? filteredMessages[messageIndex - 1]
1042 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +00001043
banksean54777362025-06-19 16:38:30 +00001044 return html`<sketch-timeline-message
1045 .message=${message}
1046 .previousMessage=${previousMessage}
1047 .open=${false}
1048 .firstMessageIndex=${this.firstMessageIndex}
1049 .state=${this.state}
1050 ></sketch-timeline-message>`;
1051 },
1052 )
1053 : ""}
1054 ${isThinking && this.isInitialLoadComplete
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001055 ? html`
1056 <div class="thinking-indicator">
1057 <div class="thinking-bubble">
1058 <div class="thinking-dots">
1059 <div class="dot"></div>
1060 <div class="dot"></div>
1061 <div class="dot"></div>
1062 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001063 </div>
1064 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001065 `
1066 : ""}
1067 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -07001068 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001069 <div
1070 id="jump-to-latest"
1071 class="${this.scrollingState}"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +00001072 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001073 >
Philip Zeyliger61a0f672025-06-21 15:33:18 -07001074 ↓ Jump to bottom
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001075 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -07001076 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001077 `;
1078 }
1079}
1080
1081declare global {
1082 interface HTMLElementTagNameMap {
1083 "sketch-timeline": SketchTimeline;
1084 }
Sean McCullough71941bd2025-04-18 13:31:48 -07001085}