feat(app): scroll the timeline under the composer (#57141)

This commit is contained in:
Kit Langton
2026-10-19 10:59:38 -07:00
committed by GitHub
Unverified
parent 295ec7dbd9
commit 15b5948601
11 changed files with 221 additions and 45 deletions
@@ -13,6 +13,9 @@ import {
} from "../utils/timeline"
import { capturePromptMotion, markPrompt, promptMotionIssues, readPromptPositions } from "../utils/prompt-motion"
// The thumb's track is the part of the timeline above the composer region, which the timeline scrolls on under.
const thumbSelector = '[data-slot="session-timeline-visible"] [data-orientation="vertical"]'
// Compositor prediction can add 20–25px to discrete CDP moves even in a plain
// scrollport. Disable it so the visual assertion measures the supplied gesture.
test.use({ colorScheme: "light", launchOptions: { args: ["--disable-features=ResamplingScrollEvents"] } })
@@ -147,7 +150,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
touchPoints: [],
timestamp: Date.now() / 1000,
})
await expect(reading.timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(
before + 615,
0,
@@ -204,7 +207,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(reading.start, 0)
const before = await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)
await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
await expect(reading.timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
await testInfo.attach("start-released.png", { body: await page.screenshot(), contentType: "image/png" })
expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(reading.start, 0)
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0)
@@ -314,7 +317,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
}
await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
await expect(root.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
const released = await tail.evaluate((element) => element.getBoundingClientRect().top)
await timeline.send(
partUpdated(
@@ -365,7 +368,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
await expect(tail).toBeInViewport()
await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0)
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
const before = await tail.evaluate((element) => element.getBoundingClientRect().top)
const bounds = (await scroller.boundingBox())!
const point = { x: bounds.x + 150, y: bounds.y + 200 }
@@ -491,7 +494,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
(element, distance) => (element.scrollTop = element.scrollHeight - element.clientHeight - distance),
growsBeforeDrag ? 1200 : handoff.key === "latest" ? 800 : 80,
)
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
const latest = page.getByRole("button", { name: "Jump to latest", exact: true })
if (handoff.key === "latest") await expect(latest.locator("..")).toHaveCSS("opacity", "1")
@@ -551,7 +554,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
await expect.poll(() => scroller.evaluate((element) => element.scrollHeight)).toBe(extent)
}
const thumb = usesScrollbar ? timeline.locator('.scroll-view__thumb[data-orientation="vertical"]') : undefined
const thumb = usesScrollbar ? page.locator(thumbSelector) : undefined
await thumb?.hover()
const grip = await thumb?.boundingBox()
@@ -626,7 +629,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
if (toStart) await expect(first).toBeInViewport()
if (!toStart) await expect(page.getByText("Reading 59.", { exact: true })).toBeInViewport()
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
if (handoff.held) await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
@@ -686,7 +689,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true })
const hidden = timeline.locator('[data-orientation="vertical"][data-visible="false"]')
const hidden = page.locator(`${thumbSelector}[data-visible="false"]`)
const heading = page.locator(`[data-timeline-part-id="${renderedPartID("prt_target_heading")}"]`)
const last = detach === "scroll" ? page.getByText("Part 79.", { exact: true }) : heading
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
@@ -750,7 +753,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
type: "touchMove",
touchPoints: [{ x: point.x, y: point.y + 60 }],
})
await expect(timeline.locator('[data-orientation="vertical"][data-visible="true"]')).toHaveCount(1)
await expect(page.locator(`${thumbSelector}[data-visible="true"]`)).toHaveCount(1)
await fixture.send(partDelta("prt_target_heading", "\n\nMore content."))
await expect(heading).toContainText("More content.")
await expect(heading.locator("p")).toHaveCount(2)
@@ -817,7 +820,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
await expect
.poll(() => tail.evaluate((element) => element.getBoundingClientRect().top))
.toBeGreaterThan(before + 50)
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
const chosen = await scroller.evaluate((root) => {
const view = root.getBoundingClientRect()
@@ -9,7 +9,7 @@ for (const reducedMotion of [false, true]) {
const chat = page.locator('[data-slot="session-chat-panel"]')
const scroll = page.locator('[data-slot="session-timeline-scroll"]')
const viewport = scroll.locator(".scroll-view__viewport")
const thumb = scroll.locator('.scroll-view__thumb[data-orientation="vertical"]')
const thumb = page.locator('[data-slot="session-timeline-visible"] .scroll-view__thumb')
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
await expect(thumb).toHaveCount(1)
await scroll.hover()
@@ -37,7 +37,11 @@ for (const width of [1440, 390]) {
? [row.dataset.timelineKey!]
: [],
),
bottomError: (spacer?.getBoundingClientRect().bottom ?? Infinity) - root.getBoundingClientRect().bottom,
// The viewport runs on under the composer; its visible part ends at the composer region.
bottomError:
(spacer?.getBoundingClientRect().bottom ?? Infinity) -
root.getBoundingClientRect().bottom +
parseFloat(getComputedStyle(root).scrollPaddingBottom),
tables: answer.querySelectorAll("table").length,
codeBlocks: answer.querySelectorAll("pre").length,
}
@@ -528,7 +532,9 @@ async function sampleTabPaint(page: Page, sessionID: string) {
)
if (root) {
const view = root.getBoundingClientRect()
// The part of the viewport above the composer region, which the timeline scrolls on under.
const box = root.getBoundingClientRect()
const view = { top: box.top, bottom: box.bottom - parseFloat(getComputedStyle(root).scrollPaddingBottom) }
// A frame counts only when the row is painted: inside the viewport and not hidden by CSS.
const inView = (element: Element) => {
@@ -6,6 +6,7 @@ import {
historyMessages,
partUpdated,
renderedPartID,
session,
sessionID,
setupTimeline,
shell,
@@ -630,3 +631,81 @@ async function captureCardEdges(page: Page, card: Locator) {
{ source: `data:image/png;base64,${screenshot.toString("base64")}`, viewport, box },
)
}
test("scrolls the timeline behind the composer region, fading at the card, and keeps its end above the region", async ({
page,
}) => {
await setupTimeline(page, {
sessionMessages: [
...validateTimelineMessages(historyMessages(18)),
{ id: "msg_9000_user", type: "user", text: "Split the work.", time: { created: 1 } },
{
id: "msg_9001_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_running_subagent",
name: "subagent",
state: {
status: "running",
input: { description: "Trace frame budget", agent: "explore" },
metadata: { sessionID: "ses_running_subagent" },
},
time: { created: 2 },
},
{ type: "text", id: "prt_timeline_end", text: "The last answer ends above the composer region." },
],
time: { created: 2 },
},
],
sessions: [
session(),
session({ id: "ses_running_subagent", parentID: sessionID, title: "Trace frame budget", agent: "explore" }),
],
sessionStatus: { [sessionID]: { type: "busy" }, ses_running_subagent: { type: "busy" } },
})
const dock = page.locator('[data-component="session-composer-dock"]')
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(page.getByRole("button", { name: "1 subagent", exact: true })).toBeVisible()
await expect(page.getByText("The last answer ends above the composer region.", { exact: true })).toBeVisible()
const geometry = () =>
page.evaluate(() => {
const edge = "--session-timeline-fade-edge"
const viewport = document.querySelector<HTMLElement>('[data-slot="session-timeline-scroll"] > *')!
const region = document.querySelector('[data-component="session-composer-dock"]')!
const dock = region.getBoundingClientRect()
const card = region.querySelector('[data-component="composer"]')!.getBoundingClientRect()
const parts = [...viewport.querySelectorAll("[data-timeline-part-id]")]
const last = parts.find((element) => element.textContent?.includes("The last answer ends"))!
return {
// The scroll area runs on behind the composer region to the panel's bottom edge.
behind: Math.round(viewport.getBoundingClientRect().bottom - dock.bottom),
inset: Math.round(viewport.querySelector<HTMLElement>("[data-timeline-inset]")!.offsetHeight - dock.height),
// Content stays visible behind the pill and fades out where the composer card starts.
fade: Math.round(dock.bottom - card.top - parseFloat(getComputedStyle(viewport).getPropertyValue(edge))),
behindPill: card.top - dock.top > 20,
atEnd: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= 1,
clear: last.getBoundingClientRect().bottom <= dock.top,
}
})
const expected = { behind: 0, inset: 0, fade: 0, behindPill: true, atEnd: true, clear: true }
await expect.poll(geometry).toEqual(expected)
const before = (await dock.boundingBox())!.height
await editor.click()
await page.keyboard.type("A draft")
for (const line of ["that grows", "the composer", "by several lines"]) {
await page.keyboard.press("Shift+Enter")
await page.keyboard.type(line)
}
await expect.poll(async () => (await dock.boundingBox())!.height).toBeGreaterThan(before + 40)
await expect.poll(geometry).toEqual(expected)
})
+18 -2
View File
@@ -39,11 +39,27 @@
@layer components {
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
[data-slot="session-timeline-scroll"]
> .scroll-view__thumb {
[data-slot="session-timeline-visible"]
.scroll-view__thumb {
visibility: hidden;
}
/* The timeline scrolls on under the composer region, passing behind its pill and docks, and fades out as it reaches
the composer card. A mask fades the content itself, so the edge works over any theme's background; smoothstep
stops avoid a visible horizon. */
[data-slot="session-timeline-scroll"] > .scroll-view__viewport {
--session-timeline-fade-start: var(--session-timeline-fade-edge, 0px);
scroll-padding-bottom: var(--session-timeline-inset, 0px);
mask-image: linear-gradient(
to top,
transparent var(--session-timeline-fade-start),
rgb(0 0 0 / 0.16) calc(var(--session-timeline-fade-start) + 5px),
rgb(0 0 0 / 0.5) calc(var(--session-timeline-fade-start) + 10px),
rgb(0 0 0 / 0.84) calc(var(--session-timeline-fade-start) + 15px),
#000 calc(var(--session-timeline-fade-start) + 20px)
);
}
#review-panel {
container-type: inline-size;
}
@@ -69,13 +69,13 @@ export function SessionActivityChips(props: {
return (
<Show when={props.subagents.length > 0 || props.shells.length > 0}>
<div data-component="session-activity-chips" class="flex items-center pb-2">
<div data-component="session-activity-chips" class="pointer-events-none flex items-center pb-2">
<Menu gutter={6} placement="top-start">
<Menu.Trigger
as="button"
type="button"
data-slot="session-activity-chip"
class="flex h-7 min-w-0 max-w-full items-center gap-1.5 rounded-full border border-v2-border-border-base bg-v2-background-bg-base ps-2 pe-2.5 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap tabular-nums text-v2-text-text-base shadow-[var(--v2-elevation-raised)] outline-none duration-150 ease-out animate-in fade-in motion-reduce:animate-none hover:bg-v2-background-bg-layer-01 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus"
class="pointer-events-auto flex h-7 min-w-0 max-w-full items-center gap-1.5 rounded-full border border-v2-border-border-base bg-v2-background-bg-base ps-2 pe-2.5 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap tabular-nums text-v2-text-text-base shadow-[var(--v2-elevation-raised)] outline-none duration-150 ease-out animate-in fade-in motion-reduce:animate-none hover:bg-v2-background-bg-layer-01 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus"
>
<SessionProgressIndicatorV2 class="shrink-0" />
<span class="truncate">{label()}</span>
@@ -26,11 +26,12 @@ export function SessionComposerRegion(props: {
<div
ref={controller.setDockRef}
data-component="session-composer-dock"
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none bg-v2-background-bg-base"
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none"
>
{/* Only the docks and composer take the pointer; the gaps beside them belong to the timeline underneath. */}
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"w-full px-3 *:pointer-events-auto": true,
"md:max-w-session md:mx-auto": controller.centered(),
}}
>
+19 -11
View File
@@ -242,6 +242,9 @@ function SessionScreenContent(props: {
centered={screen.centered()}
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
bottomInset={timeline.inset()}
fadeInset={timeline.edge()}
setVisibleRef={timeline.view.setVisibleRef}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setRevealPart={timeline.view.setRevealPart}
@@ -290,7 +293,7 @@ function SessionScreenContent(props: {
throw error()
}}
</Show>
<div class="relative flex-1 min-h-0 overflow-hidden">
<div class="relative isolate flex-1 min-h-0 overflow-hidden">
<Show when={!isDesktop() && session.identity.params.id}>
<For each={cachedViews()}>
{(entry) => (
@@ -336,16 +339,21 @@ function SessionScreenContent(props: {
</Switch>
</div>
<Show when={composer.active()} keyed>
{(model) => (
<ActiveSessionComposerRegion
session={session}
model={model}
suggestionBoundary={timeline.scroller}
onReveal={timeline.actions.revealPart}
/>
)}
</Show>
{/* The composer region overflows this zero-height row upward, over a timeline that scrolls on underneath it. It
stays shallower than the timeline's observed elements, so their resizes from its inset arrive in the same
ResizeObserver pass. */}
<div class="relative z-[70] flex h-0 shrink-0 flex-col justify-end">
<Show when={composer.active()} keyed>
{(model) => (
<ActiveSessionComposerRegion
session={session}
model={model}
suggestionBoundary={timeline.visible}
onReveal={timeline.actions.revealPart}
/>
)}
</Show>
</div>
<Show when={!isDesktop() && !!session.identity.params.id && bottomMobileTabs()}>{mobileTabs()}</Show>
</>
)
@@ -19,10 +19,15 @@ export function createSessionTimelineInteraction(session: SessionModel) {
pendingMessage: string | undefined
scroll: { overflow: boolean; jump: boolean }
follow: { sessionKey: string; pinned: boolean }
/** The composer region's height; the timeline scrolls on underneath it. */
inset: number
/** Distance from the composer region's bottom to the top of its opaque card, where the timeline fades out. */
edge: number
refs: {
scroller: HTMLDivElement | undefined
content: HTMLDivElement | undefined
dock: HTMLDivElement | undefined
visible: HTMLDivElement | undefined
}
}>({
messageID: undefined,
@@ -35,10 +40,13 @@ export function createSessionTimelineInteraction(session: SessionModel) {
sessionKey: session.identity.sessionKey(),
pinned: true,
},
inset: 0,
edge: 0,
refs: {
scroller: undefined,
content: undefined,
dock: undefined,
visible: undefined,
},
})
@@ -53,7 +61,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
}
let scroller: HTMLDivElement | undefined
let dockHeight = 0
// The history entry whose tool call was already revealed.
let revealed: string | undefined
@@ -354,17 +361,25 @@ export function createSessionTimelineInteraction(session: SessionModel) {
)
createResizeObserver(
() => state.refs.dock,
({ height }) => {
const next = Math.ceil(height)
(_rect, element, entry) => {
const next = Math.ceil(entry.borderBoxSize[0]?.blockSize ?? 0)
// Only the activity pill floats with gaps beside it; the docks, queue and composer below it form one opaque
// card. The pill changes the region's height as it comes and goes, so this observer sees every move of the edge.
const region = element.getBoundingClientRect()
const pill = element.querySelector('[data-component="session-activity-chips"]')?.getBoundingClientRect()
const edge = pill && pill.top - region.top < 1 ? Math.round(region.bottom - pill.bottom) : next
if (next === dockHeight) return
const delta = next - dockHeight
if (edge !== state.edge) setState("edge", edge)
if (next === state.inset) return
const delta = next - state.inset
// Measured before the new inset applies: a shrinking region also keeps a reader whose end it uncovers.
const stick = scroller
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, -delta)
: false
dockHeight = next
setState("inset", next)
if (stick) scrollToEnd()
@@ -388,11 +403,15 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resume,
setActiveMessage,
},
inset: () => state.inset,
edge: () => state.edge,
lastUserMessage: timeline.lastUserMessage,
resource: timeline.resource,
ready: timeline.ready,
scroll: state.scroll,
scroller: () => state.refs.scroller,
/** The part of the timeline not covered by the composer region. */
visible: () => state.refs.visible,
view: {
anchor,
markUserScroll,
@@ -409,6 +428,9 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setDockRef: (element: HTMLDivElement | undefined) => {
setState("refs", "dock", element)
},
setVisibleRef: (element: HTMLDivElement | undefined) => {
setState("refs", "visible", element)
},
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
revealMessage = reveal
},
@@ -80,6 +80,9 @@ type MessageTimelineProps = {
centered: boolean
reserveReviewToggle: boolean
setContentRef: (el: HTMLDivElement) => void
bottomInset?: number
fadeInset?: number
setVisibleRef?: (el: HTMLDivElement | undefined) => void
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setRevealPart?: (fn: (target: string) => boolean) => void
@@ -173,6 +176,9 @@ function MessageTimelineView(
onResumeScroll: props.onResumeScroll,
setScrollRef: props.setScrollRef,
setContentRef: props.setContentRef,
bottomInset: () => props.bottomInset ?? 0,
fadeInset: () => props.fadeInset ?? 0,
setVisibleRef: props.setVisibleRef,
onScheduleScrollState: props.onScheduleScrollState,
onPin: props.onPin,
onUnpin: props.onUnpin,
@@ -67,6 +67,11 @@ type Input = {
onResumeScroll: (behavior?: ScrollBehavior) => void
setScrollRef: (element: HTMLDivElement | undefined) => void
setContentRef: (element: HTMLDivElement) => void
/** Height of the composer region floating over the timeline's bottom edge. */
bottomInset?: Accessor<number>
/** Height from the bottom edge to where the timeline fades out, the top of the composer region's card. */
fadeInset?: Accessor<number>
setVisibleRef?: (element: HTMLDivElement | undefined) => void
onScheduleScrollState: (element: HTMLDivElement) => void
onPin: () => void
onUnpin: () => void
@@ -102,6 +107,9 @@ export function createTimelineVirtualizer(input: Input) {
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [visibleArea, setVisibleArea] = createSignal<HTMLDivElement>()
const [viewRoot, setViewRoot] = createSignal<HTMLDivElement>()
const bottomInset = () => input.bottomInset?.() ?? 0
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const patchGroupKeys = cached?.patchGroupKeys ?? new Map<string, string>()
createEffect(
@@ -198,11 +206,13 @@ export function createTimelineVirtualizer(input: Input) {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
// The viewport runs on under the composer region. The virtualizer sees only the part above it, which the
// inset spacer below the rows keeps consistent with the native scroll range.
observeElementRect: (instance, callback) => {
reportRect = callback
reportRect = (rect) => callback({ width: rect.width, height: Math.max(0, rect.height - bottomInset()) })
return observeElementRect(instance, (rect) => {
if (active()) callback(rect)
if (active()) reportRect?.(rect)
})
},
// Route navigation detaches and reattaches the scroll element, which drops its offset.
@@ -498,6 +508,7 @@ export function createTimelineVirtualizer(input: Input) {
if (root) input.setScrollRef(root)
if (virtualContent) input.setContentRef(virtualContent)
input.setVisibleRef?.(visibleArea())
queueMicrotask(() => {
if (!active() || !root?.isConnected) return
// A detached view can miss its nonzero ResizeObserver delivery. Publish
@@ -544,6 +555,18 @@ export function createTimelineVirtualizer(input: Input) {
})
})
createEffect(
on(
bottomInset,
() => {
const root = listRoot()
if (active() && root?.isConnected) reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
},
{ defer: true },
),
)
let coldPending = coldBottomMount
let settleQueued = false
let contentObserver: MutationObserver | undefined
@@ -849,11 +872,17 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div class="relative w-full h-full min-w-0">
<div
ref={setViewRoot}
class="relative w-full h-full min-w-0"
style={{
"--session-timeline-inset": `${bottomInset()}px`,
"--session-timeline-fade-edge": `${input.fadeInset?.() ?? 0}px`,
}}
>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-[opacity,translate]"
class="absolute bottom-[calc(var(--session-timeline-inset)+32px)] left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-[opacity,translate]"
classList={{
"bottom-8": true,
"opacity-100 translate-y-0 duration-150 ease-[cubic-bezier(.2,.8,.2,1)]":
input.scroll().overflow && input.scroll().jump,
"opacity-0 translate-y-1 motion-reduce:translate-y-0 duration-[110ms] ease-[cubic-bezier(.4,0,1,1)]":
@@ -869,9 +898,7 @@ export function createTimelineVirtualizer(input: Input) {
"background-color": "color-mix(in srgb, var(--v2-background-bg-base) 92%, transparent)",
"box-shadow": "var(--v2-elevation-raised), 0px 2px 8px var(--v2-background-bg-base)",
}}
onClick={() =>
input.onResumeScroll(prefersReducedMotion() ? "auto" : "smooth")
}
onClick={() => input.onResumeScroll(prefersReducedMotion() ? "auto" : "smooth")}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
@@ -885,6 +912,8 @@ export function createTimelineVirtualizer(input: Input) {
<ScrollView
data-slot="session-timeline-scroll"
viewportRef={bindListRoot}
thumbContainer={visibleArea()}
thumbHoverTarget={viewRoot()}
onBeforeScroll={prepareNavigation}
verticalScrollAdjustment={rendering.scrollAdjustment}
onWheel={handleListWheel}
@@ -922,7 +951,13 @@ export function createTimelineVirtualizer(input: Input) {
{props.bottomSpacer}
</div>
</div>
<div aria-hidden="true" data-timeline-inset class="h-[var(--session-timeline-inset)] shrink-0" />
</ScrollView>
<div
ref={setVisibleArea}
data-slot="session-timeline-visible"
class="pointer-events-none absolute inset-x-0 top-0 bottom-[var(--session-timeline-inset)]"
/>
</div>
)
}