blob: c15f6ee03b1bc08edcd2f8ff512758335ef73b97 [file] [log] [blame]
bankseane59a2e12025-06-28 01:38:19 +00001import { html } 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";
bankseane59a2e12025-06-28 01:38:19 +00007import { SketchTailwindElement } from "./sketch-tailwind-element";
Pokey Rule4097e532025-04-24 18:55:28 +01008import { Ref } from "lit/directives/ref";
Sean McCullough86b56862025-04-18 13:04:03 -07009
Sean McCullough71941bd2025-04-18 13:31:48 -070010@customElement("sketch-timeline")
bankseane59a2e12025-06-28 01:38:19 +000011export class SketchTimeline extends SketchTailwindElement {
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010012 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -070013 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -070014
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000015 // Active state properties to show thinking indicator
16 @property({ attribute: false })
17 agentState: string | null = null;
18
19 @property({ attribute: false })
20 llmCalls: number = 0;
21
22 @property({ attribute: false })
23 toolCalls: string[] = [];
24
Sean McCullough2c5bba42025-04-20 19:33:17 -070025 // Track if we should scroll to the bottom
26 @state()
27 private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
28
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010029 @property({ attribute: false })
Pokey Rule4097e532025-04-24 18:55:28 +010030 scrollContainer: Ref<HTMLElement>;
Sean McCullough2c5bba42025-04-20 19:33:17 -070031
Sean McCulloughe68613d2025-06-18 14:48:53 +000032 // Keep track of current scroll container for cleanup
33 private currentScrollContainer: HTMLElement | null = null;
34
35 // Event-driven scroll handling without setTimeout
36 private scrollDebounceFrame: number | null = null;
37
38 // Loading operation management with proper cancellation
39 private loadingAbortController: AbortController | null = null;
40 private pendingScrollRestoration: (() => void) | null = null;
41
42 // Track current loading operation for cancellation
43 private currentLoadingOperation: Promise<void> | null = null;
44
45 // Observers for event-driven DOM updates
46 private resizeObserver: ResizeObserver | null = null;
47 private mutationObserver: MutationObserver | null = null;
48
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070049 @property({ attribute: false })
50 firstMessageIndex: number = 0;
51
philip.zeyliger6d3de482025-06-10 19:38:14 -070052 @property({ attribute: false })
53 state: State | null = null;
54
David Crawshaw4b644682025-06-26 17:15:10 +000055 @property({ attribute: false })
56 compactPadding: boolean = false;
57
banksean54777362025-06-19 16:38:30 +000058 // Track initial load completion for better rendering control
59 @state()
60 private isInitialLoadComplete: boolean = false;
61
62 @property({ attribute: false })
63 dataManager: any = null; // Reference to DataManager for event listening
64
Sean McCulloughe68613d2025-06-18 14:48:53 +000065 // Viewport rendering properties
66 @property({ attribute: false })
67 initialMessageCount: number = 30;
68
69 @property({ attribute: false })
70 loadChunkSize: number = 20;
71
72 @state()
73 private visibleMessageStartIndex: number = 0;
74
75 @state()
76 private isLoadingOlderMessages: boolean = false;
77
78 // Threshold for triggering load more (pixels from top)
79 private loadMoreThreshold: number = 100;
80
81 // Timeout ID for loading operations
82 private loadingTimeoutId: number | null = null;
83
Sean McCullough86b56862025-04-18 13:04:03 -070084 constructor() {
85 super();
Sean McCullough71941bd2025-04-18 13:31:48 -070086
Sean McCullough86b56862025-04-18 13:04:03 -070087 // Binding methods
88 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -070089 this._handleScroll = this._handleScroll.bind(this);
bankseane59a2e12025-06-28 01:38:19 +000090
91 // Add custom animations and styles that can't be easily done with Tailwind
92 this.addCustomStyles();
93 }
94
95 private addCustomStyles() {
96 const styleId = "sketch-timeline-custom-styles";
97 if (document.getElementById(styleId)) {
98 return; // Already added
99 }
100
101 const style = document.createElement("style");
102 style.id = styleId;
103 style.textContent = `
104 /* Hide message content initially to prevent flash of incomplete content */
105 .timeline-not-initialized sketch-timeline-message {
106 opacity: 0;
107 transition: opacity 0.2s ease-in;
108 }
109
110 /* Show content once initial load is complete */
111 .timeline-initialized sketch-timeline-message {
112 opacity: 1;
113 }
114
115 /* Custom animations for thinking dots */
116 @keyframes thinking-pulse {
117 0%, 100% {
118 opacity: 0.4;
119 transform: scale(1);
120 }
121 50% {
122 opacity: 1;
123 transform: scale(1.2);
124 }
125 }
126
127 .thinking-dot-1 {
128 animation: thinking-pulse 1.5s infinite ease-in-out;
129 }
130
131 .thinking-dot-2 {
132 animation: thinking-pulse 1.5s infinite ease-in-out 0.3s;
133 }
134
135 .thinking-dot-3 {
136 animation: thinking-pulse 1.5s infinite ease-in-out 0.6s;
137 }
138
139 /* Custom spinner animation */
140 @keyframes loading-spin {
141 0% {
142 transform: rotate(0deg);
143 }
144 100% {
145 transform: rotate(360deg);
146 }
147 }
148
149 .loading-spinner {
150 animation: loading-spin 1s linear infinite;
151 }
152
153 /* Custom compact padding styling */
154 .compact-padding .scroll-container {
155 padding-left: 0;
156 }
157 `;
158 document.head.appendChild(style);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700159 }
160
161 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000162 * Safely add scroll event listener with proper cleanup tracking
163 */
164 private addScrollListener(container: HTMLElement): void {
165 // Remove any existing listener first
166 this.removeScrollListener();
167
168 // Add new listener and track the container
169 container.addEventListener("scroll", this._handleScroll);
170 this.currentScrollContainer = container;
171 }
172
173 /**
174 * Safely remove scroll event listener
175 */
176 private removeScrollListener(): void {
177 if (this.currentScrollContainer) {
178 this.currentScrollContainer.removeEventListener(
179 "scroll",
180 this._handleScroll,
181 );
182 this.currentScrollContainer = null;
183 }
184
185 // Clear any pending timeouts and operations
186 this.clearAllPendingOperations();
187 }
188
189 /**
190 * Clear all pending operations and observers to prevent race conditions
191 */
192 private clearAllPendingOperations(): void {
193 // Clear scroll debounce frame
194 if (this.scrollDebounceFrame) {
195 cancelAnimationFrame(this.scrollDebounceFrame);
196 this.scrollDebounceFrame = null;
197 }
198
199 // Abort loading operations
200 if (this.loadingAbortController) {
201 this.loadingAbortController.abort();
202 this.loadingAbortController = null;
203 }
204
205 // Cancel pending scroll restoration
206 if (this.pendingScrollRestoration) {
207 this.pendingScrollRestoration = null;
208 }
209
210 // Clean up observers
211 this.disconnectObservers();
212 }
213
214 /**
215 * Disconnect all observers
216 */
217 private disconnectObservers(): void {
218 if (this.resizeObserver) {
219 this.resizeObserver.disconnect();
220 this.resizeObserver = null;
221 }
222
223 if (this.mutationObserver) {
224 this.mutationObserver.disconnect();
225 this.mutationObserver = null;
226 }
227 }
228
229 /**
230 * Force a viewport reset to show the most recent messages
231 * Useful when loading a new session or when messages change significantly
232 */
233 public resetViewport(): void {
234 // Cancel any pending loading operations to prevent race conditions
235 this.cancelCurrentLoadingOperation();
236
237 // Reset viewport state
238 this.visibleMessageStartIndex = 0;
239 this.isLoadingOlderMessages = false;
240
241 // Clear all pending operations
242 this.clearAllPendingOperations();
243
244 this.requestUpdate();
245 }
246
247 /**
248 * Cancel current loading operation if in progress
249 */
250 private cancelCurrentLoadingOperation(): void {
251 if (this.isLoadingOlderMessages) {
252 this.isLoadingOlderMessages = false;
253
254 // Abort the loading operation
255 if (this.loadingAbortController) {
256 this.loadingAbortController.abort();
257 this.loadingAbortController = null;
258 }
259
260 // Cancel pending scroll restoration
261 this.pendingScrollRestoration = null;
262 }
263 }
264
265 /**
266 * Get the filtered messages (excluding hidden ones)
267 */
268 private get filteredMessages(): AgentMessage[] {
269 return this.messages.filter((msg) => !msg.hide_output);
270 }
271
272 /**
273 * Get the currently visible messages based on viewport rendering
274 * Race-condition safe implementation
275 */
276 private get visibleMessages(): AgentMessage[] {
277 const filtered = this.filteredMessages;
278 if (filtered.length === 0) return [];
279
280 // Always show the most recent messages first
281 // visibleMessageStartIndex represents how many additional older messages to show
282 const totalVisible =
283 this.initialMessageCount + this.visibleMessageStartIndex;
284 const startIndex = Math.max(0, filtered.length - totalVisible);
285
286 // Ensure we don't return an invalid slice during loading operations
287 const endIndex = filtered.length;
288 if (startIndex >= endIndex) {
289 return [];
290 }
291
292 return filtered.slice(startIndex, endIndex);
293 }
294
295 /**
296 * Check if the component is in a stable state for loading operations
297 */
298 private isStableForLoading(): boolean {
299 return (
300 this.scrollContainer.value !== null &&
301 this.scrollContainer.value === this.currentScrollContainer &&
302 this.scrollContainer.value.isConnected &&
303 !this.isLoadingOlderMessages &&
304 !this.currentLoadingOperation
305 );
306 }
307
308 /**
309 * Load more older messages by expanding the visible window
310 * Race-condition safe implementation
311 */
312 private async loadOlderMessages(): Promise<void> {
313 // Prevent concurrent loading operations
314 if (this.isLoadingOlderMessages || this.currentLoadingOperation) {
315 return;
316 }
317
318 const filtered = this.filteredMessages;
319 const currentVisibleCount = this.visibleMessages.length;
320 const totalAvailable = filtered.length;
321
322 // Check if there are more messages to load
323 if (currentVisibleCount >= totalAvailable) {
324 return;
325 }
326
327 // Start loading operation with proper state management
328 this.isLoadingOlderMessages = true;
329
330 // Store current scroll position for restoration
331 const container = this.scrollContainer.value;
332 const previousScrollHeight = container?.scrollHeight || 0;
333 const previousScrollTop = container?.scrollTop || 0;
334
335 // Validate scroll container hasn't changed during setup
336 if (!container || container !== this.currentScrollContainer) {
337 this.isLoadingOlderMessages = false;
338 return;
339 }
340
341 // Expand the visible window with bounds checking
342 const additionalMessages = Math.min(
343 this.loadChunkSize,
344 totalAvailable - currentVisibleCount,
345 );
346 const newStartIndex = this.visibleMessageStartIndex + additionalMessages;
347
348 // Ensure we don't exceed available messages
349 const boundedStartIndex = Math.min(
350 newStartIndex,
351 totalAvailable - this.initialMessageCount,
352 );
353 this.visibleMessageStartIndex = Math.max(0, boundedStartIndex);
354
355 // Create the loading operation with proper error handling and cleanup
356 const loadingOperation = this.executeScrollPositionRestoration(
357 container,
358 previousScrollHeight,
359 previousScrollTop,
360 );
361
362 this.currentLoadingOperation = loadingOperation;
363
364 try {
365 await loadingOperation;
366 } catch (error) {
367 console.warn("Loading operation failed:", error);
368 } finally {
369 // Ensure loading state is always cleared
370 this.isLoadingOlderMessages = false;
371 this.currentLoadingOperation = null;
372
373 // Clear the loading timeout if it exists
374 if (this.loadingTimeoutId) {
375 clearTimeout(this.loadingTimeoutId);
376 this.loadingTimeoutId = null;
377 }
378 }
379 }
380
381 /**
382 * Execute scroll position restoration with event-driven approach
383 */
384 private async executeScrollPositionRestoration(
385 container: HTMLElement,
386 previousScrollHeight: number,
387 previousScrollTop: number,
388 ): Promise<void> {
389 // Set up AbortController for proper cancellation
390 this.loadingAbortController = new AbortController();
391 const { signal } = this.loadingAbortController;
392
393 // Create scroll restoration function
394 const restoreScrollPosition = () => {
395 // Check if operation was aborted
396 if (signal.aborted) {
397 return;
398 }
399
400 // Double-check container is still valid and connected
401 if (
402 !container ||
403 !container.isConnected ||
404 container !== this.currentScrollContainer
405 ) {
406 return;
407 }
408
409 try {
410 const newScrollHeight = container.scrollHeight;
411 const scrollDifference = newScrollHeight - previousScrollHeight;
412 const newScrollTop = previousScrollTop + scrollDifference;
413
414 // Validate all scroll calculations before applying
415 const isValidRestoration =
416 scrollDifference > 0 && // Content was added
417 newScrollTop >= 0 && // New position is valid
418 newScrollTop <= newScrollHeight && // Don't exceed max scroll
419 previousScrollHeight > 0 && // Had valid previous height
420 newScrollHeight > previousScrollHeight; // Height actually increased
421
422 if (isValidRestoration) {
423 container.scrollTop = newScrollTop;
424 } else {
425 // Log invalid restoration attempts for debugging
426 console.debug("Skipped scroll restoration:", {
427 scrollDifference,
428 newScrollTop,
429 newScrollHeight,
430 previousScrollHeight,
431 previousScrollTop,
432 });
433 }
434 } catch (error) {
435 console.warn("Scroll position restoration failed:", error);
436 }
437 };
438
439 // Store the restoration function for potential cancellation
440 this.pendingScrollRestoration = restoreScrollPosition;
441
442 // Wait for DOM update and then restore scroll position
443 await this.updateComplete;
444
445 // Check if operation was cancelled during await
446 if (
447 !signal.aborted &&
448 this.pendingScrollRestoration === restoreScrollPosition
449 ) {
450 // Use ResizeObserver to detect when content is actually ready
451 await this.waitForContentReady(container, signal);
452
453 if (!signal.aborted) {
454 restoreScrollPosition();
455 this.pendingScrollRestoration = null;
456 }
457 }
458 }
459
460 /**
461 * Wait for content to be ready using ResizeObserver instead of setTimeout
462 */
463 private async waitForContentReady(
464 container: HTMLElement,
465 signal: AbortSignal,
466 ): Promise<void> {
467 return new Promise((resolve, reject) => {
468 if (signal.aborted) {
469 reject(new Error("Operation aborted"));
470 return;
471 }
472
473 // Resolve immediately if container already has content
474 if (container.scrollHeight > 0) {
475 resolve();
476 return;
477 }
478
479 // Set up ResizeObserver to detect content changes
480 const observer = new ResizeObserver((entries) => {
481 if (signal.aborted) {
482 observer.disconnect();
483 reject(new Error("Operation aborted"));
484 return;
485 }
486
487 // Content is ready when height increases
488 const entry = entries[0];
489 if (entry && entry.contentRect.height > 0) {
490 observer.disconnect();
491 resolve();
492 }
493 });
494
495 // Start observing
496 observer.observe(container);
497
498 // Clean up on abort
499 signal.addEventListener("abort", () => {
500 observer.disconnect();
501 reject(new Error("Operation aborted"));
502 });
503 });
504 }
505
506 /**
Sean McCullough2c5bba42025-04-20 19:33:17 -0700507 * Scroll to the bottom of the timeline
508 */
509 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000510 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000511
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000512 // Use instant scroll to ensure we reach the exact bottom
513 this.scrollContainer.value.scrollTo({
514 top: this.scrollContainer.value.scrollHeight,
515 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700516 });
517 }
Autoformatter71c73b52025-05-29 20:18:43 +0000518
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000519 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000520 * Scroll to bottom with event-driven approach using MutationObserver
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000521 */
Sean McCulloughe68613d2025-06-18 14:48:53 +0000522 private async scrollToBottomWithRetry(): Promise<void> {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000523 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000524
Sean McCulloughe68613d2025-06-18 14:48:53 +0000525 const container = this.scrollContainer.value;
Autoformatter71c73b52025-05-29 20:18:43 +0000526
Sean McCulloughe68613d2025-06-18 14:48:53 +0000527 // Try immediate scroll first
528 this.scrollToBottom();
Autoformatter71c73b52025-05-29 20:18:43 +0000529
Sean McCulloughe68613d2025-06-18 14:48:53 +0000530 // Check if we're at the bottom
531 const isAtBottom = () => {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000532 const targetScrollTop = container.scrollHeight - container.clientHeight;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000533 const actualScrollTop = container.scrollTop;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000534 return Math.abs(targetScrollTop - actualScrollTop) <= 1;
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000535 };
Autoformatter71c73b52025-05-29 20:18:43 +0000536
Sean McCulloughe68613d2025-06-18 14:48:53 +0000537 // If already at bottom, we're done
538 if (isAtBottom()) {
539 return;
540 }
541
542 // Use MutationObserver to detect content changes and retry
543 return new Promise((resolve) => {
544 let scrollAttempted = false;
545
546 const observer = new MutationObserver(() => {
547 if (!scrollAttempted) {
548 scrollAttempted = true;
549
550 // Use requestAnimationFrame to ensure DOM is painted
551 requestAnimationFrame(() => {
552 this.scrollToBottom();
553
554 // Check if successful
555 if (isAtBottom()) {
556 observer.disconnect();
557 resolve();
558 } else {
559 // Try one more time after another frame
560 requestAnimationFrame(() => {
561 this.scrollToBottom();
562 observer.disconnect();
563 resolve();
564 });
565 }
566 });
567 }
568 });
569
570 // Observe changes to the timeline container
571 observer.observe(container, {
572 childList: true,
573 subtree: true,
574 attributes: false,
575 });
576
577 // Clean up after a reasonable time if no changes detected
578 requestAnimationFrame(() => {
579 requestAnimationFrame(() => {
580 if (!scrollAttempted) {
581 observer.disconnect();
582 resolve();
583 }
584 });
585 });
586 });
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000587 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700588
589 /**
590 * Called after the component's properties have been updated
591 */
592 updated(changedProperties: PropertyValues): void {
banksean54777362025-06-19 16:38:30 +0000593 // Handle DataManager changes to set up event listeners
594 if (changedProperties.has("dataManager")) {
595 const oldDataManager = changedProperties.get("dataManager");
596
597 // Remove old event listener if it exists
598 if (oldDataManager) {
599 oldDataManager.removeEventListener(
600 "initialLoadComplete",
601 this.handleInitialLoadComplete,
602 );
603 }
604
605 // Add new event listener if dataManager is available
606 if (this.dataManager) {
607 this.dataManager.addEventListener(
608 "initialLoadComplete",
609 this.handleInitialLoadComplete,
610 );
611
612 // Check if initial load is already complete
613 if (
614 this.dataManager.getIsInitialLoadComplete &&
615 this.dataManager.getIsInitialLoadComplete()
616 ) {
617 this.isInitialLoadComplete = true;
618 }
619 }
620 }
621
Sean McCulloughe68613d2025-06-18 14:48:53 +0000622 // Handle scroll container changes first to prevent race conditions
623 if (changedProperties.has("scrollContainer")) {
624 // Cancel any ongoing loading operations since container is changing
625 this.cancelCurrentLoadingOperation();
626
627 if (this.scrollContainer.value) {
628 this.addScrollListener(this.scrollContainer.value);
629 } else {
630 this.removeScrollListener();
Sean McCullough2c5bba42025-04-20 19:33:17 -0700631 }
632 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000633
634 // If messages have changed, handle viewport updates
635 if (changedProperties.has("messages")) {
636 const oldMessages =
637 (changedProperties.get("messages") as AgentMessage[]) || [];
638 const newMessages = this.messages || [];
639
640 // Cancel loading operations if messages changed significantly
641 const significantChange =
642 oldMessages.length === 0 ||
643 newMessages.length < oldMessages.length ||
644 Math.abs(newMessages.length - oldMessages.length) > 20;
645
646 if (significantChange) {
647 // Cancel any ongoing operations and reset viewport
648 this.cancelCurrentLoadingOperation();
649 this.visibleMessageStartIndex = 0;
650 }
651
652 // Scroll to bottom if needed (only if not loading to prevent race conditions)
653 if (
654 this.messages.length > 0 &&
655 this.scrollingState === "pinToLatest" &&
656 !this.isLoadingOlderMessages
657 ) {
658 // Use async scroll without setTimeout
659 this.scrollToBottomWithRetry().catch((error) => {
660 console.warn("Scroll to bottom failed:", error);
661 });
662 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700663 }
Sean McCullough86b56862025-04-18 13:04:03 -0700664 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700665
Sean McCullough86b56862025-04-18 13:04:03 -0700666 /**
667 * Handle showCommitDiff event
668 */
669 private _handleShowCommitDiff(event: CustomEvent) {
670 const { commitHash } = event.detail;
671 if (commitHash) {
672 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700673 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700674 detail: { commitHash },
675 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700676 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700677 });
678 this.dispatchEvent(newEvent);
679 }
680 }
681
philip.zeyliger26bc6592025-06-30 20:15:30 -0700682 private _handleScroll(_event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000683 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000684
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000685 const container = this.scrollContainer.value;
Sean McCulloughe68613d2025-06-18 14:48:53 +0000686
687 // Verify this is still our tracked container to prevent race conditions
688 if (container !== this.currentScrollContainer) {
689 return;
690 }
691
Sean McCullough2c5bba42025-04-20 19:33:17 -0700692 const isAtBottom =
693 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000694 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000695 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000696
Sean McCulloughe68613d2025-06-18 14:48:53 +0000697 const isNearTop = container.scrollTop <= this.loadMoreThreshold;
698
699 // Update scroll state immediately for responsive UI
Sean McCullough2c5bba42025-04-20 19:33:17 -0700700 if (isAtBottom) {
701 this.scrollingState = "pinToLatest";
702 } else {
Sean McCullough2c5bba42025-04-20 19:33:17 -0700703 this.scrollingState = "floating";
704 }
Sean McCulloughe68613d2025-06-18 14:48:53 +0000705
706 // Use requestAnimationFrame for smooth debouncing instead of setTimeout
707 if (this.scrollDebounceFrame) {
708 cancelAnimationFrame(this.scrollDebounceFrame);
709 }
710
711 this.scrollDebounceFrame = requestAnimationFrame(() => {
712 // Use stability check to ensure safe loading conditions
713 if (isNearTop && this.isStableForLoading()) {
714 this.loadOlderMessages().catch((error) => {
715 console.warn("Async loadOlderMessages failed:", error);
716 });
717 }
718 this.scrollDebounceFrame = null;
719 });
Sean McCullough2c5bba42025-04-20 19:33:17 -0700720 }
721
Sean McCullough86b56862025-04-18 13:04:03 -0700722 // See https://lit.dev/docs/components/lifecycle/
723 connectedCallback() {
724 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700725
Sean McCullough86b56862025-04-18 13:04:03 -0700726 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700727 document.addEventListener(
728 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700729 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700730 );
Pokey Rule4097e532025-04-24 18:55:28 +0100731
Sean McCulloughe68613d2025-06-18 14:48:53 +0000732 // Set up scroll listener if container is available
banksean44320562025-07-21 11:09:38 -0700733 if (this.scrollContainer?.value) {
Sean McCulloughe68613d2025-06-18 14:48:53 +0000734 this.addScrollListener(this.scrollContainer.value);
735 }
736
737 // Initialize observers for event-driven behavior
738 this.setupObservers();
Sean McCullough86b56862025-04-18 13:04:03 -0700739 }
740
Sean McCulloughe68613d2025-06-18 14:48:53 +0000741 /**
banksean54777362025-06-19 16:38:30 +0000742 * Handle initial load completion from DataManager
743 */
744 private handleInitialLoadComplete = (eventData: {
745 messageCount: number;
746 expectedCount: number;
747 }): void => {
748 console.log(
749 `Timeline: Initial load complete - ${eventData.messageCount}/${eventData.expectedCount} messages`,
750 );
751 this.isInitialLoadComplete = true;
752 this.requestUpdate();
753 };
754
755 /**
Sean McCulloughe68613d2025-06-18 14:48:53 +0000756 * Set up observers for event-driven DOM monitoring
757 */
758 private setupObservers(): void {
759 // ResizeObserver will be created on-demand in loading operations
760 // MutationObserver will be created on-demand in scroll operations
761 // This avoids creating observers that may not be needed
762 }
763
764 // See https://lit.dev/docs/component/lifecycle/
Sean McCullough86b56862025-04-18 13:04:03 -0700765 disconnectedCallback() {
766 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700767
Sean McCulloughe68613d2025-06-18 14:48:53 +0000768 // Cancel any ongoing loading operations before cleanup
769 this.cancelCurrentLoadingOperation();
770
771 // Remove event listeners with guaranteed cleanup
Sean McCullough71941bd2025-04-18 13:31:48 -0700772 document.removeEventListener(
773 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700774 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700775 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700776
banksean54777362025-06-19 16:38:30 +0000777 // Remove DataManager event listener if connected
778 if (this.dataManager) {
779 this.dataManager.removeEventListener(
780 "initialLoadComplete",
781 this.handleInitialLoadComplete,
782 );
783 }
784
Sean McCulloughe68613d2025-06-18 14:48:53 +0000785 // Use our safe cleanup method
786 this.removeScrollListener();
Sean McCullough86b56862025-04-18 13:04:03 -0700787 }
788
Sean McCulloughd9f13372025-04-21 15:08:49 -0700789 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700790 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700791 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700792 // If the message has tool calls, and any of the tool_calls get a response, we need to
793 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700794 const toolCallResponses = message.tool_calls
795 ?.filter((tc) => tc.result_message)
796 .map((tc) => tc.tool_call_id)
797 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700798 return `message-${message.idx}-${toolCallResponses}`;
799 }
800
801 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000802 // Check if messages array is empty and render welcome box if it is
David Crawshawe5b2fc02025-07-06 16:33:46 +0000803 // Skip welcome box in newsessions (compactPadding) context
804 if (this.messages.length === 0 && !this.compactPadding) {
bankseane59a2e12025-06-28 01:38:19 +0000805 const compactClass = this.compactPadding ? "compact-padding" : "";
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000806 return html`
bankseane59a2e12025-06-28 01:38:19 +0000807 <div class="relative h-full">
808 <div
809 id="scroll-container"
810 class="overflow-y-auto overflow-x-hidden pl-4 max-w-full w-full h-full ${compactClass} scroll-container print:h-auto print:max-h-none print:overflow-visible"
811 >
812 <div
banksean1ee0bc62025-07-22 23:24:18 +0000813 class="my-8 mx-auto max-w-[90%] w-[90%] p-8 border-2 border-gray-300 dark:border-gray-600 rounded-lg shadow-sm bg-white dark:bg-gray-800 text-center print:break-inside-avoid"
bankseane59a2e12025-06-28 01:38:19 +0000814 data-testid="welcome-box"
815 >
816 <h2
banksean1ee0bc62025-07-22 23:24:18 +0000817 class="text-2xl font-semibold mb-6 text-center text-gray-800 dark:text-gray-100"
bankseane59a2e12025-06-28 01:38:19 +0000818 data-testid="welcome-box-title"
819 >
820 How to use Sketch
821 </h2>
banksean1ee0bc62025-07-22 23:24:18 +0000822 <p
823 class="text-gray-600 dark:text-gray-300 leading-relaxed text-base text-left"
824 >
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700825 Sketch is an agentic coding assistant.
826 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700827
banksean1ee0bc62025-07-22 23:24:18 +0000828 <p
829 class="text-gray-600 dark:text-gray-300 leading-relaxed text-base text-left"
830 >
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700831 Sketch has created a container with your repo.
832 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700833
banksean1ee0bc62025-07-22 23:24:18 +0000834 <p
835 class="text-gray-600 dark:text-gray-300 leading-relaxed text-base text-left"
836 >
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700837 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000838 below. It can edit and run your code, all in the container.
839 Sketch will create commits in a newly created git branch, which
840 you can look at and comment on in the Diff tab. Once you're
841 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700842 </p>
banksean1ee0bc62025-07-22 23:24:18 +0000843 <p
844 class="text-gray-600 dark:text-gray-300 leading-relaxed text-base text-left"
845 >
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700846 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000847 Sketch in parallel to work on multiple ideas or even the same
848 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700849 </p>
850 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000851 </div>
852 </div>
853 `;
854 }
855
856 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000857 const isThinking =
858 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
859
banksean54777362025-06-19 16:38:30 +0000860 // Apply view-initialized class when initial load is complete
bankseane59a2e12025-06-28 01:38:19 +0000861 const timelineStateClass = this.isInitialLoadComplete
862 ? "timeline-initialized"
863 : "timeline-not-initialized";
864
865 // Compact padding class
866 const compactClass = this.compactPadding ? "compact-padding" : "";
banksean54777362025-06-19 16:38:30 +0000867
Sean McCullough86b56862025-04-18 13:04:03 -0700868 return html`
bankseane59a2e12025-06-28 01:38:19 +0000869 <div class="relative h-full">
870 <div
871 id="scroll-container"
872 class="overflow-y-auto overflow-x-hidden pl-4 max-w-full w-full h-full ${compactClass} scroll-container print:h-auto print:max-h-none print:overflow-visible"
873 >
874 <div
875 class="w-full relative max-w-full mx-auto px-[15px] box-border overflow-x-hidden flex-1 min-h-[100px] ${timelineStateClass} print:h-auto print:max-h-none print:overflow-visible print:break-inside-avoid"
876 data-testid="timeline-container"
877 >
banksean54777362025-06-19 16:38:30 +0000878 ${!this.isInitialLoadComplete
879 ? html`
bankseane59a2e12025-06-28 01:38:19 +0000880 <div
banksean1ee0bc62025-07-22 23:24:18 +0000881 class="flex items-center justify-center p-5 text-gray-600 dark:text-gray-400 text-sm gap-2.5 opacity-100 print:hidden"
bankseane59a2e12025-06-28 01:38:19 +0000882 data-testid="loading-indicator"
883 >
884 <div
banksean1ee0bc62025-07-22 23:24:18 +0000885 class="w-5 h-5 border-2 border-gray-300 dark:border-gray-600 border-t-gray-600 dark:border-t-gray-300 rounded-full loading-spinner"
bankseane59a2e12025-06-28 01:38:19 +0000886 data-testid="loading-spinner"
887 ></div>
banksean54777362025-06-19 16:38:30 +0000888 <span>Loading conversation...</span>
889 </div>
890 `
891 : ""}
Sean McCulloughe68613d2025-06-18 14:48:53 +0000892 ${this.isLoadingOlderMessages
893 ? html`
bankseane59a2e12025-06-28 01:38:19 +0000894 <div
banksean1ee0bc62025-07-22 23:24:18 +0000895 class="flex items-center justify-center p-5 text-gray-600 dark:text-gray-400 text-sm gap-2.5 opacity-100 print:hidden"
bankseane59a2e12025-06-28 01:38:19 +0000896 data-testid="loading-indicator"
897 >
898 <div
banksean1ee0bc62025-07-22 23:24:18 +0000899 class="w-5 h-5 border-2 border-gray-300 dark:border-gray-600 border-t-gray-600 dark:border-t-gray-300 rounded-full loading-spinner"
bankseane59a2e12025-06-28 01:38:19 +0000900 data-testid="loading-spinner"
901 ></div>
Sean McCulloughe68613d2025-06-18 14:48:53 +0000902 <span>Loading older messages...</span>
903 </div>
904 `
905 : ""}
banksean54777362025-06-19 16:38:30 +0000906 ${this.isInitialLoadComplete
907 ? repeat(
908 this.visibleMessages,
909 this.messageKey,
philip.zeyliger26bc6592025-06-30 20:15:30 -0700910 (message, _index) => {
banksean54777362025-06-19 16:38:30 +0000911 // Find the previous message in the full filtered messages array
912 const filteredMessages = this.filteredMessages;
913 const messageIndex = filteredMessages.findIndex(
914 (m) => m === message,
915 );
philip.zeyliger26bc6592025-06-30 20:15:30 -0700916 const previousMessage =
banksean54777362025-06-19 16:38:30 +0000917 messageIndex > 0
918 ? filteredMessages[messageIndex - 1]
919 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000920
banksean54777362025-06-19 16:38:30 +0000921 return html`<sketch-timeline-message
922 .message=${message}
923 .previousMessage=${previousMessage}
924 .open=${false}
925 .firstMessageIndex=${this.firstMessageIndex}
926 .state=${this.state}
David Crawshaw4b644682025-06-26 17:15:10 +0000927 .compactPadding=${this.compactPadding}
banksean54777362025-06-19 16:38:30 +0000928 ></sketch-timeline-message>`;
929 },
930 )
931 : ""}
932 ${isThinking && this.isInitialLoadComplete
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700933 ? html`
bankseane59a2e12025-06-28 01:38:19 +0000934 <div
935 class="pl-[85px] mt-1.5 mb-4 flex"
936 data-testid="thinking-indicator"
937 style="display: flex; padding-left: 85px; margin-top: 6px; margin-bottom: 16px;"
938 >
939 <div
banksean1ee0bc62025-07-22 23:24:18 +0000940 class="bg-gray-100 dark:bg-gray-700 rounded-2xl px-4 py-2.5 max-w-20 text-black dark:text-white relative rounded-bl-[5px]"
bankseane59a2e12025-06-28 01:38:19 +0000941 data-testid="thinking-bubble"
942 >
943 <div
944 class="flex items-center justify-center gap-1 h-3.5"
945 data-testid="thinking-dots"
946 >
947 <div
banksean1ee0bc62025-07-22 23:24:18 +0000948 class="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-300 rounded-full opacity-60 thinking-dot-1"
bankseane59a2e12025-06-28 01:38:19 +0000949 data-testid="thinking-dot"
950 ></div>
951 <div
banksean1ee0bc62025-07-22 23:24:18 +0000952 class="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-300 rounded-full opacity-60 thinking-dot-2"
bankseane59a2e12025-06-28 01:38:19 +0000953 data-testid="thinking-dot"
954 ></div>
955 <div
banksean1ee0bc62025-07-22 23:24:18 +0000956 class="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-300 rounded-full opacity-60 thinking-dot-3"
bankseane59a2e12025-06-28 01:38:19 +0000957 data-testid="thinking-dot"
958 ></div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700959 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000960 </div>
961 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700962 `
963 : ""}
964 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -0700965 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700966 <div
967 id="jump-to-latest"
bankseane59a2e12025-06-28 01:38:19 +0000968 class="${this.scrollingState === "floating"
969 ? "block floating"
banksean1ee0bc62025-07-22 23:24:18 +0000970 : "hidden"} fixed bottom-20 left-1/2 -translate-x-1/2 bg-black/60 dark:bg-gray-700/80 text-white border-none rounded-xl px-2 py-1 text-xs font-normal cursor-pointer shadow-md z-[1000] transition-all duration-150 ease-out whitespace-nowrap opacity-80 hover:bg-black/80 dark:hover:bg-gray-600/90 hover:-translate-y-0.5 hover:opacity-100 hover:shadow-lg active:translate-y-0 print:hidden"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000971 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700972 >
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700973 ↓ Jump to bottom
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700974 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -0700975 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700976 `;
977 }
978}
979
980declare global {
981 interface HTMLElementTagNameMap {
982 "sketch-timeline": SketchTimeline;
983 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700984}