-
-
-
{path()?.split("/").pop()}
-
{language.t("session.files.binaryContent")}
+
+ {
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll as any}
+ >
+
+
+
+
})
requestAnimationFrame(restoreScroll)}
+ />
-
-
-
{renderCode(contents(), "pb-40")}
-
- {language.t("common.loading")}...
-
-
{(err) => {err()}
}
-
+
+
+
+ {renderCode(svgContent() ?? "", "")}
+
+
+
})
+
+
+
+
+
+
+
+
+
{path()?.split("/").pop()}
+
{language.t("session.files.binaryContent")}
+
+
+
+
{renderCode(contents(), "pb-40")}
+
+ {language.t("common.loading")}...
+
+
{(err) => {err()}
}
+
+
)
}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 6ac89a3a7..b13ccb474 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
@@ -322,8 +323,8 @@ export function MessageTimeline(props: {
- {
const root = e.currentTarget
const delta = normalizeWheelDelta({
@@ -367,7 +368,7 @@ export function MessageTimeline(props: {
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={props.onAutoScrollInteraction}
- class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
+ class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
@@ -548,7 +549,7 @@ export function MessageTimeline(props: {
)}
-
+
)
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
index 3a9f63949..9349e9937 100644
--- a/packages/app/src/pages/session/review-tab.tsx
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -143,9 +143,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
- root: props.classes?.root ?? "pb-6",
+ root: props.classes?.root ?? "pb-6 pr-3",
header: props.classes?.header ?? "px-3",
- container: props.classes?.container ?? "px-3",
+ container: props.classes?.container ?? "pl-3",
}}
diffs={props.diffs()}
diffStyle={props.diffStyle}
diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css
new file mode 100644
index 000000000..f81ae2976
--- /dev/null
+++ b/packages/ui/src/components/scroll-view.css
@@ -0,0 +1,61 @@
+.scroll-view {
+ position: relative;
+ overflow: hidden;
+}
+
+.scroll-view__viewport {
+ height: 100%;
+ width: 100%;
+ overflow-y: auto;
+ scrollbar-width: none;
+ outline: none;
+}
+
+.scroll-view__viewport::-webkit-scrollbar {
+ display: none;
+}
+
+.scroll-view__thumb {
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 16px;
+ transition: opacity 200ms ease;
+ cursor: default;
+ user-select: none;
+ opacity: 0;
+}
+
+.scroll-view__thumb::after {
+ content: "";
+ position: absolute;
+ right: 4px;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ border-radius: 9999px;
+ background-color: var(--border-weak-base);
+ backdrop-filter: blur(4px);
+ transition: background-color 150ms ease;
+}
+
+.scroll-view__thumb:hover::after,
+.scroll-view__thumb[data-dragging="true"]::after {
+ background-color: var(--border-strong-base);
+}
+
+.dark .scroll-view__thumb::after,
+[data-theme="dark"] .scroll-view__thumb::after {
+ background-color: var(--border-weak-base);
+}
+
+.dark .scroll-view__thumb:hover::after,
+[data-theme="dark"] .scroll-view__thumb:hover::after,
+.dark .scroll-view__thumb[data-dragging="true"]::after,
+[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
+ background-color: var(--border-strong-base);
+}
+
+.scroll-view__thumb[data-visible="true"] {
+ opacity: 1;
+}
diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx
new file mode 100644
index 000000000..acc54c8c3
--- /dev/null
+++ b/packages/ui/src/components/scroll-view.tsx
@@ -0,0 +1,217 @@
+import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+
+export interface ScrollViewProps extends ComponentProps<"div"> {
+ viewportRef?: (el: HTMLDivElement) => void
+ orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
+}
+
+export function ScrollView(props: ScrollViewProps) {
+ const merged = mergeProps({ orientation: "vertical" }, props)
+ const [local, events, rest] = splitProps(
+ merged,
+ ["class", "children", "viewportRef", "orientation", "style"],
+ [
+ "onScroll",
+ "onWheel",
+ "onTouchStart",
+ "onTouchMove",
+ "onTouchEnd",
+ "onTouchCancel",
+ "onPointerDown",
+ "onClick",
+ "onKeyDown",
+ ],
+ )
+
+ let rootRef!: HTMLDivElement
+ let viewportRef!: HTMLDivElement
+ let thumbRef!: HTMLDivElement
+
+ const [isHovered, setIsHovered] = createSignal(false)
+ const [isDragging, setIsDragging] = createSignal(false)
+
+ const [thumbHeight, setThumbHeight] = createSignal(0)
+ const [thumbTop, setThumbTop] = createSignal(0)
+ const [showThumb, setShowThumb] = createSignal(false)
+
+ const updateThumb = () => {
+ if (!viewportRef) return
+ const { scrollTop, scrollHeight, clientHeight } = viewportRef
+
+ if (scrollHeight <= clientHeight || scrollHeight === 0) {
+ setShowThumb(false)
+ return
+ }
+
+ setShowThumb(true)
+ const trackPadding = 8
+ const trackHeight = clientHeight - trackPadding * 2
+
+ const minThumbHeight = 32
+ // Calculate raw thumb height based on ratio
+ let height = (clientHeight / scrollHeight) * trackHeight
+ height = Math.max(height, minThumbHeight)
+
+ const maxScrollTop = scrollHeight - clientHeight
+ const maxThumbTop = trackHeight - height
+
+ const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
+
+ // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
+ const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
+
+ setThumbHeight(height)
+ setThumbTop(boundedTop)
+ }
+
+ onMount(() => {
+ if (local.viewportRef) {
+ local.viewportRef(viewportRef)
+ }
+
+ const observer = new ResizeObserver(() => {
+ updateThumb()
+ })
+
+ observer.observe(viewportRef)
+ // Also observe the first child if possible to catch content changes
+ if (viewportRef.firstElementChild) {
+ observer.observe(viewportRef.firstElementChild)
+ }
+
+ onCleanup(() => {
+ observer.disconnect()
+ })
+
+ updateThumb()
+ })
+
+ let startY = 0
+ let startScrollTop = 0
+
+ const onThumbPointerDown = (e: PointerEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(true)
+ startY = e.clientY
+ startScrollTop = viewportRef.scrollTop
+
+ thumbRef.setPointerCapture(e.pointerId)
+
+ const onPointerMove = (e: PointerEvent) => {
+ const deltaY = e.clientY - startY
+ const { scrollHeight, clientHeight } = viewportRef
+ const maxScrollTop = scrollHeight - clientHeight
+ const maxThumbTop = clientHeight - thumbHeight()
+
+ if (maxThumbTop > 0) {
+ const scrollDelta = deltaY * (maxScrollTop / maxThumbTop)
+ viewportRef.scrollTop = startScrollTop + scrollDelta
+ }
+ }
+
+ const onPointerUp = (e: PointerEvent) => {
+ setIsDragging(false)
+ thumbRef.releasePointerCapture(e.pointerId)
+ thumbRef.removeEventListener("pointermove", onPointerMove)
+ thumbRef.removeEventListener("pointerup", onPointerUp)
+ }
+
+ thumbRef.addEventListener("pointermove", onPointerMove)
+ thumbRef.addEventListener("pointerup", onPointerUp)
+ }
+
+ // Keybinds implementation
+ // We ensure the viewport has a tabindex so it can receive focus
+ // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
+ // but native usually handles this perfectly. Let's explicitly ensure it behaves well.
+ const onKeyDown = (e: KeyboardEvent) => {
+ // If user is focused on an input inside the scroll view, don't hijack keys
+ if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
+ return
+ }
+
+ const scrollAmount = viewportRef.clientHeight * 0.8
+ const lineAmount = 40
+
+ switch (e.key) {
+ case "PageDown":
+ e.preventDefault()
+ viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
+ break
+ case "PageUp":
+ e.preventDefault()
+ viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
+ break
+ case "Home":
+ e.preventDefault()
+ viewportRef.scrollTo({ top: 0, behavior: "smooth" })
+ break
+ case "End":
+ e.preventDefault()
+ viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
+ break
+ case "ArrowUp":
+ e.preventDefault()
+ viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
+ break
+ case "ArrowDown":
+ e.preventDefault()
+ viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
+ break
+ }
+ }
+
+ return (
+ {
+ viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
}}
- onScroll={props.onScroll}
+ onScroll={props.onScroll as any}
classList={{
...(props.classList ?? {}),
[props.classes?.root ?? ""]: !!props.classes?.root,
@@ -709,6 +710,6 @@ export const SessionReview = (props: SessionReviewProps) => {
-
+
)
}
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index efe00e5f1..c0af0ac9b 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -44,6 +44,7 @@
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components);
+@import "../components/scroll-view.css" layer(components);
@import "../components/session-review.css" layer(components);
@import "../components/session-turn.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components);
diff --git a/packages/ui/src/styles/tailwind/utilities.css b/packages/ui/src/styles/tailwind/utilities.css
index be305b4cb..4318b9ec1 100644
--- a/packages/ui/src/styles/tailwind/utilities.css
+++ b/packages/ui/src/styles/tailwind/utilities.css
@@ -8,34 +8,6 @@
}
}
-@utility session-scroller {
- &::-webkit-scrollbar {
- width: 10px;
- height: 10px;
- }
-
- &::-webkit-scrollbar-track {
- background: transparent;
- border-radius: 5px;
- }
-
- &::-webkit-scrollbar-thumb {
- background: var(--border-weak-base);
- border-radius: 5px;
- border: 3px solid transparent;
- background-clip: padding-box;
- }
-
- &::-webkit-scrollbar-thumb:hover {
- background: var(--border-weak-base);
- }
-
- & {
- scrollbar-width: thin;
- scrollbar-color: var(--border-weak-base) transparent;
- }
-}
-
@utility badge-mask {
-webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);