diff --git a/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts b/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts index 4e73b6c60a3..26f3af176d2 100644 --- a/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts +++ b/packages/app/e2e/regression/mobile-timeline-scroll.spec.ts @@ -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() diff --git a/packages/app/e2e/regression/session-panel-scrollbar.spec.ts b/packages/app/e2e/regression/session-panel-scrollbar.spec.ts index c1c9f626f9a..8eeda952ba2 100644 --- a/packages/app/e2e/regression/session-panel-scrollbar.spec.ts +++ b/packages/app/e2e/regression/session-panel-scrollbar.spec.ts @@ -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() diff --git a/packages/app/e2e/regression/timeline-cache.spec.ts b/packages/app/e2e/regression/timeline-cache.spec.ts index a5c32ed2f19..dc228b4e4eb 100644 --- a/packages/app/e2e/regression/timeline-cache.spec.ts +++ b/packages/app/e2e/regression/timeline-cache.spec.ts @@ -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) => { diff --git a/packages/app/e2e/regression/timeline-geometry.spec.ts b/packages/app/e2e/regression/timeline-geometry.spec.ts index d7fe5c80a71..067b228c5ee 100644 --- a/packages/app/e2e/regression/timeline-geometry.spec.ts +++ b/packages/app/e2e/regression/timeline-geometry.spec.ts @@ -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('[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("[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) +}) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 3d4d3cd2b74..86cf588e288 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -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; } diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index c7fe9751127..21cd10976bb 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -69,13 +69,13 @@ export function SessionActivityChips(props: { return ( 0 || props.shells.length > 0}> -
+
{label()} diff --git a/packages/app/src/session/composer/session-composer-region.tsx b/packages/app/src/session/composer/session-composer-region.tsx index d4632fc844d..f69e6258602 100644 --- a/packages/app/src/session/composer/session-composer-region.tsx +++ b/packages/app/src/session/composer/session-composer-region.tsx @@ -26,11 +26,12 @@ export function SessionComposerRegion(props: {
+ {/* Only the docks and composer take the pointer; the gaps beside them belong to the timeline underneath. */}
diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 9aed11d7984..8be90ac0db0 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -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() }} -
+
{(entry) => ( @@ -336,16 +339,21 @@ function SessionScreenContent(props: {
- - {(model) => ( - - )} - + {/* 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. */} + {mobileTabs()} ) diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index 23430cd6fc4..d286ef30845 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -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 }, diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 88f027ec795..4bb5b30c233 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -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, diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 8f0e1042a3e..b9ab7640e1c 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -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 + /** Height from the bottom edge to where the timeline fades out, the top of the composer region's card. */ + fadeInset?: Accessor + 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() + const [visibleArea, setVisibleArea] = createSignal() + const [viewRoot, setViewRoot] = createSignal() + const bottomInset = () => input.bottomInset?.() ?? 0 const [toolOpen, setToolOpen] = createStore>(cached?.toolOpen ?? {}) const patchGroupKeys = cached?.patchGroupKeys ?? new Map() 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 ( -
+
- input.onResumeScroll(prefersReducedMotion() ? "auto" : "smooth") - } + onClick={() => input.onResumeScroll(prefersReducedMotion() ? "auto" : "smooth")} >
+