diff --git a/packages/app/e2e/regression/timeline-cache.spec.ts b/packages/app/e2e/regression/timeline-cache.spec.ts index dc228b4e4eb..337f9831036 100644 --- a/packages/app/e2e/regression/timeline-cache.spec.ts +++ b/packages/app/e2e/regression/timeline-cache.spec.ts @@ -62,10 +62,10 @@ for (const width of [1440, 390]) { const reveal = await page.evaluate(() => window.__coldReveal) expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 }) expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1) - // The gap above the composer is part of the tail. + // The gap above the composer is part of the tail: the status lane, then the activity pill's lane. const spacer = page.locator('[data-timeline-row="bottom-spacer"]') await expect(spacer).toBeVisible() - expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64) + expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(80) } finally { release.resolve() } diff --git a/packages/app/e2e/regression/timeline-geometry.spec.ts b/packages/app/e2e/regression/timeline-geometry.spec.ts index 067b228c5ee..d053ccd214c 100644 --- a/packages/app/e2e/regression/timeline-geometry.spec.ts +++ b/packages/app/e2e/regression/timeline-geometry.spec.ts @@ -670,15 +670,17 @@ test("scrolls the timeline behind the composer region, fading at the card, and k 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.locator('[data-component="session-background-hint-row"]')).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 pill = region.querySelector('[data-slot="session-activity-chip"]')!.getBoundingClientRect() + const hint = viewport.querySelector('[data-component="session-background-hint-row"]')!.getBoundingClientRect() const parts = [...viewport.querySelectorAll("[data-timeline-part-id]")] const last = parts.find((element) => element.textContent?.includes("The last answer ends"))! @@ -686,15 +688,19 @@ test("scrolls the timeline behind the composer region, fading at the card, and k // 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, + // Content fades out where the composer card starts. + fade: Math.round( + dock.bottom - card.top - parseFloat(getComputedStyle(viewport).getPropertyValue("--session-timeline-inset")), + ), + // The pill floats above the region out of flow, so it never changes the inset the timeline scrolls above. + floats: pill.bottom <= dock.top, + // It floats in the timeline's reserved tail, below the background hint and the last answer. + reserved: hint.bottom <= pill.top && last.getBoundingClientRect().bottom <= hint.top, 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 } + const expected = { behind: 0, inset: 0, fade: 0, floats: true, reserved: true, atEnd: true } await expect.poll(geometry).toEqual(expected) const before = (await dock.boundingBox())!.height diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 88025cc0fd4..f25d7523f8d 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -69,11 +69,11 @@ 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. */ + /* The timeline scrolls on under the composer region, passing behind its floating pill, and fades out as it reaches + the region's opaque 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); + --session-timeline-fade-start: var(--session-timeline-inset, 0px); scroll-padding-bottom: var(--session-timeline-inset, 0px); mask-image: linear-gradient( to top, diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index 24f605bb321..e0174a71a00 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -15,7 +15,8 @@ export type SessionActivityItem = { target: string } -// Live work above the composer, one capsule like the iOS app's background-work summary. +// Live work above the composer, one capsule like the iOS app's background-work summary. It floats out of flow in a +// lane the timeline reserves at its tail, so coming and going never resizes the composer region or moves the timeline. export function SessionActivityChips(props: { subagents: readonly SessionActivityItem[] shells: readonly SessionActivityItem[] @@ -77,7 +78,10 @@ export function SessionActivityChips(props: { return ( -
+
state.refs.dock, - (_rect, element, entry) => { + (_rect, _element, entry) => { + // The activity pill floats above the region out of flow, so only the docks, queue and composer count here. 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 (edge !== state.edge) setState("edge", edge) if (next === state.inset) return const delta = next - state.inset @@ -404,7 +395,6 @@ export function createSessionTimelineInteraction(session: SessionModel) { setActiveMessage, }, inset: () => state.inset, - edge: () => state.edge, lastUserMessage: timeline.lastUserMessage, resource: timeline.resource, ready: timeline.ready, diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 4bb5b30c233..fde9d31cd0a 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -81,7 +81,6 @@ type MessageTimelineProps = { 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 @@ -177,7 +176,6 @@ function MessageTimelineView( setScrollRef: props.setScrollRef, setContentRef: props.setContentRef, bottomInset: () => props.bottomInset ?? 0, - fadeInset: () => props.fadeInset ?? 0, setVisibleRef: props.setVisibleRef, onScheduleScrollState: props.onScheduleScrollState, onPin: props.onPin, @@ -401,37 +399,36 @@ function MessageTimelineView( // Undelivered input waits below the work the model has seen, so live status moves into the row above it. const activityRow = createMemo(() => projection.rows().some((row) => Predicate.isTagged(row, "Activity"))) + // The lane keeps its height while empty, so Working and the hint come and go without moving the rows around it. const activity = () => ( - -
-
- -
- -
-
- -
- -
-
-
+
+
+ +
+ +
+
+ +
+ +
+
- +
) return ( diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index b9ab7640e1c..f672f61bf33 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -32,6 +32,10 @@ import { prefersReducedMotion } from "@opencode/ui/motion" const fallbackItemSize = 60 +// Reserved below the last row: a 36px lane for the live status row and its background hint, an 8px gap, then a 36px +// lane that the composer's activity pill floats in. Both come and go inside it, so neither moves the rows above. +const tailSize = 80 + const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])' // Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps @@ -67,10 +71,8 @@ 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. */ + /** Height of the composer region floating over the timeline's bottom edge, where the timeline fades out. */ 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 @@ -298,7 +300,7 @@ export function createTimelineVirtualizer(input: Input) { // Empty projections still need the bottom spacer for running status. return rows().length > 0 ? topOffset() : 0 }, - paddingEnd: 64, + paddingEnd: tailSize, get rangeExtractor() { return rangeExtractor() }, @@ -877,7 +879,6 @@ export function createTimelineVirtualizer(input: Input) { class="relative w-full h-full min-w-0" style={{ "--session-timeline-inset": `${bottomInset()}px`, - "--session-timeline-fade-edge": `${input.fadeInset?.() ?? 0}px`, }} >
{(rowKey) => }
{props.bottomSpacer}