feat(session-ui): keep open Used group header sticky (#52210)

This commit is contained in:
Luke Parker
2026-09-30 13:46:40 +10:00
committed by GitHub
parent 1b02abcfab
commit 67b14e9191
5 changed files with 164 additions and 5 deletions
@@ -186,11 +186,17 @@ for (const scenario of scenarios) {
const toolTitle = element
.closest('[data-component="edit-tool"]')
?.querySelector('[data-slot="collapsible-trigger"][data-locked]')
const top = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
// Grouped file headers stack below the stuck Used header.
const group = element
.closest('[data-component="collapsed-tool-group"]')
?.querySelector(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
const sessionTop = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
const top = sessionTop + (group?.getBoundingClientRect().height ?? 0)
const rect = element.getBoundingClientRect()
const trigger = element.querySelector("button")!
return {
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,
clickable: trigger.contains(
document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2),
@@ -198,7 +204,7 @@ for (const scenario of scenarios) {
}
}),
)
.toEqual({ gap: 0, titleGap: 0, clickable: true })
.toEqual({ gap: 0, groupGap: 0, titleGap: 0, clickable: true })
await page.screenshot({ path: info.outputPath(`${file}.png`) })
}
@@ -0,0 +1,30 @@
import { expect, story } from "../../storybook/playwright/story"
story("keeps the open Used header below the session title 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 })
const top = (locator: typeof header) =>
locator.evaluate((node) => {
const scroller = node.closest('[data-story="sticky-header-scroll"]')!
return node.getBoundingClientRect().top - scroller.getBoundingClientRect().top
})
expect(await scroller.evaluate((node) => node.scrollHeight > node.clientHeight * 2)).toBe(true)
await scroller.evaluate((node) => (node.scrollTop = 900))
await expect.poll(() => top(header)).toBe(48)
// 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" })
await file.evaluate((node) => {
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 header.click()
await expect(header).toHaveAttribute("aria-expanded", "false")
await expect.poll(() => top(header)).toBe(48)
await expect(root.locator('[data-slot="context-tool-group-item"]')).toHaveCount(0)
})
@@ -652,8 +652,28 @@
}
}
[data-component="collapsed-tool-group"] > [data-component="collapsible"].tool-collapsible {
--tool-content-gap: 8px;
[data-component="collapsed-tool-group"] {
--context-group-sticky-top: var(--sticky-accordion-top, 0px);
> [data-component="collapsible"].tool-collapsible {
--tool-content-gap: 8px;
gap: 0px;
> [data-slot="collapsible-trigger"][aria-expanded="true"] {
position: sticky;
top: var(--context-group-sticky-top);
/* Above nested file headers as they scroll out under this row. */
z-index: 21;
height: calc(28px + var(--tool-content-gap));
padding-bottom: var(--tool-content-gap);
background-color: var(--v2-background-bg-base);
}
/* Nested sticky file headers stop below the open group header. */
> [data-slot="collapsible-content"] {
--sticky-accordion-top: calc(var(--context-group-sticky-top) + 28px + var(--tool-content-gap));
}
}
}
[data-component="context-tool-group-list"] {
@@ -104,6 +104,92 @@ export const MixedReasoning = {
},
}
// Mirrors a long exploration turn in a timeline viewport with the session title header above it.
const stickyCommands = [
"git log --oneline 09c318094c..upstream/v2 -- packages/tui/src/plugin/structure.ts packages/tui/src/plugin/render.tsx",
"git log --oneline 09c318094c..upstream/v2 -- packages/tui/src/plugin/structure.ts; git diff --stat upstream/v2",
'git log --format="%h %s" 09c318094c..upstream/v2 -- packages/app/src/runtime/persistence',
'git log --format="%h %s" 09c318094c..upstream/v2 -- packages/app/src/session/review packages/app/src/session/files',
'git show 09c318094c:packages/app/src/session/review/model.ts | Select-String "ChangeMode ="',
"git show 09c318094c:packages/app/src/session/review/model.ts | Select-String -Pattern 'turn' -Context 0,0",
]
const stickySource = (lines: number, changed: boolean) =>
Array.from(
{ length: lines },
(_, index) => `export const value${index} = ${changed && index % 3 === 0 ? index + 1 : index}\n`,
).join("")
const stickyParts: ContextGroupPart[] = [
storyTool("sticky_write", "write", "completed", { path: "graph.js", content: stickySource(137, false) }),
...Array.from({ length: 36 }, (_, index): ContextGroupPart[] => [
{
type: "reasoning",
id: `sticky_thought_${index}`,
text: "Check which commits touched the review model before rebasing the stack.",
time: { created: 0, completed: ((index * 7) % 5) * 1000 + 1000 },
},
index === 18
? storyTool(
"sticky_edit",
"edit",
"completed",
{
path: "src/session/review/model.ts",
oldString: stickySource(24, false),
newString: stickySource(24, true),
},
{},
)
: index % 6 === 5
? storyTool(
`sticky_grep_${index}`,
"grep",
"completed",
{ path: "C:/tmp/opencode/stack/packages/plugin-review-desktop/", pattern: 'turn"|lastTurn|session\\.diff' },
{ metadata: { matches: 4 } },
)
: storyTool(
`sticky_shell_${index}`,
"shell",
"completed",
{ command: stickyCommands[index % stickyCommands.length]! },
{ output: "ok" },
),
]).flat(),
]
export const StickyHeader = {
args: { height: 720 },
argTypes: { height: { control: { type: "range", min: 320, max: 1200, step: 20 } } },
render: (args: { height: number }) => {
const [state, setState] = createStore({ open: true, files: {} as Record<string, boolean> })
return (
<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" }}
>
<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">
Trace stacked review history
</div>
</div>
<div class="px-6" style={{ "padding-bottom": `${args.height}px` }}>
<CurrentSessionProviders document={storyDocument(stickyParts.filter((part) => part.type === "tool"))}>
<CurrentContextToolGroup
parts={stickyParts}
busy={false}
open={state.open}
onOpenChange={(open) => setState("open", open)}
fileOpen={(path) => state.files[path] ?? path.endsWith("model.ts")}
onFileOpenChange={(path, open) => setState("files", path, open)}
/>
</CurrentSessionProviders>
</div>
</section>
)
},
}
export const PatchFollowUps = {
args: { separator: "none" },
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
@@ -608,13 +608,23 @@ export function CurrentContextToolGroup(props: {
})
return keys
})
let root: HTMLDivElement | undefined
const change = (open: boolean) => {
// Collapsing from the stuck header would leave the viewport far below the group, so keep the header in place.
// Scroll before collapsing because the shorter content would clamp the scroll position first.
const trigger = root?.querySelector(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
const stuck = !open && root && trigger ? trigger.getBoundingClientRect().top - root.getBoundingClientRect().top : 0
if (stuck > 0 && root) scrollParent(root)?.scrollBy({ top: -stuck, behavior: "instant" })
props.onOpenChange(open)
props.onSizeChange?.()
}
return (
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}>
<div
ref={root}
data-component="collapsed-tool-group"
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
>
<BasicTool
icon="glasses"
status={pending() ? "running" : "completed"}
@@ -1034,6 +1044,13 @@ export function CurrentFileToolGroup(props: {
)
}
function scrollParent(element: HTMLElement): HTMLElement | undefined {
const parent = element.parentElement
if (!parent) return
if (/auto|scroll/.test(getComputedStyle(parent).overflowY)) return parent
return scrollParent(parent)
}
function isFileChangeTool(tool: SessionMessageAssistantTool) {
return tool.state.status !== "error" && (tool.name === "edit" || tool.name === "write" || tool.name === "patch")
}