summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJeremy Osih <[email protected]>2025-06-27 00:38:14 +0200
committerJeremy Osih <[email protected]>2025-06-27 00:38:14 +0200
commite950ad5306944fe1897949dee9573526206a6860 (patch)
tree1e3d4edc25b50664a8764d194f3e10ad770ffc7e
parent5394b5188bf192085891c457d1b054dd0dd93bdc (diff)
downloadopencode-e950ad5306944fe1897949dee9573526206a6860.tar.gz
opencode-e950ad5306944fe1897949dee9573526206a6860.zip
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]>
-rw-r--r--packages/web/src/components/Share.tsx94
-rw-r--r--packages/web/src/components/share.module.css35
2 files changed, 129 insertions, 0 deletions
diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx
index 07b12946b..5510f5a69 100644
--- a/packages/web/src/components/Share.tsx
+++ b/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>
)
}
diff --git a/packages/web/src/components/share.module.css b/packages/web/src/components/share.module.css
index 53f082c9b..a52fd1765 100644
--- a/packages/web/src/components/share.module.css
+++ b/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;
+ }
+}