blob: 2f8e5e359264baebb1b3d29f25d8245c46a1178b [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 `;
263
264 constructor() {
265 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700266
Sean McCullough86b56862025-04-18 13:04:03 -0700267 // Binding methods
268 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700269 this._handleScroll = this._handleScroll.bind(this);
270 }
271
272 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000273 * Safely add scroll event listener with proper cleanup tracking
274 */
275 private addScrollListener(container: HTMLElement): void {
276 // Remove any existing listener first
277 this.removeScrollListener();
278
279 // Add new listener and track the container
280 container.addEventListener("scroll", this._handleScroll);
281 this.currentScrollContainer = container;
282 }
283
284 /**
285 * Safely remove scroll event listener
286 */
287 private removeScrollListener(): void {
288 if (this.currentScrollContainer) {
289 this.currentScrollContainer.removeEventListener(
290 "scroll",
291 this._handleScroll,
292 );
293 this.currentScrollContainer = null;
294 }
295
296 // Clear any pending timeouts and operations
297 this.clearAllPendingOperations();
298 }
299
300 /**
301 * Clear all pending operations and observers to prevent race conditions
302 */
303 private clearAllPendingOperations(): void {
304 // Clear scroll debounce frame
305 if (this.scrollDebounceFrame) {
306 cancelAnimationFrame(this.scrollDebounceFrame);
307 this.scrollDebounceFrame = null;
308 }
309
310 // Abort loading operations
311 if (this.loadingAbortController) {
312 this.loadingAbortController.abort();
313 this.loadingAbortController = null;
314 }
315
316 // Cancel pending scroll restoration
317 if (this.pendingScrollRestoration) {
318 this.pendingScrollRestoration = null;
319 }
320
321 // Clean up observers
322 this.disconnectObservers();
323 }
324
325 /**
326 * Disconnect all observers
327 */
328 private disconnectObservers(): void {
329 if (this.resizeObserver) {
330 this.resizeObserver.disconnect();
331 this.resizeObserver = null;
332 }
333
334 if (this.mutationObserver) {
335 this.mutationObserver.disconnect();
336 this.mutationObserver = null;
337 }
338 }
339
340 /**
341 * Force a viewport reset to show the most recent messages
342 * Useful when loading a new session or when messages change significantly
343 */
344 public resetViewport(): void {
345 // Cancel any pending loading operations to prevent race conditions
346 this.cancelCurrentLoadingOperation();
347
348 // Reset viewport state
349 this.visibleMessageStartIndex = 0;
350 this.isLoadingOlderMessages = false;
351
352 // Clear all pending operations
353 this.clearAllPendingOperations();
354
355 this.requestUpdate();
356 }
357
358 /**
359 * Cancel current loading operation if in progress
360 */
361 private cancelCurrentLoadingOperation(): void {
362 if (this.isLoadingOlderMessages) {
363 this.isLoadingOlderMessages = false;
364
365 // Abort the loading operation
366 if (this.loadingAbortController) {
367 this.loadingAbortController.abort();
368 this.loadingAbortController = null;
369 }
370
371 // Cancel pending scroll restoration
372 this.pendingScrollRestoration = null;
373 }
374 }
375
376 /**
377 * Get the filtered messages (excluding hidden ones)
378 */
379 private get filteredMessages(): AgentMessage[] {
380 return this.messages.filter((msg) => !msg.hide_output);
381 }
382
383 /**
384 * Get the currently visible messages based on viewport rendering
385 * Race-condition safe implementation
386 */
387 private get visibleMessages(): AgentMessage[] {
388 const filtered = this.filteredMessages;
389 if (filtered.length === 0) return [];
390
391 // Always show the most recent messages first
392 // visibleMessageStartIndex represents how many additional older messages to show
393 const totalVisible =
394 this.initialMessageCount + this.visibleMessageStartIndex;
395 const startIndex = Math.max(0, filtered.length - totalVisible);
396
397 // Ensure we don't return an invalid slice during loading operations
398 const endIndex = filtered.length;
399 if (startIndex >= endIndex) {
400 return [];
401 }
402
403 return filtered.slice(startIndex, endIndex);
404 }
405
406 /**
407 * Check if the component is in a stable state for loading operations
408 */
409 private isStableForLoading(): boolean {
410 return (
411 this.scrollContainer.value !== null &&
412 this.scrollContainer.value === this.currentScrollContainer &&
413 this.scrollContainer.value.isConnected &&
414 !this.isLoadingOlderMessages &&
415 !this.currentLoadingOperation
416 );
417 }
418
419 /**
420 * Load more older messages by expanding the visible window
421 * Race-condition safe implementation
422 */
423 private async loadOlderMessages(): Promise<void> {
424 // Prevent concurrent loading operations
425 if (this.isLoadingOlderMessages || this.currentLoadingOperation) {
426 return;
427 }
428
429 const filtered = this.filteredMessages;
430 const currentVisibleCount = this.visibleMessages.length;
431 const totalAvailable = filtered.length;
432
433 // Check if there are more messages to load
434 if (currentVisibleCount >= totalAvailable) {
435 return;
436 }
437
438 // Start loading operation with proper state management
439 this.isLoadingOlderMessages = true;
440
441 // Store current scroll position for restoration
442 const container = this.scrollContainer.value;
443 const previousScrollHeight = container?.scrollHeight || 0;
444 const previousScrollTop = container?.scrollTop || 0;
445
446 // Validate scroll container hasn't changed during setup
447 if (!container || container !== this.currentScrollContainer) {
448 this.isLoadingOlderMessages = false;
449 return;
450 }
451
452 // Expand the visible window with bounds checking
453 const additionalMessages = Math.min(
454 this.loadChunkSize,
455 totalAvailable - currentVisibleCount,
456 );
457 const newStartIndex = this.visibleMessageStartIndex + additionalMessages;
458
459 // Ensure we don't exceed available messages
460 const boundedStartIndex = Math.min(
461 newStartIndex,
462 totalAvailable - this.initialMessageCount,
463 );
464 this.visibleMessageStartIndex = Math.max(0, boundedStartIndex);
465
466 // Create the loading operation with proper error handling and cleanup
467 const loadingOperation = this.executeScrollPositionRestoration(
468 container,
469 previousScrollHeight,
470 previousScrollTop,
471 );
472
473 this.currentLoadingOperation = loadingOperation;
474
475 try {
476 await loadingOperation;
477 } catch (error) {
478 console.warn("Loading operation failed:", error);
479 } finally {
480 // Ensure loading state is always cleared
481 this.isLoadingOlderMessages = false;
482 this.currentLoadingOperation = null;
483
484 // Clear the loading timeout if it exists
485 if (this.loadingTimeoutId) {
486 clearTimeout(this.loadingTimeoutId);
487 this.loadingTimeoutId = null;
488 }
489 }
490 }
491
492 /**
493 * Execute scroll position restoration with event-driven approach
494 */
495 private async executeScrollPositionRestoration(
496 container: HTMLElement,
497 previousScrollHeight: number,
498 previousScrollTop: number,
499 ): Promise<void> {
500 // Set up AbortController for proper cancellation
501 this.loadingAbortController = new AbortController();
502 const { signal } = this.loadingAbortController;
503
504 // Create scroll restoration function
505 const restoreScrollPosition = () => {
506 // Check if operation was aborted
507 if (signal.aborted) {
508 return;
509 }
510
511 // Double-check container is still valid and connected
512 if (
513 !container ||
514 !container.isConnected ||
515 container !== this.currentScrollContainer
516 ) {
517 return;
518 }
519
520 try {
521 const newScrollHeight = container.scrollHeight;
522 const scrollDifference = newScrollHeight - previousScrollHeight;
523 const newScrollTop = previousScrollTop + scrollDifference;
524
525 // Validate all scroll calculations before applying
526 const isValidRestoration =
527 scrollDifference > 0 && // Content was added
528 newScrollTop >= 0 && // New position is valid
529 newScrollTop <= newScrollHeight && // Don't exceed max scroll
530 previousScrollHeight > 0 && // Had valid previous height
531 newScrollHeight > previousScrollHeight; // Height actually increased
532
533 if (isValidRestoration) {
534 container.scrollTop = newScrollTop;
535 } else {
536 // Log invalid restoration attempts for debugging
537 console.debug("Skipped scroll restoration:", {
538 scrollDifference,
539 newScrollTop,
540 newScrollHeight,
541 previousScrollHeight,
542 previousScrollTop,
543 });
544 }
545 } catch (error) {
546 console.warn("Scroll position restoration failed:", error);
547 }
548 };
549
550 // Store the restoration function for potential cancellation
551 this.pendingScrollRestoration = restoreScrollPosition;
552
553 // Wait for DOM update and then restore scroll position
554 await this.updateComplete;
555
556 // Check if operation was cancelled during await
557 if (
558 !signal.aborted &&
559 this.pendingScrollRestoration === restoreScrollPosition
560 ) {
561 // Use ResizeObserver to detect when content is actually ready
562 await this.waitForContentReady(container, signal);
563
564 if (!signal.aborted) {
565 restoreScrollPosition();
566 this.pendingScrollRestoration = null;
567 }
568 }
569 }
570
571 /**
572 * Wait for content to be ready using ResizeObserver instead of setTimeout
573 */
574 private async waitForContentReady(
575 container: HTMLElement,
576 signal: AbortSignal,
577 ): Promise<void> {
578 return new Promise((resolve, reject) => {
579 if (signal.aborted) {
580 reject(new Error("Operation aborted"));
581 return;
582 }
583
584 // Resolve immediately if container already has content
585 if (container.scrollHeight > 0) {
586 resolve();
587 return;
588 }
589
590 // Set up ResizeObserver to detect content changes
591 const observer = new ResizeObserver((entries) => {
592 if (signal.aborted) {
593 observer.disconnect();
594 reject(new Error("Operation aborted"));
595 return;
596 }
597
598 // Content is ready when height increases
599 const entry = entries[0];
600 if (entry && entry.contentRect.height > 0) {
601 observer.disconnect();
602 resolve();
603 }
604 });
605
606 // Start observing
607 observer.observe(container);
608
609 // Clean up on abort
610 signal.addEventListener("abort", () => {
611 observer.disconnect();
612 reject(new Error("Operation aborted"));
613 });
614 });
615 }
616
617 /**
Sean McCullough2c5bba42025-04-20 19:33:17 -0700618 * Scroll to the bottom of the timeline
619 */
620 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000621 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000622
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000623 // Use instant scroll to ensure we reach the exact bottom
624 this.scrollContainer.value.scrollTo({
625 top: this.scrollContainer.value.scrollHeight,
626 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700627 });
628 }
Autoformatter71c73b52025-05-29 20:18:43 +0000629
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000630 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000631 * Scroll to bottom with event-driven approach using MutationObserver
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000632 */
Sean McCulloughe68613d2025-06-18 14:48:53 +0000633 private async scrollToBottomWithRetry(): Promise<void> {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000634 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000635
Sean McCulloughe68613d2025-06-18 14:48:53 +0000636 const container = this.scrollContainer.value;
Autoformatter71c73b52025-05-29 20:18:43 +0000637
Sean McCulloughe68613d2025-06-18 14:48:53 +0000638 // Try immediate scroll first
639 this.scrollToBottom();
Autoformatter71c73b52025-05-29 20:18:43 +0000640
Sean McCulloughe68613d2025-06-18 14:48:53 +0000641 // Check if we're at the bottom
642 const isAtBottom = () => {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000643 const targetScrollTop = container.scrollHeight - container.clientHeight;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000644 const actualScrollTop = container.scrollTop;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000645 return Math.abs(targetScrollTop - actualScrollTop) <= 1;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000646 };
Autoformatter71c73b52025-05-29 20:18:43 +0000647
Sean McCulloughe68613d2025-06-18 14:48:53 +0000648 // If already at bottom, we're done
649 if (isAtBottom()) {
650 return;
651 }
652
653 // Use MutationObserver to detect content changes and retry
654 return new Promise((resolve) => {
655 let scrollAttempted = false;
656
657 const observer = new MutationObserver(() => {
658 if (!scrollAttempted) {
659 scrollAttempted = true;
660
661 // Use requestAnimationFrame to ensure DOM is painted
662 requestAnimationFrame(() => {
663 this.scrollToBottom();
664
665 // Check if successful
666 if (isAtBottom()) {
667 observer.disconnect();
668 resolve();
669 } else {
670 // Try one more time after another frame
671 requestAnimationFrame(() => {
672 this.scrollToBottom();
673 observer.disconnect();
674 resolve();
675 });
676 }
677 });
678 }
679 });
680
681 // Observe changes to the timeline container
682 observer.observe(container, {
683 childList: true,
684 subtree: true,
685 attributes: false,
686 });
687
688 // Clean up after a reasonable time if no changes detected
689 requestAnimationFrame(() => {
690 requestAnimationFrame(() => {
691 if (!scrollAttempted) {
692 observer.disconnect();
693 resolve();
694 }
695 });
696 });
697 });
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000698 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700699
700 /**
701 * Called after the component's properties have been updated
702 */
703 updated(changedProperties: PropertyValues): void {
banksean54777362025-06-19 16:38:30 +0000704 // Handle DataManager changes to set up event listeners
705 if (changedProperties.has("dataManager")) {
706 const oldDataManager = changedProperties.get("dataManager");
707
708 // Remove old event listener if it exists
709 if (oldDataManager) {
710 oldDataManager.removeEventListener(
711 "initialLoadComplete",
712 this.handleInitialLoadComplete,
713 );
714 }
715
716 // Add new event listener if dataManager is available
717 if (this.dataManager) {
718 this.dataManager.addEventListener(
719 "initialLoadComplete",
720 this.handleInitialLoadComplete,
721 );
722
723 // Check if initial load is already complete
724 if (
725 this.dataManager.getIsInitialLoadComplete &&
726 this.dataManager.getIsInitialLoadComplete()
727 ) {
728 this.isInitialLoadComplete = true;
729 }
730 }
731 }
732
Sean McCulloughe68613d2025-06-18 14:48:53 +0000733 // Handle scroll container changes first to prevent race conditions
734 if (changedProperties.has("scrollContainer")) {
735 // Cancel any ongoing loading operations since container is changing
736 this.cancelCurrentLoadingOperation();
737
738 if (this.scrollContainer.value) {
739 this.addScrollListener(this.scrollContainer.value);
740 } else {
741 this.removeScrollListener();
Sean McCullough2c5bba42025-04-20 19:33:17 -0700742 }
743 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000744
745 // If messages have changed, handle viewport updates
746 if (changedProperties.has("messages")) {
747 const oldMessages =
748 (changedProperties.get("messages") as AgentMessage[]) || [];
749 const newMessages = this.messages || [];
750
751 // Cancel loading operations if messages changed significantly
752 const significantChange =
753 oldMessages.length === 0 ||
754 newMessages.length < oldMessages.length ||
755 Math.abs(newMessages.length - oldMessages.length) > 20;
756
757 if (significantChange) {
758 // Cancel any ongoing operations and reset viewport
759 this.cancelCurrentLoadingOperation();
760 this.visibleMessageStartIndex = 0;
761 }
762
763 // Scroll to bottom if needed (only if not loading to prevent race conditions)
764 if (
765 this.messages.length > 0 &&
766 this.scrollingState === "pinToLatest" &&
767 !this.isLoadingOlderMessages
768 ) {
769 // Use async scroll without setTimeout
770 this.scrollToBottomWithRetry().catch((error) => {
771 console.warn("Scroll to bottom failed:", error);
772 });
773 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700774 }
Sean McCullough86b56862025-04-18 13:04:03 -0700775 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700776
Sean McCullough86b56862025-04-18 13:04:03 -0700777 /**
778 * Handle showCommitDiff event
779 */
780 private _handleShowCommitDiff(event: CustomEvent) {
781 const { commitHash } = event.detail;
782 if (commitHash) {
783 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700784 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700785 detail: { commitHash },
786 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700787 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700788 });
789 this.dispatchEvent(newEvent);
790 }
791 }
792
Sean McCullough2c5bba42025-04-20 19:33:17 -0700793 private _handleScroll(event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000794 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000795
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000796 const container = this.scrollContainer.value;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000797
798 // Verify this is still our tracked container to prevent race conditions
799 if (container !== this.currentScrollContainer) {
800 return;
801 }
802
Sean McCullough2c5bba42025-04-20 19:33:17 -0700803 const isAtBottom =
804 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000805 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000806 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000807
Sean McCulloughe68613d2025-06-18 14:48:53 +0000808 const isNearTop = container.scrollTop <= this.loadMoreThreshold;
809
810 // Update scroll state immediately for responsive UI
Sean McCullough2c5bba42025-04-20 19:33:17 -0700811 if (isAtBottom) {
812 this.scrollingState = "pinToLatest";
813 } else {
Sean McCullough2c5bba42025-04-20 19:33:17 -0700814 this.scrollingState = "floating";
815 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000816
817 // Use requestAnimationFrame for smooth debouncing instead of setTimeout
818 if (this.scrollDebounceFrame) {
819 cancelAnimationFrame(this.scrollDebounceFrame);
820 }
821
822 this.scrollDebounceFrame = requestAnimationFrame(() => {
823 // Use stability check to ensure safe loading conditions
824 if (isNearTop && this.isStableForLoading()) {
825 this.loadOlderMessages().catch((error) => {
826 console.warn("Async loadOlderMessages failed:", error);
827 });
828 }
829 this.scrollDebounceFrame = null;
830 });
Sean McCullough2c5bba42025-04-20 19:33:17 -0700831 }
832
Sean McCullough86b56862025-04-18 13:04:03 -0700833 // See https://lit.dev/docs/components/lifecycle/
834 connectedCallback() {
835 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700836
Sean McCullough86b56862025-04-18 13:04:03 -0700837 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700838 document.addEventListener(
839 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700840 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700841 );
Pokey Rule4097e532025-04-24 18:55:28 +0100842
Sean McCulloughe68613d2025-06-18 14:48:53 +0000843 // Set up scroll listener if container is available
844 if (this.scrollContainer.value) {
845 this.addScrollListener(this.scrollContainer.value);
846 }
847
848 // Initialize observers for event-driven behavior
849 this.setupObservers();
Sean McCullough86b56862025-04-18 13:04:03 -0700850 }
851
Sean McCulloughe68613d2025-06-18 14:48:53 +0000852 /**
banksean54777362025-06-19 16:38:30 +0000853 * Handle initial load completion from DataManager
854 */
855 private handleInitialLoadComplete = (eventData: {
856 messageCount: number;
857 expectedCount: number;
858 }): void => {
859 console.log(
860 `Timeline: Initial load complete - ${eventData.messageCount}/${eventData.expectedCount} messages`,
861 );
862 this.isInitialLoadComplete = true;
863 this.requestUpdate();
864 };
865
866 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000867 * Set up observers for event-driven DOM monitoring
868 */
869 private setupObservers(): void {
870 // ResizeObserver will be created on-demand in loading operations
871 // MutationObserver will be created on-demand in scroll operations
872 // This avoids creating observers that may not be needed
873 }
874
875 // See https://lit.dev/docs/component/lifecycle/
Sean McCullough86b56862025-04-18 13:04:03 -0700876 disconnectedCallback() {
877 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700878
Sean McCulloughe68613d2025-06-18 14:48:53 +0000879 // Cancel any ongoing loading operations before cleanup
880 this.cancelCurrentLoadingOperation();
881
882 // Remove event listeners with guaranteed cleanup
Sean McCullough71941bd2025-04-18 13:31:48 -0700883 document.removeEventListener(
884 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700885 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700886 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700887
banksean54777362025-06-19 16:38:30 +0000888 // Remove DataManager event listener if connected
889 if (this.dataManager) {
890 this.dataManager.removeEventListener(
891 "initialLoadComplete",
892 this.handleInitialLoadComplete,
893 );
894 }
895
Sean McCulloughe68613d2025-06-18 14:48:53 +0000896 // Use our safe cleanup method
897 this.removeScrollListener();
Sean McCullough86b56862025-04-18 13:04:03 -0700898 }
899
Sean McCulloughd9f13372025-04-21 15:08:49 -0700900 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700901 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700902 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700903 // If the message has tool calls, and any of the tool_calls get a response, we need to
904 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700905 const toolCallResponses = message.tool_calls
906 ?.filter((tc) => tc.result_message)
907 .map((tc) => tc.tool_call_id)
908 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700909 return `message-${message.idx}-${toolCallResponses}`;
910 }
911
912 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000913 // Check if messages array is empty and render welcome box if it is
914 if (this.messages.length === 0) {
915 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700916 <div style="position: relative; height: 100%;">
917 <div id="scroll-container">
918 <div class="welcome-box">
919 <h2 class="welcome-box-title">How to use Sketch</h2>
920 <p class="welcome-box-content">
921 Sketch is an agentic coding assistant.
922 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700923
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700924 <p class="welcome-box-content">
925 Sketch has created a container with your repo.
926 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700927
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700928 <p class="welcome-box-content">
929 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000930 below. It can edit and run your code, all in the container.
931 Sketch will create commits in a newly created git branch, which
932 you can look at and comment on in the Diff tab. Once you're
933 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700934 </p>
935 <p class="welcome-box-content">
936 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000937 Sketch in parallel to work on multiple ideas or even the same
938 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700939 </p>
940 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000941 </div>
942 </div>
943 `;
944 }
945
946 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000947 const isThinking =
948 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
949
banksean54777362025-06-19 16:38:30 +0000950 // Apply view-initialized class when initial load is complete
951 const containerClass = this.isInitialLoadComplete
952 ? "timeline-container view-initialized"
953 : "timeline-container";
954
Sean McCullough86b56862025-04-18 13:04:03 -0700955 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700956 <div style="position: relative; height: 100%;">
957 <div id="scroll-container">
banksean54777362025-06-19 16:38:30 +0000958 <div class="${containerClass}">
959 ${!this.isInitialLoadComplete
960 ? html`
961 <div class="loading-indicator">
962 <div class="loading-spinner"></div>
963 <span>Loading conversation...</span>
964 </div>
965 `
966 : ""}
Sean McCulloughe68613d2025-06-18 14:48:53 +0000967 ${this.isLoadingOlderMessages
968 ? html`
969 <div class="loading-indicator">
970 <div class="loading-spinner"></div>
971 <span>Loading older messages...</span>
972 </div>
973 `
974 : ""}
banksean54777362025-06-19 16:38:30 +0000975 ${this.isInitialLoadComplete
976 ? repeat(
977 this.visibleMessages,
978 this.messageKey,
979 (message, index) => {
980 // Find the previous message in the full filtered messages array
981 const filteredMessages = this.filteredMessages;
982 const messageIndex = filteredMessages.findIndex(
983 (m) => m === message,
984 );
985 let previousMessage =
986 messageIndex > 0
987 ? filteredMessages[messageIndex - 1]
988 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000989
banksean54777362025-06-19 16:38:30 +0000990 return html`<sketch-timeline-message
991 .message=${message}
992 .previousMessage=${previousMessage}
993 .open=${false}
994 .firstMessageIndex=${this.firstMessageIndex}
995 .state=${this.state}
996 ></sketch-timeline-message>`;
997 },
998 )
999 : ""}
1000 ${isThinking && this.isInitialLoadComplete
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001001 ? html`
1002 <div class="thinking-indicator">
1003 <div class="thinking-bubble">
1004 <div class="thinking-dots">
1005 <div class="dot"></div>
1006 <div class="dot"></div>
1007 <div class="dot"></div>
1008 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001009 </div>
1010 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001011 `
1012 : ""}
1013 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -07001014 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001015 <div
1016 id="jump-to-latest"
1017 class="${this.scrollingState}"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +00001018 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001019 >
1020
1021 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -07001022 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001023 `;
1024 }
1025}
1026
1027declare global {
1028 interface HTMLElementTagNameMap {
1029 "sketch-timeline": SketchTimeline;
1030 }
Sean McCullough71941bd2025-04-18 13:31:48 -07001031}