webui: fix chat timeline scrolling to reach exact bottom

Implement robust scroll-to-bottom behavior with retry logic to handle
dynamic content rendering and ensure chat view scrolls completely to
the bottom when browser windows are opened.

The issue occurred when popping browser windows - the chat timeline
would scroll toward the bottom but not reach the exact bottom position,
leaving some content (like the input field) partially visible or cut off.

Root cause analysis:
- 50ms timeout insufficient for dynamic content (images, tool cards) to render
- scrollHeight calculation happened before final layout was complete
- smooth scroll behavior could be interrupted by subsequent layout changes
- 1px tolerance in scroll detection too strict for various screen sizes

Implementation improvements:

1. Enhanced scrollToBottom() method:
   - Switch from 'smooth' to 'instant' scroll behavior for reliability
   - Calculate exact target scroll position (scrollHeight - clientHeight)
   - Add null safety checks for scroll container

2. New scrollToBottomWithRetry() method:
   - Retry logic with up to 5 attempts at 50ms intervals
   - Verify actual scroll position after each attempt
   - Continue retrying until exactly at bottom or max attempts reached
   - Prevents race conditions with dynamic content loading

3. Improved scroll detection accuracy:
   - Increased tolerance from 1px to 3px for isAtBottom detection
   - Better handling of fractional pixel differences across browsers
   - More reliable detection of 'pinToLatest' vs 'floating' states

4. Enhanced timing and integration:
   - Increased initial timeout from 50ms to 100ms for content rendering
   - Updated both automatic scroll (on message changes) and manual scroll (jump-to-latest button)
   - Consistent behavior across all scroll triggers

Technical benefits:
- Eliminates incomplete scrolling that left content partially visible
- Handles dynamic content loading (images, expanding tool cards, etc.)
- Provides immediate feedback with instant scroll behavior
- Self-correcting through retry mechanism for timing edge cases
- Better cross-browser compatibility with increased tolerance

Testing verification:
- Started test sketch instance and verified complete scroll behavior
- Confirmed chat scrolls to exact bottom showing input field fully
- Verified manual 'jump to latest' button works correctly
- Screenshots show complete message content and input accessibility

This ensures users always see the complete conversation and can easily
access the input field when browser windows are opened, resolving the
reported incomplete scrolling behavior.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sa9a8755f69c688cfk
diff --git a/webui/src/web-components/sketch-timeline.ts b/webui/src/web-components/sketch-timeline.ts
index 6a0f683..cc16115 100644
--- a/webui/src/web-components/sketch-timeline.ts
+++ b/webui/src/web-components/sketch-timeline.ts
@@ -193,11 +193,51 @@
    * Scroll to the bottom of the timeline
    */
   private scrollToBottom(): void {
-    this.scrollContainer.value?.scrollTo({
-      top: this.scrollContainer.value?.scrollHeight,
-      behavior: "smooth",
+    if (!this.scrollContainer.value) return;
+    
+    // Use instant scroll to ensure we reach the exact bottom
+    this.scrollContainer.value.scrollTo({
+      top: this.scrollContainer.value.scrollHeight,
+      behavior: "instant",
     });
   }
+  
+  /**
+   * Scroll to bottom with retry logic to handle dynamic content
+   */
+  private scrollToBottomWithRetry(): void {
+    if (!this.scrollContainer.value) return;
+    
+    let attempts = 0;
+    const maxAttempts = 5;
+    const retryInterval = 50;
+    
+    const tryScroll = () => {
+      if (!this.scrollContainer.value) return;
+      
+      const container = this.scrollContainer.value;
+      const targetScrollTop = container.scrollHeight - container.clientHeight;
+      
+      // Scroll to the calculated position
+      container.scrollTo({
+        top: targetScrollTop,
+        behavior: "instant",
+      });
+      
+      attempts++;
+      
+      // Check if we're actually at the bottom
+      const actualScrollTop = container.scrollTop;
+      const isAtBottom = Math.abs(targetScrollTop - actualScrollTop) <= 1;
+      
+      if (!isAtBottom && attempts < maxAttempts) {
+        // Still not at bottom and we have attempts left, try again
+        setTimeout(tryScroll, retryInterval);
+      }
+    };
+    
+    tryScroll();
+  }
 
   /**
    * Called after the component's properties have been updated
@@ -206,7 +246,8 @@
     // If messages have changed, scroll to bottom if needed
     if (changedProperties.has("messages") && this.messages.length > 0) {
       if (this.scrollingState == "pinToLatest") {
-        setTimeout(() => this.scrollToBottom(), 50);
+        // Use longer timeout and retry logic to handle dynamic content
+        setTimeout(() => this.scrollToBottomWithRetry(), 100);
       }
     }
     if (changedProperties.has("scrollContainer")) {
@@ -234,12 +275,14 @@
   }
 
   private _handleScroll(event) {
+    if (!this.scrollContainer.value) return;
+    
+    const container = this.scrollContainer.value;
     const isAtBottom =
       Math.abs(
-        this.scrollContainer.value.scrollHeight -
-          this.scrollContainer.value.clientHeight -
-          this.scrollContainer.value.scrollTop,
-      ) <= 1;
+        container.scrollHeight - container.clientHeight - container.scrollTop
+      ) <= 3; // Increased tolerance to 3px for better detection
+    
     if (isAtBottom) {
       this.scrollingState = "pinToLatest";
     } else {
@@ -376,7 +419,7 @@
         <div
           id="jump-to-latest"
           class="${this.scrollingState}"
-          @click=${this.scrollToBottom}
+          @click=${this.scrollToBottomWithRetry}
         >

         </div>