fix(app): home page scroll target (#36664)

Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Aarav Sareen
2026-07-16 09:51:42 +00:00
committed by GitHub
co-authored by Jay V Brendan Allan
parent 958e08e109
commit dc3ff4b7aa
4 changed files with 302 additions and 143 deletions
+78 -13
View File
@@ -5,6 +5,7 @@ import {
createMemo, createMemo,
createResource, createResource,
createRoot, createRoot,
createSignal,
For, For,
Match, Match,
on, on,
@@ -78,6 +79,23 @@ const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12 const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16 const HOME_SESSION_HEADER_FADE_DISTANCE = 16
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
const SHOW_HOME_SESSION_ARCHIVE = false const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
@@ -150,6 +168,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let content: HTMLDivElement | undefined let content: HTMLDivElement | undefined
let positionFrame: number | undefined let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>() const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>() const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({ const [state, setState] = createStore({
@@ -208,6 +227,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
function updatePositionCache() { function updatePositionCache() {
if (!viewport) return if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((el) => el !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => { groups().forEach((group) => {
const el = headerRefs.get(group.id) const el = headerRefs.get(group.id)
if (!el) return if (!el) return
@@ -223,7 +249,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
.slice(index + 1) .slice(index + 1)
.map((item) => headerOffsets.get(item.id)) .map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined) .find((offset) => offset !== undefined)
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity = const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
@@ -277,6 +303,9 @@ export function NewHome() {
const marked = useMarked() const marked = useMarked()
const openSettings = useSettingsCommand() const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined let focusSessionSearch: (() => void) | undefined
let sessionViewport: HTMLDivElement | undefined
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({ const [state, setState] = createStore({
search: "", search: "",
searchFocused: false, searchFocused: false,
@@ -604,8 +633,23 @@ export function NewHome() {
} }
return ( return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1"> <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6"> <ScrollView
class="h-full [container-type:size]"
thumbContainer={sessionThumbTrack}
thumbHoverTarget={sessionHoverTarget}
viewportRef={(el) => {
sessionViewport = el
sessionHeaderOpacity.setViewport(el)
}}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
onWheel={(event) => {
if (!sessionViewport) return
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
containHomeWheel(event, sessionViewport)
}}
>
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn <HomeProjectColumn
projects={projects()} projects={projects()}
recentlyClosed={recentlyClosed()} recentlyClosed={recentlyClosed()}
@@ -631,11 +675,21 @@ export function NewHome() {
openSettings={openSettings} openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language} language={language}
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
/> />
<section <section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative" ref={setSessionHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={language.t("sidebar.project.recentSessions")} aria-label={language.t("sidebar.project.recentSessions")}
>
<div
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
> >
<HomeSessionSearch <HomeSessionSearch
value={state.search} value={state.search}
@@ -654,13 +708,8 @@ export function NewHome() {
onClose={closeSearch} onClose={closeSearch}
onSelect={selectSearchSession} onSelect={selectSearchSession}
/> />
<ScrollView
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
>
<Show when={groups().length > 0 && newSessionProject()}> <Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex"> <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2 <ButtonV2
data-action="home-new-session" data-action="home-new-session"
variant="ghost-muted" variant="ghost-muted"
@@ -673,6 +722,16 @@ export function NewHome() {
</ButtonV2> </ButtonV2>
</div> </div>
</Show> </Show>
</div>
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
ref={setSessionThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show <Show
when={!sessionLoad.isLoading} when={!sessionLoad.isLoading}
fallback={ fallback={
@@ -716,7 +775,7 @@ export function NewHome() {
</div> </div>
</Show> </Show>
</Show> </Show>
</ScrollView> </div>
</section> </section>
<HomeUtilityNav <HomeUtilityNav
class="flex lg:hidden" class="flex lg:hidden"
@@ -725,6 +784,7 @@ export function NewHome() {
language={language} language={language}
/> />
</div> </div>
</ScrollView>
</div> </div>
) )
} }
@@ -746,6 +806,7 @@ function HomeProjectColumn(props: {
openSettings: () => void openSettings: () => void
openHelp: () => void openHelp: () => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
onWheel: (event: WheelEvent) => void
}) { }) {
const global = useGlobal() const global = useGlobal()
const dialog = useDialog() const dialog = useDialog()
@@ -762,8 +823,12 @@ function HomeProjectColumn(props: {
return ( return (
<aside <aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]" class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
aria-label={props.language.t("home.projects")} aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
> >
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3"> <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div> <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
@@ -1489,7 +1554,7 @@ function HomeSessionGroupHeader(props: {
return ( return (
<div <div
ref={props.ref} ref={props.ref}
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`} class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
> >
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}> <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title} {props.title}
@@ -27,6 +27,7 @@
transition: opacity 200ms ease; transition: opacity 200ms ease;
cursor: default; cursor: default;
user-select: none; user-select: none;
pointer-events: auto;
opacity: 0; opacity: 0;
} }
@@ -72,4 +72,19 @@ describe("scrollTopFromThumbPointer", () => {
expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0) expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0)
expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400) expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400)
}) })
test("uses scrollClientHeight when the thumb track differs from the viewport", () => {
const input = {
pointer: 400,
viewportTop: 100,
grabOffset: 0,
clientHeight: 400,
scrollClientHeight: 800,
scrollHeight: 8_000,
thumbHeight: 40,
}
// track usable = 400 - 16 - 40 = 344; thumbTop = 400 - 100 - 8 = 292
// maxScroll = 8000 - 800 = 7200 → 292/344 * 7200
expect(scrollTopFromThumbPointer(input)).toBeCloseTo((292 / 344) * 7200)
})
}) })
+103 -25
View File
@@ -1,4 +1,15 @@
import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" import {
createEffect,
createMemo,
mergeProps,
onCleanup,
onMount,
Show,
splitProps,
type Accessor,
type ComponentProps,
} from "solid-js"
import { Portal } from "solid-js/web"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
@@ -8,7 +19,17 @@ export type ScrollViewThumbVisibility = "hover" | "scroll"
export interface ScrollViewProps extends ComponentProps<"div"> { export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void viewportRef?: (el: HTMLDivElement) => void
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
/**
* `hover`: show while hovered or scrolling. `scroll`: show only while scrolling.
*
* In most cases, scrolling a container = hovering over it, so this change has no effect.
* This is a special case to account for the home page scroll, where scrolling a container != hovering over it
* */
thumbVisibility?: ScrollViewThumbVisibility thumbVisibility?: ScrollViewThumbVisibility
/** Mount the thumb into an external track. Scroll metrics still come from this ScrollView. */
thumbContainer?: HTMLElement | Accessor<HTMLElement | undefined>
/** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */
thumbHoverTarget?: HTMLElement | Accessor<HTMLElement | undefined>
} }
export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => { export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
@@ -65,12 +86,14 @@ export function scrollTopFromThumbPointer(input: {
clientHeight: number clientHeight: number
scrollHeight: number scrollHeight: number
thumbHeight: number thumbHeight: number
/** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */
scrollClientHeight?: number
}) { }) {
const padding = 8 const padding = 8
const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
if (maxThumbTop <= 0) return 0 if (maxThumbTop <= 0) return 0
const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop)) const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop))
return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight) return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight))
} }
export function ScrollView(props: ScrollViewProps) { export function ScrollView(props: ScrollViewProps) {
@@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) {
const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props) const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props)
const [local, events, rest] = splitProps( const [local, events, rest] = splitProps(
merged, merged,
["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"], [
"class",
"children",
"viewportRef",
"orientation",
"thumbVisibility",
"thumbContainer",
"thumbHoverTarget",
"style",
],
[ [
"onScroll", "onScroll",
"onWheel", "onWheel",
@@ -96,6 +128,15 @@ export function ScrollView(props: ScrollViewProps) {
let viewportRef!: HTMLDivElement let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement let thumbRef!: HTMLDivElement
const resolveEl = (value: HTMLElement | Accessor<HTMLElement | undefined> | undefined) => {
if (typeof value === "function") return value()
return value
}
const thumbMount = createMemo(() => resolveEl(local.thumbContainer))
const thumbHover = createMemo(() => resolveEl(local.thumbHoverTarget))
const hoverRoot = () => !local.thumbHoverTarget && !local.thumbContainer
const [state, setState] = createStore({ const [state, setState] = createStore({
isHovered: false, isHovered: false,
isDragging: false, isDragging: false,
@@ -114,7 +155,6 @@ export function ScrollView(props: ScrollViewProps) {
let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
const markScrolling = () => { const markScrolling = () => {
if (local.thumbVisibility !== "scroll") return
setState("isScrolling", true) setState("isScrolling", true)
if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer) if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800) scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800)
@@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) {
const thumbVisible = () => { const thumbVisible = () => {
if (isDragging()) return true if (isDragging()) return true
if (local.thumbVisibility === "scroll") return isScrolling() if (isScrolling()) return true
return isHovered() return local.thumbVisibility === "hover" && isHovered()
} }
onCleanup(() => { onCleanup(() => {
@@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) {
setState("showThumb", true) setState("showThumb", true)
const trackPadding = 8 const trackPadding = 8
const trackHeight = clientHeight - trackPadding * 2 const trackClientHeight = thumbMount()?.clientHeight || clientHeight
const trackHeight = trackClientHeight - trackPadding * 2
const minThumbHeight = 32 const minThumbHeight = 32
// Calculate raw thumb height based on ratio // Calculate raw thumb height based on ratio
@@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) {
local.viewportRef(viewportRef) local.viewportRef(viewportRef)
} }
createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb) createResizeObserver(
() => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[],
updateThumb,
)
updateThumb() updateThumb()
}) })
createEffect(() => {
thumbMount()
updateThumb()
})
createEffect(() => {
const target = thumbHover()
if (!target) return
const enter = () => setState("isHovered", true)
const leave = () => setState("isHovered", false)
target.addEventListener("pointerenter", enter)
target.addEventListener("pointerleave", leave)
onCleanup(() => {
target.removeEventListener("pointerenter", enter)
target.removeEventListener("pointerleave", leave)
setState("isHovered", false)
})
})
const onThumbPointerDown = (e: PointerEvent) => { const onThumbPointerDown = (e: PointerEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setState("isDragging", true) setState("isDragging", true)
const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
const track = thumbMount() ?? viewportRef
thumbRef.setPointerCapture(e.pointerId) thumbRef.setPointerCapture(e.pointerId)
@@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) {
const { scrollHeight, clientHeight } = viewportRef const { scrollHeight, clientHeight } = viewportRef
viewportRef.scrollTop = scrollTopFromThumbPointer({ viewportRef.scrollTop = scrollTopFromThumbPointer({
pointer: e.clientY, pointer: e.clientY,
viewportTop: viewportRef.getBoundingClientRect().top, viewportTop: track.getBoundingClientRect().top,
grabOffset, grabOffset,
clientHeight, clientHeight: track.clientHeight,
scrollClientHeight: clientHeight,
scrollHeight, scrollHeight,
thumbHeight: thumbHeight(), thumbHeight: thumbHeight(),
}) })
@@ -203,6 +269,23 @@ export function ScrollView(props: ScrollViewProps) {
thumbRef.addEventListener("pointercancel", done) thumbRef.addEventListener("pointercancel", done)
} }
const renderThumb = () => (
<div
ref={(el) => {
thumbRef = el
}}
onPointerDown={onThumbPointerDown}
class="scroll-view__thumb"
data-visible={thumbVisible()}
data-dragging={isDragging()}
style={{
height: `${thumbHeight()}px`,
transform: `translateY(${thumbTop()}px)`,
"z-index": 100, // ensure it displays over content
}}
/>
)
// Keybinds implementation // Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus // 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, // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) {
ref={rootRef} ref={rootRef}
class={`scroll-view ${local.class || ""}`} class={`scroll-view ${local.class || ""}`}
style={local.style} style={local.style}
onPointerEnter={() => setState("isHovered", true)} onPointerEnter={() => {
onPointerLeave={() => setState("isHovered", false)} if (hoverRoot()) setState("isHovered", true)
}}
onPointerLeave={() => {
if (hoverRoot()) setState("isHovered", false)
}}
{...rest} {...rest}
> >
{/* Viewport */} {/* Viewport */}
@@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) {
{local.children} {local.children}
</div> </div>
{/* Thumb Overlay */} {/* Thumb Overlay — optionally portaled into an external track */}
<Show when={showThumb()}> <Show when={showThumb()}>
<div <Show when={thumbMount()} fallback={renderThumb()}>
ref={thumbRef} {(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
onPointerDown={onThumbPointerDown} </Show>
class="scroll-view__thumb"
data-visible={thumbVisible()}
data-dragging={isDragging()}
style={{
height: `${thumbHeight()}px`,
transform: `translateY(${thumbTop()}px)`,
"z-index": 100, // ensure it displays over content
}}
/>
</Show> </Show>
</div> </div>
) )