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 15:21:42 +05:30 committed by GitHub
parent 958e08e109
commit dc3ff4b7aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 302 additions and 143 deletions

View file

@ -5,6 +5,7 @@ import {
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
on,
@ -78,6 +79,23 @@ const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 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 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"
@ -150,6 +168,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
@ -208,6 +227,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
function updatePositionCache() {
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) => {
const el = headerRefs.get(group.id)
if (!el) return
@ -223,7 +249,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.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 opacity =
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 openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined
let sessionViewport: HTMLDivElement | undefined
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({
search: "",
searchFocused: false,
@ -604,127 +633,158 @@ export function NewHome() {
}
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="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">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
<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">
<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
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<ScrollView
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
<section
ref={setSessionHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={language.t("sidebar.project.recentSessions")}
>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
<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)
}}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</Show>
</ScrollView>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</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
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</ScrollView>
</div>
)
}
@ -746,6 +806,7 @@ function HomeProjectColumn(props: {
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
onWheel: (event: WheelEvent) => void
}) {
const global = useGlobal()
const dialog = useDialog()
@ -762,8 +823,12 @@ function HomeProjectColumn(props: {
return (
<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")}
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="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
@ -1489,7 +1554,7 @@ function HomeSessionGroupHeader(props: {
return (
<div
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 }}>
{props.title}

View file

@ -27,6 +27,7 @@
transition: opacity 200ms ease;
cursor: default;
user-select: none;
pointer-events: auto;
opacity: 0;
}

View file

@ -72,4 +72,19 @@ describe("scrollTopFromThumbPointer", () => {
expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0)
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)
})
})

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 { createStore } from "solid-js/store"
import { useI18n } from "../context/i18n"
@ -8,7 +19,17 @@ export type ScrollViewThumbVisibility = "hover" | "scroll"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
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
/** 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">) => {
@ -65,12 +86,14 @@ export function scrollTopFromThumbPointer(input: {
clientHeight: number
scrollHeight: number
thumbHeight: number
/** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */
scrollClientHeight?: number
}) {
const padding = 8
const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
if (maxThumbTop <= 0) return 0
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) {
@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) {
const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props)
const [local, events, rest] = splitProps(
merged,
["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"],
[
"class",
"children",
"viewportRef",
"orientation",
"thumbVisibility",
"thumbContainer",
"thumbHoverTarget",
"style",
],
[
"onScroll",
"onWheel",
@ -96,6 +128,15 @@ export function ScrollView(props: ScrollViewProps) {
let viewportRef!: 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({
isHovered: false,
isDragging: false,
@ -114,7 +155,6 @@ export function ScrollView(props: ScrollViewProps) {
let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
const markScrolling = () => {
if (local.thumbVisibility !== "scroll") return
setState("isScrolling", true)
if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800)
@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) {
const thumbVisible = () => {
if (isDragging()) return true
if (local.thumbVisibility === "scroll") return isScrolling()
return isHovered()
if (isScrolling()) return true
return local.thumbVisibility === "hover" && isHovered()
}
onCleanup(() => {
@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) {
setState("showThumb", true)
const trackPadding = 8
const trackHeight = clientHeight - trackPadding * 2
const trackClientHeight = thumbMount()?.clientHeight || clientHeight
const trackHeight = trackClientHeight - trackPadding * 2
const minThumbHeight = 32
// Calculate raw thumb height based on ratio
@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) {
local.viewportRef(viewportRef)
}
createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb)
createResizeObserver(
() => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[],
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) => {
e.preventDefault()
e.stopPropagation()
setState("isDragging", true)
const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
const track = thumbMount() ?? viewportRef
thumbRef.setPointerCapture(e.pointerId)
@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) {
const { scrollHeight, clientHeight } = viewportRef
viewportRef.scrollTop = scrollTopFromThumbPointer({
pointer: e.clientY,
viewportTop: viewportRef.getBoundingClientRect().top,
viewportTop: track.getBoundingClientRect().top,
grabOffset,
clientHeight,
clientHeight: track.clientHeight,
scrollClientHeight: clientHeight,
scrollHeight,
thumbHeight: thumbHeight(),
})
@ -203,6 +269,23 @@ export function ScrollView(props: ScrollViewProps) {
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
// 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,
@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) {
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
style={local.style}
onPointerEnter={() => setState("isHovered", true)}
onPointerLeave={() => setState("isHovered", false)}
onPointerEnter={() => {
if (hoverRoot()) setState("isHovered", true)
}}
onPointerLeave={() => {
if (hoverRoot()) setState("isHovered", false)
}}
{...rest}
>
{/* Viewport */}
@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) {
{local.children}
</div>
{/* Thumb Overlay */}
{/* Thumb Overlay — optionally portaled into an external track */}
<Show when={showThumb()}>
<div
ref={thumbRef}
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
}}
/>
<Show when={thumbMount()} fallback={renderThumb()}>
{(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
</Show>
</Show>
</div>
)