fix(app): float the activity pill in reserved space above the composer (#57273)

This commit is contained in:
Luke Parker
2026-10-20 14:08:02 +10:00
committed by GitHub
Unverified
parent 15be9f1939
commit 2a00a32086
8 changed files with 66 additions and 66 deletions
@@ -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()
}
@@ -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<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 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<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,
// 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
+4 -4
View File
@@ -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,
@@ -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 (
<Show when={active()}>
<div data-component="session-activity-chips" class="pointer-events-none flex items-center pb-2">
<div
data-component="session-activity-chips"
class="pointer-events-none absolute inset-x-0 bottom-full flex items-center pb-2"
>
<Menu gutter={6} placement="top-start">
<Menu.Trigger
as="button"
-1
View File
@@ -238,7 +238,6 @@ function SessionScreenContent(props: {
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
bottomInset={timeline.inset()}
fadeInset={timeline.edge()}
setVisibleRef={timeline.view.setVisibleRef}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
@@ -19,10 +19,8 @@ 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. */
/** The composer region's height; the timeline scrolls on underneath it and fades out at its top. */
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
@@ -41,7 +39,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
pinned: true,
},
inset: 0,
edge: 0,
refs: {
scroller: undefined,
content: undefined,
@@ -361,15 +358,9 @@ export function createSessionTimelineInteraction(session: SessionModel) {
)
createResizeObserver(
() => 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,
@@ -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 = () => (
<Show when={showWorking() || backgroundHintPresence.present()}>
<div
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-session md:mx-auto": props.centered,
}}
>
<div class={`flex h-9 items-center gap-2 pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
<Show when={showWorking()}>
<div data-component="session-working" role="status">
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</Show>
<Show when={backgroundHintPresence.present()}>
<div
ref={setBackgroundHintRef}
data-component="session-background-hint-row"
class="duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint onMove={props.background.move} />
</div>
</Show>
</div>
<div
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-session md:mx-auto": props.centered,
}}
>
<div class={`flex h-9 items-center gap-2 pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
<Show when={showWorking()}>
<div data-component="session-working" role="status">
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</Show>
<Show when={backgroundHintPresence.present()}>
<div
ref={setBackgroundHintRef}
data-component="session-background-hint-row"
class="duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint onMove={props.background.move} />
</div>
</Show>
</div>
</Show>
</div>
)
return (
@@ -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<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
@@ -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`,
}}
>
<div
@@ -945,8 +946,11 @@ export function createTimelineVirtualizer(input: Input) {
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<div
data-timeline-row="bottom-spacer"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64 - rendering.scrollAdjustment}px)` }}
class="absolute top-0 left-0 w-full"
style={{
height: `${tailSize}px`,
transform: `translateY(${virtualizer.getTotalSize() - tailSize - rendering.scrollAdjustment}px)`,
}}
>
{props.bottomSpacer}
</div>