feat(app): add infinite home session scrolling

This commit is contained in:
Brendan Allan
2026-07-13 16:20:30 +08:00
parent dd02cea9e7
commit e2aa7e7717
3 changed files with 367 additions and 172 deletions
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test"
import {
homeSessionActiveHeaderIndex,
homeSessionListRows,
homeSessionListRowSize,
shouldLoadMoreHomeSessions,
} from "./home-session-list"
describe("homeSessionListRows", () => {
test("flattens groups with stable headers and spacing metadata", () => {
const rows = homeSessionListRows(
[
{ id: "today", title: "Today", sessions: ["a", "b"] },
{ id: "older", title: "Older", sessions: ["c"] },
],
(session) => `session:${session}`,
)
expect(rows.map((row) => row.key)).toEqual(["header:today", "session:a", "session:b", "header:older", "session:c"])
expect(rows.map(homeSessionListRowSize)).toEqual([44, 41, 64, 44, 40])
expect(rows[2]).toMatchObject({ type: "session", last: true, finalGroup: false })
expect(rows[4]).toMatchObject({ type: "session", last: true, finalGroup: true })
expect(homeSessionActiveHeaderIndex(rows, 2)).toBe(0)
expect(homeSessionActiveHeaderIndex(rows, 4)).toBe(3)
})
})
describe("shouldLoadMoreHomeSessions", () => {
test("loads within the bottom threshold", () => {
expect(shouldLoadMoreHomeSessions({ scrollTop: 601, scrollHeight: 1_400, clientHeight: 400, threshold: 400 })).toBe(
true,
)
expect(shouldLoadMoreHomeSessions({ scrollTop: 600, scrollHeight: 1_400, clientHeight: 400, threshold: 400 })).toBe(
false,
)
})
test("fills a viewport taller than its content", () => {
expect(shouldLoadMoreHomeSessions({ scrollTop: 0, scrollHeight: 300, clientHeight: 600, threshold: 400 })).toBe(true)
})
})
@@ -0,0 +1,53 @@
export type HomeSessionListGroup<T> = {
id: string
title: string
sessions: T[]
}
export type HomeSessionListRow<T> =
| {
type: "header"
key: string
group: HomeSessionListGroup<T>
}
| {
type: "session"
key: string
group: HomeSessionListGroup<T>
record: T
last: boolean
finalGroup: boolean
}
export function homeSessionListRows<T>(groups: HomeSessionListGroup<T>[], key: (record: T) => string) {
return groups.flatMap((group, groupIndex) => [
{ type: "header" as const, key: `header:${group.id}`, group },
...group.sessions.map((record, index) => ({
type: "session" as const,
key: key(record),
group,
record,
last: index === group.sessions.length - 1,
finalGroup: groupIndex === groups.length - 1,
})),
])
}
export function homeSessionListRowSize<T>(row: HomeSessionListRow<T>) {
if (row.type === "header") return 44
if (!row.last) return 41
return row.finalGroup ? 40 : 64
}
export function homeSessionActiveHeaderIndex<T>(rows: HomeSessionListRow<T>[], index: number) {
return rows.slice(0, index + 1).findLastIndex((row) => row.type === "header")
}
export function shouldLoadMoreHomeSessions(input: {
scrollTop: number
scrollHeight: number
clientHeight: number
threshold: number
}) {
return input.scrollHeight - input.scrollTop - input.clientHeight < input.threshold
}
+273 -172
View File
@@ -1,6 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import {
type ComponentProps,
createEffect,
createMemo,
createResource,
@@ -17,6 +16,7 @@ import {
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore, produce } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { createVirtualizer } from "@tanstack/solid-virtual"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -69,11 +69,18 @@ import { archiveHomeSession } from "./home-session-archive"
import { shouldOpenSessionInBackground } from "./home-session-open"
import { showToast } from "@/utils/toast"
import { fileManagerApp } from "@/utils/file-manager"
import {
homeSessionActiveHeaderIndex,
homeSessionListRows,
homeSessionListRowSize,
shouldLoadMoreHomeSessions,
} from "./home-session-list"
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
const HOME_SESSION_LOAD_THRESHOLD = 400
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"
@@ -139,107 +146,6 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(el: HTMLDivElement) {
viewport = el
bindResizeObserver()
queuePositionUpdate()
}
function setContentRef(el: HTMLDivElement) {
content = el
bindResizeObserver()
queuePositionUpdate()
}
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
headerRefs.set(id, el)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
headerOffsets.set(group.id, el.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.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 nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function titleOpacity(id: HomeSessionGroup["id"]) {
return state.titleOpacity[id] ?? 1
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
// background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
@@ -303,6 +209,9 @@ export function NewHome() {
if (!project) return projects().flatMap(directories)
return directories(project)
})
const sessionScope = createMemo(
() => `${selection().server}\0${projectDirectories().map(pathKey).sort().join("\0")}`,
)
const search = createMemo(() => state.search.trim())
const searchPlaceholder = createMemo(() => {
const project = selectedProject()
@@ -340,21 +249,18 @@ export function NewHome() {
projectByID,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const searchResults = createMemo(() => {
const query = search().toLowerCase()
if (!query) return []
return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language))
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
const prefetched = new Set<string>()
createEffect(() => {
const ctx = focusedServerCtx()
if (!ctx) return
records()
allRecords()
.slice(0, 2)
.forEach((record) => {
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
@@ -590,69 +496,21 @@ export function NewHome() {
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)}
>
<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>
}
>
<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>
</ScrollView>
<Show when={sessionScope()} keyed>
<HomeSessionList
records={allRecords()}
directories={projectDirectories()}
sync={focusedSync()}
loading={sessionLoad.isLoading}
canCreateSession={!!newSessionProject()}
showProjectName={!selectedProject()}
server={selection().server}
openNewSession={openNewSession}
openSession={openSession}
archiveSession={archiveSession}
language={language}
/>
</Show>
</section>
<HomeUtilityNav
class="flex lg:hidden"
@@ -665,6 +523,251 @@ export function NewHome() {
)
}
function HomeSessionList(props: {
records: HomeSessionRecord[]
directories: string[]
sync: ServerSync
loading: boolean
canCreateSession: boolean
showProjectName: boolean
server: ServerConnection.Key
openNewSession: () => void
openSession: (session: Session, options?: OpenSessionOptions) => void
archiveSession: (session: Session) => Promise<void>
language: ReturnType<typeof useLanguage>
}) {
const [state, setState] = createStore({
limit: HOME_SESSION_LIMIT,
loadingMore: false,
scrollTop: 0,
viewport: undefined as HTMLDivElement | undefined,
})
const records = createMemo(() => props.records.slice(0, state.limit))
const groups = createMemo(() => groupSessions(records(), props.language))
const rows = createMemo(() => homeSessionListRows(groups(), homeSessionSearchKey))
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.key, row] as const)))
const hasMore = createMemo(() => props.records.length > state.limit || props.directories.some(directoryHasMore))
let fillFrame: number | undefined
let disposed = false
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return rows().length
},
getScrollElement: () => state.viewport ?? null,
estimateSize: (index) => {
const row = rows()[index]
return row ? homeSessionListRowSize(row) : 40
},
overscan: 10,
paddingStart: 12,
paddingEnd: 64,
get getItemKey() {
const current = rows()
return (index: number) => current[index]?.key ?? index
},
onChange: scheduleFill,
})
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
const activeHeaderIndex = createMemo(() => {
const offset = state.scrollTop + HOME_SESSION_HEADER_STICKY_TOP
const index = virtualizer.measurementsCache.findLastIndex((item) => item.start <= offset)
return homeSessionActiveHeaderIndex(rows(), Math.max(0, index))
})
const activeHeader = createMemo(() => {
const row = rows()[activeHeaderIndex()]
return row?.type === "header" ? row : undefined
})
createEffect(
on(
() => [props.loading, state.loadingMore, state.limit, props.records.length, hasMore()] as const,
scheduleFill,
),
)
onCleanup(() => {
disposed = true
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})
function directoryHasMore(directory: string) {
const store = props.sync.child(directory, { bootstrap: false })[0]
return store.sessionTotal > sortedRootSessions(store, Date.now()).length
}
async function loadMore() {
if (props.loading || state.loadingMore || !hasMore()) return
const limit = state.limit + HOME_SESSION_LIMIT
setState("loadingMore", true)
try {
await Promise.all(
props.directories
.filter(directoryHasMore)
.map((directory) => props.sync.project.loadSessions(directory, { limit })),
)
if (!disposed) setState("limit", limit)
} finally {
if (!disposed) {
setState("loadingMore", false)
scheduleFill()
}
}
}
function scheduleFill() {
if (fillFrame !== undefined || !state.viewport) return
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
const viewport = state.viewport
if (!viewport || props.loading || state.loadingMore || !hasMore()) return
if (
!shouldLoadMoreHomeSessions({
scrollTop: viewport.scrollTop,
scrollHeight: viewport.scrollHeight,
clientHeight: viewport.clientHeight,
threshold: HOME_SESSION_LOAD_THRESHOLD,
})
)
return
void loadMore()
})
}
function bindViewport(viewport: HTMLDivElement) {
setState("viewport", viewport)
scheduleFill()
}
function handleScroll(viewport: HTMLDivElement) {
setState("scrollTop", viewport.scrollTop)
if (
shouldLoadMoreHomeSessions({
scrollTop: viewport.scrollTop,
scrollHeight: viewport.scrollHeight,
clientHeight: viewport.clientHeight,
threshold: HOME_SESSION_LOAD_THRESHOLD,
})
)
void loadMore()
}
function headerTitleOpacity(index: number) {
if (index !== activeHeaderIndex()) return 1
state.scrollTop
const next = rows().findIndex((row, rowIndex) => rowIndex > index && row.type === "header")
if (next < 0) return 1
const measurement = virtualizer.measurementsCache[next]
if (!measurement) return 1
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
return Math.max(
0,
Math.min(1, (measurement.start - state.scrollTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE),
)
}
return (
<div class="mt-3 -mr-3 min-h-0 flex-1 relative">
<Show when={activeHeader()}>
{(header) => (
<div
data-component="home-session-sticky-header"
class="pointer-events-none absolute top-3 left-0 right-3 z-[5]"
>
<HomeSessionGroupHeader
title={header().group.title}
titleOpacity={headerTitleOpacity(activeHeaderIndex())}
elevated
/>
</div>
)}
</Show>
<ScrollView class="h-full relative" viewportRef={bindViewport} onScroll={(event) => handleScroll(event.currentTarget)}>
<Show when={groups().length > 0 && props.canCreateSession}>
<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={props.openNewSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show
when={!props.loading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
</div>
}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={props.canCreateSession ? props.openNewSession : undefined} />}
>
<div
data-component="home-session-list"
data-total-rows={records().length}
class="relative min-w-0 pr-3"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
<For each={virtualRowKeys()}>
{(key) => (
<Show when={virtualItemByKey().get(key)}>
{(item) => (
<Show when={rowByKey().get(String(key))}>
{(row) => {
const value = row()
return (
<div
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
"z-index": value.type === "header" ? 10 : undefined,
}}
>
{value.type === "session" ? (
<HomeSessionRow
record={value.record}
showProjectName={props.showProjectName}
server={props.server}
openSession={props.openSession}
archiveSession={props.archiveSession}
/>
) : item().index !== activeHeaderIndex() ? (
<HomeSessionGroupHeader title={value.group.title} titleOpacity={1} />
) : null}
</div>
)
}}
</Show>
)}
</Show>
)}
</For>
<Show when={state.loadingMore}>
<div class="pointer-events-none absolute bottom-4 left-0 flex w-full justify-center" role="status">
<Spinner />
</div>
</Show>
</div>
</Show>
</Show>
</ScrollView>
</div>
)
}
function HomeProjectColumn(props: {
projects: LocalProject[]
recentlyClosed: LocalProject[]
@@ -1411,13 +1514,11 @@ function HomeSessionSearchResultRow(props: {
function HomeSessionGroupHeader(props: {
title: string
titleOpacity: number
ref: ComponentProps<"div">["ref"]
elevated?: boolean
}) {
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 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header relative" : ""}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}