Browse Source

feat(web): add scroll to last message button

Add intelligent floating scroll button for long conversations that:
- Only appears when scrolling down (direction-aware)
- Auto-hides after 3 seconds of inactivity
- Stays visible on hover to prevent accidental disappearance
- Uses consistent design patterns with repo styling
- Includes proper accessibility features

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: Jeremy Osih <[email protected]>
Co-Authored-By: opencode <[email protected]>
Jeremy Osih 8 months ago
parent
commit
e950ad5306
2 changed files with 129 additions and 0 deletions
  1. 94 0
      packages/web/src/components/Share.tsx
  2. 35 0
      packages/web/src/components/share.module.css

+ 94 - 0
packages/web/src/components/Share.tsx

@@ -40,6 +40,7 @@ import {
   IconMagnifyingGlass,
   IconWrenchScrewdriver,
   IconDocumentMagnifyingGlass,
+  IconArrowDown,
 } from "./icons"
 import DiffView from "./DiffView"
 import CodeBlock from "./CodeBlock"
@@ -721,6 +722,83 @@ export default function Share(props: {
     })
   })
 
+  const [showScrollButton, setShowScrollButton] = createSignal(false)
+  const [isButtonHovered, setIsButtonHovered] = createSignal(false)
+  let scrollTimeout: number | undefined
+  let lastScrollY = 0
+
+  const checkScrollNeed = () => {
+    const currentScrollY = window.scrollY
+    const isScrollingDown = currentScrollY > lastScrollY
+    const scrolled = currentScrollY > 200 // Show after scrolling 200px
+    const isNearBottom = window.innerHeight + currentScrollY >= document.body.scrollHeight - 100
+    
+    // Only show when scrolling down, scrolled enough, and not near bottom
+    const shouldShow = isScrollingDown && scrolled && !isNearBottom
+    
+    // Update last scroll position
+    lastScrollY = currentScrollY
+    
+    if (shouldShow) {
+      setShowScrollButton(true)
+      // Clear existing timeout
+      if (scrollTimeout) {
+        clearTimeout(scrollTimeout)
+      }
+      // Hide button after 3 seconds of no scrolling (unless hovered)
+      scrollTimeout = window.setTimeout(() => {
+        if (!isButtonHovered()) {
+          setShowScrollButton(false)
+        }
+      }, 3000)
+    } else if (!isButtonHovered()) {
+      // Only hide if not hovered (to prevent disappearing while user is about to click)
+      setShowScrollButton(false)
+      if (scrollTimeout) {
+        clearTimeout(scrollTimeout)
+      }
+    }
+  }
+
+  const handleButtonMouseEnter = () => {
+    setIsButtonHovered(true)
+    // Clear timeout when hovering
+    if (scrollTimeout) {
+      clearTimeout(scrollTimeout)
+    }
+  }
+
+  const handleButtonMouseLeave = () => {
+    setIsButtonHovered(false)
+    // Restart timeout when leaving hover
+    if (showScrollButton()) {
+      scrollTimeout = window.setTimeout(() => {
+        if (!isButtonHovered()) {
+          setShowScrollButton(false)
+        }
+      }, 3000)
+    }
+  }
+
+  const scrollToBottom = () => {
+    document.body.scrollIntoView({ behavior: "smooth", block: "end" })
+  }
+
+  onMount(() => {
+    lastScrollY = window.scrollY // Initialize scroll position
+    checkScrollNeed()
+    window.addEventListener("scroll", checkScrollNeed)
+    window.addEventListener("resize", checkScrollNeed)
+  })
+
+  onCleanup(() => {
+    window.removeEventListener("scroll", checkScrollNeed)
+    window.removeEventListener("resize", checkScrollNeed)
+    if (scrollTimeout) {
+      clearTimeout(scrollTimeout)
+    }
+  })
+
   const data = createMemo(() => {
     const result = {
       rootDir: undefined as string | undefined,
@@ -875,6 +953,7 @@ export default function Share(props: {
               </span>
             )}
           </div>
+
         </div>
       </div>
 
@@ -1975,6 +2054,21 @@ export default function Share(props: {
           </div>
         </div>
       </Show>
+
+      {/* Floating scroll to bottom button */}
+      <Show when={showScrollButton()}>
+        <button
+          type="button"
+          class={styles.scrollButton}
+          onClick={scrollToBottom}
+          onMouseEnter={handleButtonMouseEnter}
+          onMouseLeave={handleButtonMouseLeave}
+          title="Scroll to bottom"
+          aria-label="Scroll to bottom"
+        >
+          <IconArrowDown width={20} height={20} />
+        </button>
+      </Show>
     </main>
   )
 }

+ 35 - 0
packages/web/src/components/share.module.css

@@ -760,3 +760,38 @@
     }
   }
 }
+
+.scrollButton {
+  position: fixed;
+  bottom: 2rem;
+  right: 2rem;
+  width: 2.5rem;
+  height: 2.5rem;
+  border-radius: 0.25rem;
+  border: 1px solid var(--sl-color-divider);
+  background-color: var(--sl-color-bg-surface);
+  color: var(--sl-color-text-secondary);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  transition: all 0.15s ease, opacity 0.5s ease;
+  z-index: 100;
+  appearance: none;
+  opacity: 1;
+
+  &:hover {
+    color: var(--sl-color-text);
+    border-color: var(--sl-color-hairline);
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  }
+
+  &:active {
+    transform: translateY(1px);
+  }
+
+  svg {
+    display: block;
+  }
+}