blob: 5dae38a2ecbd92015c594fa2d2c316cc957ddf2e [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
Sean McCulloughe68613d2025-06-18 14:48:53 +000054 // Viewport rendering properties
55 @property({ attribute: false })
56 initialMessageCount: number = 30;
57
58 @property({ attribute: false })
59 loadChunkSize: number = 20;
60
61 @state()
62 private visibleMessageStartIndex: number = 0;
63
64 @state()
65 private isLoadingOlderMessages: boolean = false;
66
67 // Threshold for triggering load more (pixels from top)
68 private loadMoreThreshold: number = 100;
69
70 // Timeout ID for loading operations
71 private loadingTimeoutId: number | null = null;
72
Sean McCullough86b56862025-04-18 13:04:03 -070073 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070074 /* Hide views initially to prevent flash of content */
75 .timeline-container .timeline,
76 .timeline-container .diff-view,
77 .timeline-container .chart-view,
78 .timeline-container .terminal-view {
79 visibility: hidden;
80 }
Sean McCullough86b56862025-04-18 13:04:03 -070081
Sean McCullough71941bd2025-04-18 13:31:48 -070082 /* Will be set by JavaScript once we know which view to display */
83 .timeline-container.view-initialized .timeline,
84 .timeline-container.view-initialized .diff-view,
85 .timeline-container.view-initialized .chart-view,
86 .timeline-container.view-initialized .terminal-view {
87 visibility: visible;
88 }
89
90 .timeline-container {
91 width: 100%;
92 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000093 max-width: 100%;
94 margin: 0 auto;
95 padding: 0 15px;
96 box-sizing: border-box;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070097 overflow-x: hidden;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070098 flex: 1;
Sean McCullough71941bd2025-04-18 13:31:48 -070099 }
100
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000101 /* Chat-like timeline styles */
Sean McCullough71941bd2025-04-18 13:31:48 -0700102 .timeline {
103 position: relative;
104 margin: 10px 0;
105 scroll-behavior: smooth;
106 }
107
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000108 /* Remove the vertical timeline line */
Sean McCullough2c5bba42025-04-20 19:33:17 -0700109
110 #scroll-container {
Philip Zeyligere31d2a92025-05-11 15:22:35 -0700111 overflow-y: auto;
112 overflow-x: hidden;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700113 padding-left: 1em;
Philip Zeyligere31d2a92025-05-11 15:22:35 -0700114 max-width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700115 width: 100%;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000116 height: 100%;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700117 }
118 #jump-to-latest {
119 display: none;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700120 position: absolute;
121 bottom: 20px;
122 right: 20px;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700123 background: rgb(33, 150, 243);
124 color: white;
125 border-radius: 8px;
126 padding: 0.5em;
127 margin: 0.5em;
128 font-size: x-large;
129 opacity: 0.5;
130 cursor: pointer;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700131 z-index: 50;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700132 }
133 #jump-to-latest:hover {
134 opacity: 1;
135 }
136 #jump-to-latest.floating {
137 display: block;
138 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000139
140 /* Welcome box styles for the empty chat state */
141 .welcome-box {
142 margin: 2rem auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700143 max-width: 90%;
144 width: 90%;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000145 padding: 2rem;
146 border: 2px solid #e0e0e0;
147 border-radius: 8px;
148 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
149 background-color: #ffffff;
150 text-align: center;
151 }
152
153 .welcome-box-title {
154 font-size: 1.5rem;
155 font-weight: 600;
156 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700157 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000158 color: #333;
159 }
160
161 .welcome-box-content {
162 color: #666; /* Slightly grey font color */
163 line-height: 1.6;
164 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700165 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000166 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000167
168 /* Thinking indicator styles */
169 .thinking-indicator {
170 padding-left: 85px;
171 margin-top: 5px;
172 margin-bottom: 15px;
173 display: flex;
174 }
175
176 .thinking-bubble {
177 background-color: #f1f1f1;
178 border-radius: 15px;
179 padding: 10px 15px;
180 max-width: 80px;
181 color: black;
182 position: relative;
183 border-bottom-left-radius: 5px;
184 }
185
186 .thinking-dots {
187 display: flex;
188 align-items: center;
189 justify-content: center;
190 gap: 4px;
191 height: 14px;
192 }
193
194 .dot {
195 width: 6px;
196 height: 6px;
197 background-color: #888;
198 border-radius: 50%;
199 opacity: 0.6;
200 }
201
202 .dot:nth-child(1) {
203 animation: pulse 1.5s infinite ease-in-out;
204 }
205
206 .dot:nth-child(2) {
207 animation: pulse 1.5s infinite ease-in-out 0.3s;
208 }
209
210 .dot:nth-child(3) {
211 animation: pulse 1.5s infinite ease-in-out 0.6s;
212 }
213
214 @keyframes pulse {
215 0%,
216 100% {
217 opacity: 0.4;
218 transform: scale(1);
219 }
220 50% {
221 opacity: 1;
222 transform: scale(1.2);
223 }
224 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000225
226 /* Loading indicator styles */
227 .loading-indicator {
228 display: flex;
229 align-items: center;
230 justify-content: center;
231 padding: 20px;
232 color: #666;
233 font-size: 14px;
234 gap: 10px;
235 }
236
237 .loading-spinner {
238 width: 20px;
239 height: 20px;
240 border: 2px solid #e0e0e0;
241 border-top: 2px solid #666;
242 border-radius: 50%;
243 animation: spin 1s linear infinite;
244 }
245
246 @keyframes spin {
247 0% {
248 transform: rotate(0deg);
249 }
250 100% {
251 transform: rotate(360deg);
252 }
253 }
Sean McCullough86b56862025-04-18 13:04:03 -0700254 `;
255
256 constructor() {
257 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700258
Sean McCullough86b56862025-04-18 13:04:03 -0700259 // Binding methods
260 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700261 this._handleScroll = this._handleScroll.bind(this);
262 }
263
264 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000265 * Safely add scroll event listener with proper cleanup tracking
266 */
267 private addScrollListener(container: HTMLElement): void {
268 // Remove any existing listener first
269 this.removeScrollListener();
270
271 // Add new listener and track the container
272 container.addEventListener("scroll", this._handleScroll);
273 this.currentScrollContainer = container;
274 }
275
276 /**
277 * Safely remove scroll event listener
278 */
279 private removeScrollListener(): void {
280 if (this.currentScrollContainer) {
281 this.currentScrollContainer.removeEventListener(
282 "scroll",
283 this._handleScroll,
284 );
285 this.currentScrollContainer = null;
286 }
287
288 // Clear any pending timeouts and operations
289 this.clearAllPendingOperations();
290 }
291
292 /**
293 * Clear all pending operations and observers to prevent race conditions
294 */
295 private clearAllPendingOperations(): void {
296 // Clear scroll debounce frame
297 if (this.scrollDebounceFrame) {
298 cancelAnimationFrame(this.scrollDebounceFrame);
299 this.scrollDebounceFrame = null;
300 }
301
302 // Abort loading operations
303 if (this.loadingAbortController) {
304 this.loadingAbortController.abort();
305 this.loadingAbortController = null;
306 }
307
308 // Cancel pending scroll restoration
309 if (this.pendingScrollRestoration) {
310 this.pendingScrollRestoration = null;
311 }
312
313 // Clean up observers
314 this.disconnectObservers();
315 }
316
317 /**
318 * Disconnect all observers
319 */
320 private disconnectObservers(): void {
321 if (this.resizeObserver) {
322 this.resizeObserver.disconnect();
323 this.resizeObserver = null;
324 }
325
326 if (this.mutationObserver) {
327 this.mutationObserver.disconnect();
328 this.mutationObserver = null;
329 }
330 }
331
332 /**
333 * Force a viewport reset to show the most recent messages
334 * Useful when loading a new session or when messages change significantly
335 */
336 public resetViewport(): void {
337 // Cancel any pending loading operations to prevent race conditions
338 this.cancelCurrentLoadingOperation();
339
340 // Reset viewport state
341 this.visibleMessageStartIndex = 0;
342 this.isLoadingOlderMessages = false;
343
344 // Clear all pending operations
345 this.clearAllPendingOperations();
346
347 this.requestUpdate();
348 }
349
350 /**
351 * Cancel current loading operation if in progress
352 */
353 private cancelCurrentLoadingOperation(): void {
354 if (this.isLoadingOlderMessages) {
355 this.isLoadingOlderMessages = false;
356
357 // Abort the loading operation
358 if (this.loadingAbortController) {
359 this.loadingAbortController.abort();
360 this.loadingAbortController = null;
361 }
362
363 // Cancel pending scroll restoration
364 this.pendingScrollRestoration = null;
365 }
366 }
367
368 /**
369 * Get the filtered messages (excluding hidden ones)
370 */
371 private get filteredMessages(): AgentMessage[] {
372 return this.messages.filter((msg) => !msg.hide_output);
373 }
374
375 /**
376 * Get the currently visible messages based on viewport rendering
377 * Race-condition safe implementation
378 */
379 private get visibleMessages(): AgentMessage[] {
380 const filtered = this.filteredMessages;
381 if (filtered.length === 0) return [];
382
383 // Always show the most recent messages first
384 // visibleMessageStartIndex represents how many additional older messages to show
385 const totalVisible =
386 this.initialMessageCount + this.visibleMessageStartIndex;
387 const startIndex = Math.max(0, filtered.length - totalVisible);
388
389 // Ensure we don't return an invalid slice during loading operations
390 const endIndex = filtered.length;
391 if (startIndex >= endIndex) {
392 return [];
393 }
394
395 return filtered.slice(startIndex, endIndex);
396 }
397
398 /**
399 * Check if the component is in a stable state for loading operations
400 */
401 private isStableForLoading(): boolean {
402 return (
403 this.scrollContainer.value !== null &&
404 this.scrollContainer.value === this.currentScrollContainer &&
405 this.scrollContainer.value.isConnected &&
406 !this.isLoadingOlderMessages &&
407 !this.currentLoadingOperation
408 );
409 }
410
411 /**
412 * Load more older messages by expanding the visible window
413 * Race-condition safe implementation
414 */
415 private async loadOlderMessages(): Promise<void> {
416 // Prevent concurrent loading operations
417 if (this.isLoadingOlderMessages || this.currentLoadingOperation) {
418 return;
419 }
420
421 const filtered = this.filteredMessages;
422 const currentVisibleCount = this.visibleMessages.length;
423 const totalAvailable = filtered.length;
424
425 // Check if there are more messages to load
426 if (currentVisibleCount >= totalAvailable) {
427 return;
428 }
429
430 // Start loading operation with proper state management
431 this.isLoadingOlderMessages = true;
432
433 // Store current scroll position for restoration
434 const container = this.scrollContainer.value;
435 const previousScrollHeight = container?.scrollHeight || 0;
436 const previousScrollTop = container?.scrollTop || 0;
437
438 // Validate scroll container hasn't changed during setup
439 if (!container || container !== this.currentScrollContainer) {
440 this.isLoadingOlderMessages = false;
441 return;
442 }
443
444 // Expand the visible window with bounds checking
445 const additionalMessages = Math.min(
446 this.loadChunkSize,
447 totalAvailable - currentVisibleCount,
448 );
449 const newStartIndex = this.visibleMessageStartIndex + additionalMessages;
450
451 // Ensure we don't exceed available messages
452 const boundedStartIndex = Math.min(
453 newStartIndex,
454 totalAvailable - this.initialMessageCount,
455 );
456 this.visibleMessageStartIndex = Math.max(0, boundedStartIndex);
457
458 // Create the loading operation with proper error handling and cleanup
459 const loadingOperation = this.executeScrollPositionRestoration(
460 container,
461 previousScrollHeight,
462 previousScrollTop,
463 );
464
465 this.currentLoadingOperation = loadingOperation;
466
467 try {
468 await loadingOperation;
469 } catch (error) {
470 console.warn("Loading operation failed:", error);
471 } finally {
472 // Ensure loading state is always cleared
473 this.isLoadingOlderMessages = false;
474 this.currentLoadingOperation = null;
475
476 // Clear the loading timeout if it exists
477 if (this.loadingTimeoutId) {
478 clearTimeout(this.loadingTimeoutId);
479 this.loadingTimeoutId = null;
480 }
481 }
482 }
483
484 /**
485 * Execute scroll position restoration with event-driven approach
486 */
487 private async executeScrollPositionRestoration(
488 container: HTMLElement,
489 previousScrollHeight: number,
490 previousScrollTop: number,
491 ): Promise<void> {
492 // Set up AbortController for proper cancellation
493 this.loadingAbortController = new AbortController();
494 const { signal } = this.loadingAbortController;
495
496 // Create scroll restoration function
497 const restoreScrollPosition = () => {
498 // Check if operation was aborted
499 if (signal.aborted) {
500 return;
501 }
502
503 // Double-check container is still valid and connected
504 if (
505 !container ||
506 !container.isConnected ||
507 container !== this.currentScrollContainer
508 ) {
509 return;
510 }
511
512 try {
513 const newScrollHeight = container.scrollHeight;
514 const scrollDifference = newScrollHeight - previousScrollHeight;
515 const newScrollTop = previousScrollTop + scrollDifference;
516
517 // Validate all scroll calculations before applying
518 const isValidRestoration =
519 scrollDifference > 0 && // Content was added
520 newScrollTop >= 0 && // New position is valid
521 newScrollTop <= newScrollHeight && // Don't exceed max scroll
522 previousScrollHeight > 0 && // Had valid previous height
523 newScrollHeight > previousScrollHeight; // Height actually increased
524
525 if (isValidRestoration) {
526 container.scrollTop = newScrollTop;
527 } else {
528 // Log invalid restoration attempts for debugging
529 console.debug("Skipped scroll restoration:", {
530 scrollDifference,
531 newScrollTop,
532 newScrollHeight,
533 previousScrollHeight,
534 previousScrollTop,
535 });
536 }
537 } catch (error) {
538 console.warn("Scroll position restoration failed:", error);
539 }
540 };
541
542 // Store the restoration function for potential cancellation
543 this.pendingScrollRestoration = restoreScrollPosition;
544
545 // Wait for DOM update and then restore scroll position
546 await this.updateComplete;
547
548 // Check if operation was cancelled during await
549 if (
550 !signal.aborted &&
551 this.pendingScrollRestoration === restoreScrollPosition
552 ) {
553 // Use ResizeObserver to detect when content is actually ready
554 await this.waitForContentReady(container, signal);
555
556 if (!signal.aborted) {
557 restoreScrollPosition();
558 this.pendingScrollRestoration = null;
559 }
560 }
561 }
562
563 /**
564 * Wait for content to be ready using ResizeObserver instead of setTimeout
565 */
566 private async waitForContentReady(
567 container: HTMLElement,
568 signal: AbortSignal,
569 ): Promise<void> {
570 return new Promise((resolve, reject) => {
571 if (signal.aborted) {
572 reject(new Error("Operation aborted"));
573 return;
574 }
575
576 // Resolve immediately if container already has content
577 if (container.scrollHeight > 0) {
578 resolve();
579 return;
580 }
581
582 // Set up ResizeObserver to detect content changes
583 const observer = new ResizeObserver((entries) => {
584 if (signal.aborted) {
585 observer.disconnect();
586 reject(new Error("Operation aborted"));
587 return;
588 }
589
590 // Content is ready when height increases
591 const entry = entries[0];
592 if (entry && entry.contentRect.height > 0) {
593 observer.disconnect();
594 resolve();
595 }
596 });
597
598 // Start observing
599 observer.observe(container);
600
601 // Clean up on abort
602 signal.addEventListener("abort", () => {
603 observer.disconnect();
604 reject(new Error("Operation aborted"));
605 });
606 });
607 }
608
609 /**
Sean McCullough2c5bba42025-04-20 19:33:17 -0700610 * Scroll to the bottom of the timeline
611 */
612 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000613 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000614
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000615 // Use instant scroll to ensure we reach the exact bottom
616 this.scrollContainer.value.scrollTo({
617 top: this.scrollContainer.value.scrollHeight,
618 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700619 });
620 }
Autoformatter71c73b52025-05-29 20:18:43 +0000621
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000622 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000623 * Scroll to bottom with event-driven approach using MutationObserver
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000624 */
Sean McCulloughe68613d2025-06-18 14:48:53 +0000625 private async scrollToBottomWithRetry(): Promise<void> {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000626 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000627
Sean McCulloughe68613d2025-06-18 14:48:53 +0000628 const container = this.scrollContainer.value;
Autoformatter71c73b52025-05-29 20:18:43 +0000629
Sean McCulloughe68613d2025-06-18 14:48:53 +0000630 // Try immediate scroll first
631 this.scrollToBottom();
Autoformatter71c73b52025-05-29 20:18:43 +0000632
Sean McCulloughe68613d2025-06-18 14:48:53 +0000633 // Check if we're at the bottom
634 const isAtBottom = () => {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000635 const targetScrollTop = container.scrollHeight - container.clientHeight;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000636 const actualScrollTop = container.scrollTop;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000637 return Math.abs(targetScrollTop - actualScrollTop) <= 1;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000638 };
Autoformatter71c73b52025-05-29 20:18:43 +0000639
Sean McCulloughe68613d2025-06-18 14:48:53 +0000640 // If already at bottom, we're done
641 if (isAtBottom()) {
642 return;
643 }
644
645 // Use MutationObserver to detect content changes and retry
646 return new Promise((resolve) => {
647 let scrollAttempted = false;
648
649 const observer = new MutationObserver(() => {
650 if (!scrollAttempted) {
651 scrollAttempted = true;
652
653 // Use requestAnimationFrame to ensure DOM is painted
654 requestAnimationFrame(() => {
655 this.scrollToBottom();
656
657 // Check if successful
658 if (isAtBottom()) {
659 observer.disconnect();
660 resolve();
661 } else {
662 // Try one more time after another frame
663 requestAnimationFrame(() => {
664 this.scrollToBottom();
665 observer.disconnect();
666 resolve();
667 });
668 }
669 });
670 }
671 });
672
673 // Observe changes to the timeline container
674 observer.observe(container, {
675 childList: true,
676 subtree: true,
677 attributes: false,
678 });
679
680 // Clean up after a reasonable time if no changes detected
681 requestAnimationFrame(() => {
682 requestAnimationFrame(() => {
683 if (!scrollAttempted) {
684 observer.disconnect();
685 resolve();
686 }
687 });
688 });
689 });
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000690 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700691
692 /**
693 * Called after the component's properties have been updated
694 */
695 updated(changedProperties: PropertyValues): void {
Sean McCulloughe68613d2025-06-18 14:48:53 +0000696 // Handle scroll container changes first to prevent race conditions
697 if (changedProperties.has("scrollContainer")) {
698 // Cancel any ongoing loading operations since container is changing
699 this.cancelCurrentLoadingOperation();
700
701 if (this.scrollContainer.value) {
702 this.addScrollListener(this.scrollContainer.value);
703 } else {
704 this.removeScrollListener();
Sean McCullough2c5bba42025-04-20 19:33:17 -0700705 }
706 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000707
708 // If messages have changed, handle viewport updates
709 if (changedProperties.has("messages")) {
710 const oldMessages =
711 (changedProperties.get("messages") as AgentMessage[]) || [];
712 const newMessages = this.messages || [];
713
714 // Cancel loading operations if messages changed significantly
715 const significantChange =
716 oldMessages.length === 0 ||
717 newMessages.length < oldMessages.length ||
718 Math.abs(newMessages.length - oldMessages.length) > 20;
719
720 if (significantChange) {
721 // Cancel any ongoing operations and reset viewport
722 this.cancelCurrentLoadingOperation();
723 this.visibleMessageStartIndex = 0;
724 }
725
726 // Scroll to bottom if needed (only if not loading to prevent race conditions)
727 if (
728 this.messages.length > 0 &&
729 this.scrollingState === "pinToLatest" &&
730 !this.isLoadingOlderMessages
731 ) {
732 // Use async scroll without setTimeout
733 this.scrollToBottomWithRetry().catch((error) => {
734 console.warn("Scroll to bottom failed:", error);
735 });
736 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700737 }
Sean McCullough86b56862025-04-18 13:04:03 -0700738 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700739
Sean McCullough86b56862025-04-18 13:04:03 -0700740 /**
741 * Handle showCommitDiff event
742 */
743 private _handleShowCommitDiff(event: CustomEvent) {
744 const { commitHash } = event.detail;
745 if (commitHash) {
746 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700747 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700748 detail: { commitHash },
749 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700750 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700751 });
752 this.dispatchEvent(newEvent);
753 }
754 }
755
Sean McCullough2c5bba42025-04-20 19:33:17 -0700756 private _handleScroll(event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000757 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000758
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000759 const container = this.scrollContainer.value;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000760
761 // Verify this is still our tracked container to prevent race conditions
762 if (container !== this.currentScrollContainer) {
763 return;
764 }
765
Sean McCullough2c5bba42025-04-20 19:33:17 -0700766 const isAtBottom =
767 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000768 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000769 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000770
Sean McCulloughe68613d2025-06-18 14:48:53 +0000771 const isNearTop = container.scrollTop <= this.loadMoreThreshold;
772
773 // Update scroll state immediately for responsive UI
Sean McCullough2c5bba42025-04-20 19:33:17 -0700774 if (isAtBottom) {
775 this.scrollingState = "pinToLatest";
776 } else {
Sean McCullough2c5bba42025-04-20 19:33:17 -0700777 this.scrollingState = "floating";
778 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000779
780 // Use requestAnimationFrame for smooth debouncing instead of setTimeout
781 if (this.scrollDebounceFrame) {
782 cancelAnimationFrame(this.scrollDebounceFrame);
783 }
784
785 this.scrollDebounceFrame = requestAnimationFrame(() => {
786 // Use stability check to ensure safe loading conditions
787 if (isNearTop && this.isStableForLoading()) {
788 this.loadOlderMessages().catch((error) => {
789 console.warn("Async loadOlderMessages failed:", error);
790 });
791 }
792 this.scrollDebounceFrame = null;
793 });
Sean McCullough2c5bba42025-04-20 19:33:17 -0700794 }
795
Sean McCullough86b56862025-04-18 13:04:03 -0700796 // See https://lit.dev/docs/components/lifecycle/
797 connectedCallback() {
798 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700799
Sean McCullough86b56862025-04-18 13:04:03 -0700800 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700801 document.addEventListener(
802 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700803 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700804 );
Pokey Rule4097e532025-04-24 18:55:28 +0100805
Sean McCulloughe68613d2025-06-18 14:48:53 +0000806 // Set up scroll listener if container is available
807 if (this.scrollContainer.value) {
808 this.addScrollListener(this.scrollContainer.value);
809 }
810
811 // Initialize observers for event-driven behavior
812 this.setupObservers();
Sean McCullough86b56862025-04-18 13:04:03 -0700813 }
814
Sean McCulloughe68613d2025-06-18 14:48:53 +0000815 /**
816 * Set up observers for event-driven DOM monitoring
817 */
818 private setupObservers(): void {
819 // ResizeObserver will be created on-demand in loading operations
820 // MutationObserver will be created on-demand in scroll operations
821 // This avoids creating observers that may not be needed
822 }
823
824 // See https://lit.dev/docs/component/lifecycle/
Sean McCullough86b56862025-04-18 13:04:03 -0700825 disconnectedCallback() {
826 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700827
Sean McCulloughe68613d2025-06-18 14:48:53 +0000828 // Cancel any ongoing loading operations before cleanup
829 this.cancelCurrentLoadingOperation();
830
831 // Remove event listeners with guaranteed cleanup
Sean McCullough71941bd2025-04-18 13:31:48 -0700832 document.removeEventListener(
833 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700834 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700835 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700836
Sean McCulloughe68613d2025-06-18 14:48:53 +0000837 // Use our safe cleanup method
838 this.removeScrollListener();
Sean McCullough86b56862025-04-18 13:04:03 -0700839 }
840
Sean McCulloughd9f13372025-04-21 15:08:49 -0700841 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700842 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700843 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700844 // If the message has tool calls, and any of the tool_calls get a response, we need to
845 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700846 const toolCallResponses = message.tool_calls
847 ?.filter((tc) => tc.result_message)
848 .map((tc) => tc.tool_call_id)
849 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700850 return `message-${message.idx}-${toolCallResponses}`;
851 }
852
853 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000854 // Check if messages array is empty and render welcome box if it is
855 if (this.messages.length === 0) {
856 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700857 <div style="position: relative; height: 100%;">
858 <div id="scroll-container">
859 <div class="welcome-box">
860 <h2 class="welcome-box-title">How to use Sketch</h2>
861 <p class="welcome-box-content">
862 Sketch is an agentic coding assistant.
863 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700864
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700865 <p class="welcome-box-content">
866 Sketch has created a container with your repo.
867 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700868
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700869 <p class="welcome-box-content">
870 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000871 below. It can edit and run your code, all in the container.
872 Sketch will create commits in a newly created git branch, which
873 you can look at and comment on in the Diff tab. Once you're
874 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700875 </p>
876 <p class="welcome-box-content">
877 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000878 Sketch in parallel to work on multiple ideas or even the same
879 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700880 </p>
881 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000882 </div>
883 </div>
884 `;
885 }
886
887 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000888 const isThinking =
889 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
890
Sean McCullough86b56862025-04-18 13:04:03 -0700891 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700892 <div style="position: relative; height: 100%;">
893 <div id="scroll-container">
894 <div class="timeline-container">
Sean McCulloughe68613d2025-06-18 14:48:53 +0000895 ${this.isLoadingOlderMessages
896 ? html`
897 <div class="loading-indicator">
898 <div class="loading-spinner"></div>
899 <span>Loading older messages...</span>
900 </div>
901 `
902 : ""}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700903 ${repeat(
Sean McCulloughe68613d2025-06-18 14:48:53 +0000904 this.visibleMessages,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700905 this.messageKey,
906 (message, index) => {
Sean McCulloughe68613d2025-06-18 14:48:53 +0000907 // Find the previous message in the full filtered messages array
908 const filteredMessages = this.filteredMessages;
909 const messageIndex = filteredMessages.findIndex(
910 (m) => m === message,
911 );
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700912 let previousMessage =
Sean McCulloughe68613d2025-06-18 14:48:53 +0000913 messageIndex > 0
914 ? filteredMessages[messageIndex - 1]
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000915 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000916
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700917 return html`<sketch-timeline-message
918 .message=${message}
919 .previousMessage=${previousMessage}
920 .open=${false}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700921 .firstMessageIndex=${this.firstMessageIndex}
philip.zeyliger6d3de482025-06-10 19:38:14 -0700922 .state=${this.state}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700923 ></sketch-timeline-message>`;
924 },
925 )}
926 ${isThinking
927 ? html`
928 <div class="thinking-indicator">
929 <div class="thinking-bubble">
930 <div class="thinking-dots">
931 <div class="dot"></div>
932 <div class="dot"></div>
933 <div class="dot"></div>
934 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000935 </div>
936 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700937 `
938 : ""}
939 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -0700940 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700941 <div
942 id="jump-to-latest"
943 class="${this.scrollingState}"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000944 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700945 >
946
947 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -0700948 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700949 `;
950 }
951}
952
953declare global {
954 interface HTMLElementTagNameMap {
955 "sketch-timeline": SketchTimeline;
956 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700957}