fix(session-ui): keep stuck headers clear of the session title fade

This commit is contained in:
LukeParkerDev
2026-09-30 13:41:51 +10:00
parent 34e05dff41
commit 32701ebdf5
6 changed files with 85 additions and 10 deletions
@@ -182,7 +182,8 @@ for (const scenario of scenarios) {
.poll(() =>
header.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!
const title = viewport.querySelector("[data-session-title]")?.firstElementChild
// Stuck headers sit below the whole title, including its fade.
const title = viewport.querySelector("[data-session-title]")
const toolTitle = element
.closest('[data-component="edit-tool"]')
?.querySelector('[data-slot="collapsible-trigger"][data-locked]')
@@ -194,7 +195,13 @@ for (const scenario of scenarios) {
const top = sessionTop + (group?.getBoundingClientRect().height ?? 0)
const rect = element.getBoundingClientRect()
const trigger = element.querySelector("button")!
// The first stuck header covers the faded strip between it and the title.
const cover = getComputedStyle(group ?? toolTitle ?? element, "::before")
const fade = title
? title.getBoundingClientRect().height - title.firstElementChild!.getBoundingClientRect().height
: 0
return {
cover: cover.display === "block" && cover.height === `${fade}px`,
gap: Math.abs(rect.top - top - (toolTitle?.getBoundingClientRect().height ?? 0)),
groupGap: group ? Math.abs(group.getBoundingClientRect().top - sessionTop) : 0,
titleGap: toolTitle ? Math.abs(toolTitle.getBoundingClientRect().top - top) : 0,
@@ -204,7 +211,7 @@ for (const scenario of scenarios) {
}
}),
)
.toEqual({ gap: 0, groupGap: 0, titleGap: 0, clickable: true })
.toEqual({ cover: true, gap: 0, groupGap: 0, titleGap: 0, clickable: true })
await page.screenshot({ path: info.outputPath(`${file}.png`) })
}
@@ -34,6 +34,8 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
const upwardKeys = new Set(["up", "page-up", "home"])
// Stuck headers can clear the session title's 16px fade only where they can detect being stuck and cover it.
const coverTitleFade = CSS.supports("container-type", "scroll-state")
const cache = new Map<
string,
{
@@ -683,7 +685,10 @@ export function createTimelineVirtualizer(input: Input) {
onScroll={handleListScroll}
onClick={input.onSelectionInteraction}
class="relative min-w-0 w-full h-full"
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
style={{
"--sticky-accordion-top": !input.showHeader() ? "0px" : coverTitleFade ? "64px" : "48px",
"--sticky-accordion-fade": input.showHeader() && coverTitleFade ? "16px" : "0px",
}}
>
<Show when={input.showHeader()} fallback={<div aria-hidden="true" class="h-4 md:hidden" />}>
{props.header}
@@ -1,6 +1,6 @@
import { expect, story } from "../../storybook/playwright/story"
story("keeps the open Used header below the session title while scrolling", async ({ mount }) => {
story("keeps the open Used header below the session title fade while scrolling", async ({ mount }) => {
const root = await mount("current-tool-group--sticky-header", { args: { height: "720" } })
const scroller = root.locator('[data-story="sticky-header-scroll"]')
const header = root.getByRole("button", { name: "Used 37 Write, Shell, Grep, Edit", exact: true })
@@ -9,10 +9,19 @@ story("keeps the open Used header below the session title while scrolling", asyn
const scroller = node.closest('[data-story="sticky-header-scroll"]')!
return node.getBoundingClientRect().top - scroller.getBoundingClientRect().top
})
// The cover fills the faded strip between the session title and a stuck header.
const cover = (locator: typeof header) =>
locator.evaluate((node) => {
const style = getComputedStyle(node, "::before")
return style.display === "none" ? "none" : style.height
})
expect(await scroller.evaluate((node) => node.scrollHeight > node.clientHeight * 2)).toBe(true)
await expect(header).toHaveAttribute("aria-expanded", "true")
expect(await cover(header)).toBe("none")
await scroller.evaluate((node) => (node.scrollTop = 900))
await expect.poll(() => top(header)).toBe(48)
await expect.poll(() => top(header)).toBe(64)
await expect.poll(() => cover(header)).toBe("16px")
// Nested file headers stack below the stuck Used header instead of covering it.
const file = root.locator('[data-component="sticky-accordion-header"]').filter({ hasText: "model.ts" })
@@ -20,11 +29,13 @@ story("keeps the open Used header below the session title while scrolling", asyn
const scroller = node.closest<HTMLElement>('[data-story="sticky-header-scroll"]')!
scroller.scrollTop += node.getBoundingClientRect().top - scroller.getBoundingClientRect().top + 60
})
await expect.poll(() => top(header)).toBe(48)
await expect.poll(() => top(file)).toBe(84)
await expect.poll(() => top(header)).toBe(64)
await expect.poll(() => top(file)).toBe(100)
await expect.poll(() => cover(file)).toBe("0px")
await header.click()
await expect(header).toHaveAttribute("aria-expanded", "false")
await expect.poll(() => top(header)).toBe(48)
await expect.poll(() => top(header)).toBe(64)
await expect.poll(() => cover(header)).toBe("none")
await expect(root.locator('[data-slot="context-tool-group-item"]')).toHaveCount(0)
})
@@ -669,9 +669,41 @@
background-color: var(--v2-background-bg-base);
}
/* Nested sticky file headers stop below the open group header. */
/* Nested sticky file headers stop below the open group header, which already covers the fade. */
> [data-slot="collapsible-content"] {
--sticky-accordion-top: calc(var(--context-group-sticky-top) + 28px + var(--tool-content-gap));
--sticky-accordion-fade: 0px;
}
}
}
/* Stuck headers sit below the session title fade and cover the faded strip above them. */
[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"],
:is([data-component="edit-tool"], [data-component="write-tool"], [data-component="apply-patch-tool"])
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"] {
&:is([aria-expanded="true"], [data-locked]) {
container-type: scroll-state;
}
&::before {
content: "";
position: absolute;
inset-inline: 0;
bottom: 100%;
display: none;
height: var(--sticky-accordion-fade, 0px);
background-color: var(--v2-background-bg-base);
}
}
@container scroll-state(stuck: top) {
[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"],
:is([data-component="edit-tool"], [data-component="write-tool"], [data-component="apply-patch-tool"])
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"] {
&::before {
display: block;
}
}
}
@@ -1292,6 +1324,8 @@
}
> [data-component="collapsible"] > [data-slot="collapsible-content"] {
/* The sticky tool title above the file headers already covers the fade. */
--sticky-accordion-fade: 0px;
border: none;
border-inline-start: none;
margin-inline-start: 0;
@@ -166,7 +166,7 @@ export const StickyHeader = {
<section
data-story="sticky-header-scroll"
class="relative w-full max-w-[1030px] overflow-y-auto bg-v2-background-bg-base"
style={{ height: `${args.height}px`, "--sticky-accordion-top": "48px" }}
style={{ height: `${args.height}px`, "--sticky-accordion-top": "64px", "--sticky-accordion-fade": "16px" }}
>
<div class="sticky top-0 z-30 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] pb-4 pe-3 ps-2.5">
<div class="flex h-12 items-center px-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
@@ -3,4 +3,22 @@
top: calc(var(--sticky-accordion-top, 0px) + var(--sticky-accordion-offset, 0px));
z-index: 10;
background-color: var(--background-stronger);
container-type: scroll-state;
/* When stuck, cover the faded strip that --sticky-accordion-fade reserves above the header. */
&::before {
content: "";
position: absolute;
inset-inline: 0;
bottom: 100%;
display: none;
height: var(--sticky-accordion-fade, 0px);
background-color: var(--v2-background-bg-base);
}
}
@container scroll-state(stuck: top) {
[data-component="sticky-accordion-header"]::before {
display: block;
}
}