From 34e05dff4138332ed499ad7dde9e253ab297c36a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:16:15 +1000 Subject: [PATCH] feat(session-ui): keep open Used group header sticky --- .../regression/patch-sticky-header.spec.ts | 10 ++- .../component-tests/tool-group-sticky.spec.ts | 30 +++++++ .../src/components/message-part.css | 24 +++++- .../src/tools/tool-group.stories.tsx | 86 +++++++++++++++++++ .../session-ui/src/tools/tool-renderer.tsx | 19 +++- 5 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 packages/session-ui/component-tests/tool-group-sticky.spec.ts diff --git a/packages/app/e2e/regression/patch-sticky-header.spec.ts b/packages/app/e2e/regression/patch-sticky-header.spec.ts index a34b0b1c68e..1290483ae51 100644 --- a/packages/app/e2e/regression/patch-sticky-header.spec.ts +++ b/packages/app/e2e/regression/patch-sticky-header.spec.ts @@ -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`) }) } diff --git a/packages/session-ui/component-tests/tool-group-sticky.spec.ts b/packages/session-ui/component-tests/tool-group-sticky.spec.ts new file mode 100644 index 00000000000..b61efc6c067 --- /dev/null +++ b/packages/session-ui/component-tests/tool-group-sticky.spec.ts @@ -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('[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) +}) diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 62f94196cc1..75457c00863 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -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"] { diff --git a/packages/session-ui/src/tools/tool-group.stories.tsx b/packages/session-ui/src/tools/tool-group.stories.tsx index d9985f0d64b..1a3f79b2837 100644 --- a/packages/session-ui/src/tools/tool-group.stories.tsx +++ b/packages/session-ui/src/tools/tool-group.stories.tsx @@ -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 }) + return ( +
+
+
+ Trace stacked review history +
+
+
+ part.type === "tool"))}> + setState("open", open)} + fileOpen={(path) => state.files[path] ?? path.endsWith("model.ts")} + onFileOpenChange={(path, open) => setState("files", path, open)} + /> + +
+
+ ) + }, +} + export const PatchFollowUps = { args: { separator: "none" }, argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } }, diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index cbafd69e588..e16b2177820 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -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 ( -
part.id).join(",")}> +
part.id).join(",")} + >