mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(session-ui): keep open Used group header sticky
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user