← Back to task

Commit 2151b678

commit 2151b67836d209645b85bb64d56a96bfbf2859cd
Author: Ben Sima <ben@bensima.com>
Date:   Mon Dec 1 15:41:19 2025

    Add autoscroll toggle button for timeline
    
    Adds a toggle button next to the LIVE indicator that controls whether
    the timeline auto-scrolls to new events. Default is ON.
    
    - renderAutoscrollToggle button with ⬇ icon
    - toggleAutoscroll() JS function tracks state
    - htmx:afterSettle checks autoscrollEnabled before scrolling
    - Blue styling to differentiate from green LIVE button
    
    Task-Id: t-222

diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index a493395f..6a2d826a 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -2273,11 +2273,23 @@ renderLiveToggle =
     ]
     " LIVE"
 
--- | JavaScript for toggling live updates
+-- | Render the autoscroll toggle button
+renderAutoscrollToggle :: (Monad m) => Lucid.HtmlT m ()
+renderAutoscrollToggle =
+  Lucid.button_
+    [ Lucid.class_ "timeline-autoscroll-toggle",
+      Lucid.id_ "autoscroll-toggle",
+      Lucid.makeAttribute "onclick" "toggleAutoscroll()",
+      Lucid.title_ "Toggle automatic scrolling to newest events"
+    ]
+    " ⬇ Auto-scroll"
+
+-- | JavaScript for toggling live updates and autoscroll
 liveToggleJs :: Text
 liveToggleJs =
   Text.unlines
     [ "var liveUpdatesEnabled = true;",
+      "var autoscrollEnabled = true;",
       "",
       "function toggleLiveUpdates() {",
       "  liveUpdatesEnabled = !liveUpdatesEnabled;",
@@ -2287,11 +2299,28 @@ liveToggleJs =
       "  }",
       "}",
       "",
+      "function toggleAutoscroll() {",
+      "  autoscrollEnabled = !autoscrollEnabled;",
+      "  var btn = document.getElementById('autoscroll-toggle');",
+      "  if (btn) {",
+      "    btn.classList.toggle('timeline-autoscroll-disabled', !autoscrollEnabled);",
+      "  }",
+      "}",
+      "",
       "document.body.addEventListener('htmx:beforeRequest', function(evt) {",
       "  var timeline = document.getElementById('unified-timeline');",
       "  if (timeline && timeline.contains(evt.target) && !liveUpdatesEnabled) {",
       "    evt.preventDefault();",
       "  }",
+      "});",
+      "",
+      "document.body.addEventListener('htmx:afterSettle', function(evt) {",
+      "  if (autoscrollEnabled) {",
+      "    var log = document.querySelector('.timeline-events');",
+      "    if (log) {",
+      "      log.scrollTop = log.scrollHeight;",
+      "    }",
+      "  }",
       "});"
     ]
 
@@ -2352,7 +2381,9 @@ renderUnifiedTimeline tid legacyComments events status now = do
           when (totalCents > 0) <| Lucid.toHtml (formatCostHeader totalCents)
           when (totalCents > 0 && totalTokens > 0) <| metaSep
           when (totalTokens > 0) <| Lucid.toHtml (formatTokensHeader totalTokens <> " tokens")
-      when isInProgress <| renderLiveToggle
+      when isInProgress <| do
+        renderLiveToggle
+        renderAutoscrollToggle
 
     if null nonCostEvents && null legacyComments
       then Lucid.p_ [Lucid.class_ "empty-msg"] "No activity yet."
diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs
index cf32570b..bbf828bc 100644
--- a/Omni/Jr/Web/Style.hs
+++ b/Omni/Jr/Web/Style.hs
@@ -1597,6 +1597,23 @@ unifiedTimelineStyles = do
     backgroundColor "#f3f4f6"
     border (px 1) solid "#d1d5db"
     Stylesheet.key "animation" ("none" :: Text)
+  ".timeline-autoscroll-toggle" ? do
+    fontSize (px 10)
+    fontWeight bold
+    color "#3b82f6"
+    backgroundColor "#dbeafe"
+    padding (px 2) (px 6) (px 2) (px 6)
+    borderRadius (px 10) (px 10) (px 10) (px 10)
+    marginLeft (px 4)
+    border (px 1) solid "#93c5fd"
+    cursor pointer
+    Stylesheet.key "transition" ("all 0.2s ease" :: Text)
+  ".timeline-autoscroll-toggle:hover" ? do
+    Stylesheet.key "box-shadow" ("0 0 6px rgba(59,130,246,0.3)" :: Text)
+  ".timeline-autoscroll-toggle.timeline-autoscroll-disabled" ? do
+    color "#6b7280"
+    backgroundColor "#f3f4f6"
+    border (px 1) solid "#d1d5db"
   ".timeline-live" ? do
     fontSize (px 10)
     fontWeight bold