mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(app): scroll the timeline under the composer (#57141)
This commit is contained in:
committed by
GitHub
Unverified
parent
295ec7dbd9
commit
15b5948601
@@ -13,6 +13,9 @@ import {
|
||||
} from "../utils/timeline"
|
||||
import { capturePromptMotion, markPrompt, promptMotionIssues, readPromptPositions } from "../utils/prompt-motion"
|
||||
|
||||
// The thumb's track is the part of the timeline above the composer region, which the timeline scrolls on under.
|
||||
const thumbSelector = '[data-slot="session-timeline-visible"] [data-orientation="vertical"]'
|
||||
|
||||
// Compositor prediction can add 20–25px to discrete CDP moves even in a plain
|
||||
// scrollport. Disable it so the visual assertion measures the supplied gesture.
|
||||
test.use({ colorScheme: "light", launchOptions: { args: ["--disable-features=ResamplingScrollEvents"] } })
|
||||
@@ -147,7 +150,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
touchPoints: [],
|
||||
timestamp: Date.now() / 1000,
|
||||
})
|
||||
await expect(reading.timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
|
||||
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(
|
||||
before + 615,
|
||||
0,
|
||||
@@ -204,7 +207,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(reading.start, 0)
|
||||
const before = await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
await expect(reading.timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
|
||||
await testInfo.attach("start-released.png", { body: await page.screenshot(), contentType: "image/png" })
|
||||
expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(reading.start, 0)
|
||||
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0)
|
||||
@@ -314,7 +317,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
}
|
||||
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
await expect(root.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
|
||||
const released = await tail.evaluate((element) => element.getBoundingClientRect().top)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
@@ -365,7 +368,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await expect(tail).toBeInViewport()
|
||||
await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
|
||||
const before = await tail.evaluate((element) => element.getBoundingClientRect().top)
|
||||
const bounds = (await scroller.boundingBox())!
|
||||
const point = { x: bounds.x + 150, y: bounds.y + 200 }
|
||||
@@ -491,7 +494,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
(element, distance) => (element.scrollTop = element.scrollHeight - element.clientHeight - distance),
|
||||
growsBeforeDrag ? 1200 : handoff.key === "latest" ? 800 : 80,
|
||||
)
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
|
||||
const latest = page.getByRole("button", { name: "Jump to latest", exact: true })
|
||||
|
||||
if (handoff.key === "latest") await expect(latest.locator("..")).toHaveCSS("opacity", "1")
|
||||
@@ -551,7 +554,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollHeight)).toBe(extent)
|
||||
}
|
||||
|
||||
const thumb = usesScrollbar ? timeline.locator('.scroll-view__thumb[data-orientation="vertical"]') : undefined
|
||||
const thumb = usesScrollbar ? page.locator(thumbSelector) : undefined
|
||||
await thumb?.hover()
|
||||
const grip = await thumb?.boundingBox()
|
||||
|
||||
@@ -626,7 +629,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
if (toStart) await expect(first).toBeInViewport()
|
||||
|
||||
if (!toStart) await expect(page.getByText("Reading 59.", { exact: true })).toBeInViewport()
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
|
||||
|
||||
if (handoff.held) await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
|
||||
@@ -686,7 +689,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true })
|
||||
const hidden = timeline.locator('[data-orientation="vertical"][data-visible="false"]')
|
||||
const hidden = page.locator(`${thumbSelector}[data-visible="false"]`)
|
||||
const heading = page.locator(`[data-timeline-part-id="${renderedPartID("prt_target_heading")}"]`)
|
||||
const last = detach === "scroll" ? page.getByText("Part 79.", { exact: true }) : heading
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
@@ -750,7 +753,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: point.x, y: point.y + 60 }],
|
||||
})
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="true"]')).toHaveCount(1)
|
||||
await expect(page.locator(`${thumbSelector}[data-visible="true"]`)).toHaveCount(1)
|
||||
await fixture.send(partDelta("prt_target_heading", "\n\nMore content."))
|
||||
await expect(heading).toContainText("More content.")
|
||||
await expect(heading.locator("p")).toHaveCount(2)
|
||||
@@ -817,7 +820,7 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
await expect
|
||||
.poll(() => tail.evaluate((element) => element.getBoundingClientRect().top))
|
||||
.toBeGreaterThan(before + 50)
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await expect(page.locator(`${thumbSelector}[data-visible="false"]`)).toHaveCount(1)
|
||||
|
||||
const chosen = await scroller.evaluate((root) => {
|
||||
const view = root.getBoundingClientRect()
|
||||
|
||||
@@ -9,7 +9,7 @@ for (const reducedMotion of [false, true]) {
|
||||
const chat = page.locator('[data-slot="session-chat-panel"]')
|
||||
const scroll = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const viewport = scroll.locator(".scroll-view__viewport")
|
||||
const thumb = scroll.locator('.scroll-view__thumb[data-orientation="vertical"]')
|
||||
const thumb = page.locator('[data-slot="session-timeline-visible"] .scroll-view__thumb')
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(thumb).toHaveCount(1)
|
||||
await scroll.hover()
|
||||
|
||||
@@ -37,7 +37,11 @@ for (const width of [1440, 390]) {
|
||||
? [row.dataset.timelineKey!]
|
||||
: [],
|
||||
),
|
||||
bottomError: (spacer?.getBoundingClientRect().bottom ?? Infinity) - root.getBoundingClientRect().bottom,
|
||||
// The viewport runs on under the composer; its visible part ends at the composer region.
|
||||
bottomError:
|
||||
(spacer?.getBoundingClientRect().bottom ?? Infinity) -
|
||||
root.getBoundingClientRect().bottom +
|
||||
parseFloat(getComputedStyle(root).scrollPaddingBottom),
|
||||
tables: answer.querySelectorAll("table").length,
|
||||
codeBlocks: answer.querySelectorAll("pre").length,
|
||||
}
|
||||
@@ -528,7 +532,9 @@ async function sampleTabPaint(page: Page, sessionID: string) {
|
||||
)
|
||||
|
||||
if (root) {
|
||||
const view = root.getBoundingClientRect()
|
||||
// The part of the viewport above the composer region, which the timeline scrolls on under.
|
||||
const box = root.getBoundingClientRect()
|
||||
const view = { top: box.top, bottom: box.bottom - parseFloat(getComputedStyle(root).scrollPaddingBottom) }
|
||||
|
||||
// A frame counts only when the row is painted: inside the viewport and not hidden by CSS.
|
||||
const inView = (element: Element) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
historyMessages,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
@@ -630,3 +631,81 @@ async function captureCardEdges(page: Page, card: Locator) {
|
||||
{ source: `data:image/png;base64,${screenshot.toString("base64")}`, viewport, box },
|
||||
)
|
||||
}
|
||||
|
||||
test("scrolls the timeline behind the composer region, fading at the card, and keeps its end above the region", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
...validateTimelineMessages(historyMessages(18)),
|
||||
{ id: "msg_9000_user", type: "user", text: "Split the work.", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_9001_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_running_subagent",
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { description: "Trace frame budget", agent: "explore" },
|
||||
metadata: { sessionID: "ses_running_subagent" },
|
||||
},
|
||||
time: { created: 2 },
|
||||
},
|
||||
{ type: "text", id: "prt_timeline_end", text: "The last answer ends above the composer region." },
|
||||
],
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: "ses_running_subagent", parentID: sessionID, title: "Trace frame budget", agent: "explore" }),
|
||||
],
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, ses_running_subagent: { type: "busy" } },
|
||||
})
|
||||
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.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 parts = [...viewport.querySelectorAll("[data-timeline-part-id]")]
|
||||
const last = parts.find((element) => element.textContent?.includes("The last answer ends"))!
|
||||
|
||||
return {
|
||||
// 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,
|
||||
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 }
|
||||
await expect.poll(geometry).toEqual(expected)
|
||||
const before = (await dock.boundingBox())!.height
|
||||
|
||||
await editor.click()
|
||||
await page.keyboard.type("A draft")
|
||||
|
||||
for (const line of ["that grows", "the composer", "by several lines"]) {
|
||||
await page.keyboard.press("Shift+Enter")
|
||||
await page.keyboard.type(line)
|
||||
}
|
||||
|
||||
await expect.poll(async () => (await dock.boundingBox())!.height).toBeGreaterThan(before + 40)
|
||||
await expect.poll(geometry).toEqual(expected)
|
||||
})
|
||||
|
||||
@@ -39,11 +39,27 @@
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
|
||||
[data-slot="session-timeline-scroll"]
|
||||
> .scroll-view__thumb {
|
||||
[data-slot="session-timeline-visible"]
|
||||
.scroll-view__thumb {
|
||||
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. */
|
||||
[data-slot="session-timeline-scroll"] > .scroll-view__viewport {
|
||||
--session-timeline-fade-start: var(--session-timeline-fade-edge, 0px);
|
||||
scroll-padding-bottom: var(--session-timeline-inset, 0px);
|
||||
mask-image: linear-gradient(
|
||||
to top,
|
||||
transparent var(--session-timeline-fade-start),
|
||||
rgb(0 0 0 / 0.16) calc(var(--session-timeline-fade-start) + 5px),
|
||||
rgb(0 0 0 / 0.5) calc(var(--session-timeline-fade-start) + 10px),
|
||||
rgb(0 0 0 / 0.84) calc(var(--session-timeline-fade-start) + 15px),
|
||||
#000 calc(var(--session-timeline-fade-start) + 20px)
|
||||
);
|
||||
}
|
||||
|
||||
#review-panel {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@@ -69,13 +69,13 @@ export function SessionActivityChips(props: {
|
||||
|
||||
return (
|
||||
<Show when={props.subagents.length > 0 || props.shells.length > 0}>
|
||||
<div data-component="session-activity-chips" class="flex items-center pb-2">
|
||||
<div data-component="session-activity-chips" class="pointer-events-none flex items-center pb-2">
|
||||
<Menu gutter={6} placement="top-start">
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-slot="session-activity-chip"
|
||||
class="flex h-7 min-w-0 max-w-full items-center gap-1.5 rounded-full border border-v2-border-border-base bg-v2-background-bg-base ps-2 pe-2.5 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap tabular-nums text-v2-text-text-base shadow-[var(--v2-elevation-raised)] outline-none duration-150 ease-out animate-in fade-in motion-reduce:animate-none hover:bg-v2-background-bg-layer-01 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus"
|
||||
class="pointer-events-auto flex h-7 min-w-0 max-w-full items-center gap-1.5 rounded-full border border-v2-border-border-base bg-v2-background-bg-base ps-2 pe-2.5 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap tabular-nums text-v2-text-text-base shadow-[var(--v2-elevation-raised)] outline-none duration-150 ease-out animate-in fade-in motion-reduce:animate-none hover:bg-v2-background-bg-layer-01 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
<SessionProgressIndicatorV2 class="shrink-0" />
|
||||
<span class="truncate">{label()}</span>
|
||||
|
||||
@@ -26,11 +26,12 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
ref={controller.setDockRef}
|
||||
data-component="session-composer-dock"
|
||||
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none bg-v2-background-bg-base"
|
||||
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none"
|
||||
>
|
||||
{/* Only the docks and composer take the pointer; the gaps beside them belong to the timeline underneath. */}
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-3 pointer-events-auto": true,
|
||||
"w-full px-3 *:pointer-events-auto": true,
|
||||
"md:max-w-session md:mx-auto": controller.centered(),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -242,6 +242,9 @@ function SessionScreenContent(props: {
|
||||
centered={screen.centered()}
|
||||
reserveReviewToggle={!sideVisible()}
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
bottomInset={timeline.inset()}
|
||||
fadeInset={timeline.edge()}
|
||||
setVisibleRef={timeline.view.setVisibleRef}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setRevealPart={timeline.view.setRevealPart}
|
||||
@@ -290,7 +293,7 @@ function SessionScreenContent(props: {
|
||||
throw error()
|
||||
}}
|
||||
</Show>
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden">
|
||||
<div class="relative isolate flex-1 min-h-0 overflow-hidden">
|
||||
<Show when={!isDesktop() && session.identity.params.id}>
|
||||
<For each={cachedViews()}>
|
||||
{(entry) => (
|
||||
@@ -336,16 +339,21 @@ function SessionScreenContent(props: {
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => (
|
||||
<ActiveSessionComposerRegion
|
||||
session={session}
|
||||
model={model}
|
||||
suggestionBoundary={timeline.scroller}
|
||||
onReveal={timeline.actions.revealPart}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
{/* The composer region overflows this zero-height row upward, over a timeline that scrolls on underneath it. It
|
||||
stays shallower than the timeline's observed elements, so their resizes from its inset arrive in the same
|
||||
ResizeObserver pass. */}
|
||||
<div class="relative z-[70] flex h-0 shrink-0 flex-col justify-end">
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => (
|
||||
<ActiveSessionComposerRegion
|
||||
session={session}
|
||||
model={model}
|
||||
suggestionBoundary={timeline.visible}
|
||||
onReveal={timeline.actions.revealPart}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id && bottomMobileTabs()}>{mobileTabs()}</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -19,10 +19,15 @@ 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. */
|
||||
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
|
||||
dock: HTMLDivElement | undefined
|
||||
visible: HTMLDivElement | undefined
|
||||
}
|
||||
}>({
|
||||
messageID: undefined,
|
||||
@@ -35,10 +40,13 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
sessionKey: session.identity.sessionKey(),
|
||||
pinned: true,
|
||||
},
|
||||
inset: 0,
|
||||
edge: 0,
|
||||
refs: {
|
||||
scroller: undefined,
|
||||
content: undefined,
|
||||
dock: undefined,
|
||||
visible: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -53,7 +61,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
}
|
||||
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
// The history entry whose tool call was already revealed.
|
||||
let revealed: string | undefined
|
||||
|
||||
@@ -354,17 +361,25 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
)
|
||||
createResizeObserver(
|
||||
() => state.refs.dock,
|
||||
({ height }) => {
|
||||
const next = Math.ceil(height)
|
||||
(_rect, element, entry) => {
|
||||
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 (next === dockHeight) return
|
||||
const delta = next - dockHeight
|
||||
if (edge !== state.edge) setState("edge", edge)
|
||||
|
||||
if (next === state.inset) return
|
||||
const delta = next - state.inset
|
||||
|
||||
// Measured before the new inset applies: a shrinking region also keeps a reader whose end it uncovers.
|
||||
const stick = scroller
|
||||
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
|
||||
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, -delta)
|
||||
: false
|
||||
|
||||
dockHeight = next
|
||||
setState("inset", next)
|
||||
|
||||
if (stick) scrollToEnd()
|
||||
|
||||
@@ -388,11 +403,15 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
resume,
|
||||
setActiveMessage,
|
||||
},
|
||||
inset: () => state.inset,
|
||||
edge: () => state.edge,
|
||||
lastUserMessage: timeline.lastUserMessage,
|
||||
resource: timeline.resource,
|
||||
ready: timeline.ready,
|
||||
scroll: state.scroll,
|
||||
scroller: () => state.refs.scroller,
|
||||
/** The part of the timeline not covered by the composer region. */
|
||||
visible: () => state.refs.visible,
|
||||
view: {
|
||||
anchor,
|
||||
markUserScroll,
|
||||
@@ -409,6 +428,9 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setDockRef: (element: HTMLDivElement | undefined) => {
|
||||
setState("refs", "dock", element)
|
||||
},
|
||||
setVisibleRef: (element: HTMLDivElement | undefined) => {
|
||||
setState("refs", "visible", element)
|
||||
},
|
||||
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
|
||||
revealMessage = reveal
|
||||
},
|
||||
|
||||
@@ -80,6 +80,9 @@ type MessageTimelineProps = {
|
||||
centered: boolean
|
||||
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
|
||||
setRevealPart?: (fn: (target: string) => boolean) => void
|
||||
@@ -173,6 +176,9 @@ function MessageTimelineView(
|
||||
onResumeScroll: props.onResumeScroll,
|
||||
setScrollRef: props.setScrollRef,
|
||||
setContentRef: props.setContentRef,
|
||||
bottomInset: () => props.bottomInset ?? 0,
|
||||
fadeInset: () => props.fadeInset ?? 0,
|
||||
setVisibleRef: props.setVisibleRef,
|
||||
onScheduleScrollState: props.onScheduleScrollState,
|
||||
onPin: props.onPin,
|
||||
onUnpin: props.onUnpin,
|
||||
|
||||
@@ -67,6 +67,11 @@ 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. */
|
||||
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
|
||||
onUnpin: () => void
|
||||
@@ -102,6 +107,9 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && input.pinned()
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const [visibleArea, setVisibleArea] = createSignal<HTMLDivElement>()
|
||||
const [viewRoot, setViewRoot] = createSignal<HTMLDivElement>()
|
||||
const bottomInset = () => input.bottomInset?.() ?? 0
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const patchGroupKeys = cached?.patchGroupKeys ?? new Map<string, string>()
|
||||
createEffect(
|
||||
@@ -198,11 +206,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
// The viewport runs on under the composer region. The virtualizer sees only the part above it, which the
|
||||
// inset spacer below the rows keeps consistent with the native scroll range.
|
||||
observeElementRect: (instance, callback) => {
|
||||
reportRect = callback
|
||||
reportRect = (rect) => callback({ width: rect.width, height: Math.max(0, rect.height - bottomInset()) })
|
||||
|
||||
return observeElementRect(instance, (rect) => {
|
||||
if (active()) callback(rect)
|
||||
if (active()) reportRect?.(rect)
|
||||
})
|
||||
},
|
||||
// Route navigation detaches and reattaches the scroll element, which drops its offset.
|
||||
@@ -498,6 +508,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (root) input.setScrollRef(root)
|
||||
|
||||
if (virtualContent) input.setContentRef(virtualContent)
|
||||
input.setVisibleRef?.(visibleArea())
|
||||
queueMicrotask(() => {
|
||||
if (!active() || !root?.isConnected) return
|
||||
// A detached view can miss its nonzero ResizeObserver delivery. Publish
|
||||
@@ -544,6 +555,18 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
bottomInset,
|
||||
() => {
|
||||
const root = listRoot()
|
||||
|
||||
if (active() && root?.isConnected) reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
let coldPending = coldBottomMount
|
||||
let settleQueued = false
|
||||
let contentObserver: MutationObserver | undefined
|
||||
@@ -849,11 +872,17 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
ref={setViewRoot}
|
||||
class="relative w-full h-full min-w-0"
|
||||
style={{
|
||||
"--session-timeline-inset": `${bottomInset()}px`,
|
||||
"--session-timeline-fade-edge": `${input.fadeInset?.() ?? 0}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-[opacity,translate]"
|
||||
class="absolute bottom-[calc(var(--session-timeline-inset)+32px)] left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-[opacity,translate]"
|
||||
classList={{
|
||||
"bottom-8": true,
|
||||
"opacity-100 translate-y-0 duration-150 ease-[cubic-bezier(.2,.8,.2,1)]":
|
||||
input.scroll().overflow && input.scroll().jump,
|
||||
"opacity-0 translate-y-1 motion-reduce:translate-y-0 duration-[110ms] ease-[cubic-bezier(.4,0,1,1)]":
|
||||
@@ -869,9 +898,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
"background-color": "color-mix(in srgb, var(--v2-background-bg-base) 92%, transparent)",
|
||||
"box-shadow": "var(--v2-elevation-raised), 0px 2px 8px var(--v2-background-bg-base)",
|
||||
}}
|
||||
onClick={() =>
|
||||
input.onResumeScroll(prefersReducedMotion() ? "auto" : "smooth")
|
||||
}
|
||||
onClick={() => input.onResumeScroll(prefersReducedMotion() ? "auto" : "smooth")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
@@ -885,6 +912,8 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<ScrollView
|
||||
data-slot="session-timeline-scroll"
|
||||
viewportRef={bindListRoot}
|
||||
thumbContainer={visibleArea()}
|
||||
thumbHoverTarget={viewRoot()}
|
||||
onBeforeScroll={prepareNavigation}
|
||||
verticalScrollAdjustment={rendering.scrollAdjustment}
|
||||
onWheel={handleListWheel}
|
||||
@@ -922,7 +951,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" data-timeline-inset class="h-[var(--session-timeline-inset)] shrink-0" />
|
||||
</ScrollView>
|
||||
<div
|
||||
ref={setVisibleArea}
|
||||
data-slot="session-timeline-visible"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 bottom-[var(--session-timeline-inset)]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user