fix(ui): sync xray overlay with timeline scroll

This commit is contained in:
Shantur Rathore
2026-03-03 15:02:08 +00:00
parent 80a02b68b9
commit 3c76f9776c
2 changed files with 80 additions and 91 deletions

View File

@@ -1,5 +1,4 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
import { Portal } from "solid-js/web"
import MessagePreview from "./message-preview" import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message" import type { ClientPart } from "../types/message"
@@ -416,12 +415,9 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// Stable layout offsets per badge (relative to scroll content), recomputed only // Stable layout offsets per badge (relative to scroll content), recomputed only
// on activation, resize, or expansion — NOT on every scroll frame. // on activation, resize, or expansion — NOT on every scroll frame.
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({}) const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
// Lightweight scroll state: 1 getBoundingClientRect on container per frame.
const [containerScroll, setContainerScroll] = createSignal({ containerTop: 0, scrollTop: 0, left: 0 })
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
const [clipBounds, setClipBounds] = createSignal<{ top: number; bottom: number }>({ top: 0, bottom: typeof window !== "undefined" ? window.innerHeight : 800 })
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let scrollRafId: number | null = null let xrayOverlayRef: HTMLDivElement | undefined
// Full layout recomputation: reads every badge's getBoundingClientRect once, // Full layout recomputation: reads every badge's getBoundingClientRect once,
// then stores offsets relative to the scroll content so they survive scrolling. // then stores offsets relative to the scroll content so they survive scrolling.
@@ -441,37 +437,19 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
} }
setBadgeOffsets(offsets) setBadgeOffsets(offsets)
setContainerScroll({ containerTop: containerRect.top, scrollTop, left: containerRect.left }) if (xrayOverlayRef) {
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollTop}px`)
}
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
setWindowWidth(window.innerWidth) setWindowWidth(window.innerWidth)
const layout = scrollContainerRef.closest(".message-layout")
if (layout) {
const layoutRect = layout.getBoundingClientRect()
// Shrink clip bottom when the delete toolbar is visible so bars
// disappear behind it instead of overlapping.
const toolbar = layout.querySelector(".message-delete-mode-toolbar")
const toolbarInset = toolbar ? toolbar.getBoundingClientRect().height + 16 : 0
setClipBounds({ top: layoutRect.top, bottom: layoutRect.bottom - toolbarInset })
}
} }
} }
// RAF-throttled scroll handler: only 1 container getBoundingClientRect per frame const handleScroll = () => {
// instead of N badge getBoundingClientRect calls.
const handleScrollRaf = () => {
if (!isSelectionActive()) return if (!isSelectionActive()) return
if (scrollRafId !== null) return if (!scrollContainerRef || !xrayOverlayRef) return
scrollRafId = requestAnimationFrame(() => { xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
scrollRafId = null
if (!scrollContainerRef) return
const containerRect = scrollContainerRef.getBoundingClientRect()
setContainerScroll({
containerTop: containerRect.top,
scrollTop: scrollContainerRef.scrollTop,
left: containerRect.left,
})
})
} }
createEffect(() => { createEffect(() => {
@@ -484,10 +462,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
window.addEventListener("resize", computeBadgeLayout) window.addEventListener("resize", computeBadgeLayout)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("resize", computeBadgeLayout) window.removeEventListener("resize", computeBadgeLayout)
if (scrollRafId !== null) {
cancelAnimationFrame(scrollRafId)
scrollRafId = null
}
}) })
} }
} }
@@ -733,7 +707,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`} class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
role="navigation" role="navigation"
aria-label={t("messageTimeline.ariaLabel")} aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScrollRaf} onScroll={handleScroll}
> >
<For each={props.segments}> <For each={props.segments}>
{(segment, segIndex) => { {(segment, segIndex) => {
@@ -895,57 +869,60 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
</Show> </Show>
</div> </div>
<Portal> <Show when={isSelectionActive()}>
<Show when={isSelectionActive()}> <div
<div class="message-timeline-xray-overlay" style={{ "--max-rib-width": `${maxRibWidth()}px`, "clip-path": `inset(${clipBounds().top}px 0 ${(typeof window !== "undefined" ? window.innerHeight : 0) - clipBounds().bottom}px 0)` }}> ref={(el) => {
<For each={xraySegments()}> xrayOverlayRef = el
{(segment) => { if (xrayOverlayRef && scrollContainerRef) {
// Derive screen position from stable layout offset + scroll state. xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
// Only arithmetic — no DOM reads per segment per scroll frame. }
const pos = () => { }}
const offset = badgeOffsets()[segment.id] class="message-timeline-xray-overlay"
if (!offset) return null style={{ "--max-rib-width": `${maxRibWidth()}px` }}
const scroll = containerScroll() >
const top = scroll.containerTop + offset.layoutTop - scroll.scrollTop + offset.height / 2 <div class="message-timeline-xray-overlay-inner">
const bounds = clipBounds() <For each={xraySegments()}>
if (top < bounds.top - 20 || top > bounds.bottom + 20) return null {(segment) => {
return { top, left: scroll.left } const pos = () => {
} const offset = badgeOffsets()[segment.id]
const tokens = () => getSegmentTokens(segment) if (!offset) return null
const relativeWeight = () => tokens() / maxTokens() return { top: offset.layoutTop + offset.height / 2 }
const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0) }
const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP const tokens = () => getSegmentTokens(segment)
const isParent = segment.type === "assistant" || segment.type === "user" const relativeWeight = () => tokens() / maxTokens()
const displayTokens = () => const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0)
isParent ? getMessageAggregateTokens(segment.messageId) : tokens() const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP
return ( const isParent = segment.type === "assistant" || segment.type === "user"
<Show when={pos()}> const displayTokens = () =>
isParent ? getMessageAggregateTokens(segment.messageId) : tokens()
return (
<Show when={pos()}>
<div
class="message-timeline-xray-rib"
style={{
top: `${pos()!.top}px`,
left: "var(--xray-overhang)",
}}
>
<span class="message-timeline-xray-token-label">
{formatTokenLabel(displayTokens())}
</span>
<div <div
class="message-timeline-xray-rib" class="message-timeline-relative-bar"
style={{ style={{ "--segment-weight": relativeWeight() }}
top: `${pos()!.top}px`, />
left: `${pos()!.left}px`, <div
}} class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
> style={{ "--segment-weight": absoluteWeight() }}
<span class="message-timeline-xray-token-label"> />
{formatTokenLabel(displayTokens())} </div>
</span> </Show>
<div )
class="message-timeline-relative-bar" }}
style={{ "--segment-weight": relativeWeight() }} </For>
/>
<div
class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
style={{ "--segment-weight": absoluteWeight() }}
/>
</div>
</Show>
)
}}
</For>
</div> </div>
</Show> </div>
</Portal> </Show>
</div> </div>
) )
} }

View File

@@ -325,18 +325,30 @@
} }
.message-timeline-xray-overlay { .message-timeline-xray-overlay {
position: fixed; position: absolute;
top: 0; inset: 0;
left: 0; /* Extend the overlay box into the stream so ribs are not relying on
width: 100vw; overflow-visible behavior (which is brittle around scroll containers). */
height: 100vh; --xray-overhang: calc(var(--max-rib-width, 50vw) + 84px);
left: calc(-1 * var(--xray-overhang));
width: calc(100% + var(--xray-overhang));
overflow: hidden;
padding: 0.25rem;
pointer-events: none; pointer-events: none;
/* Below Command Palette (z-50) but above normal content. */ /* Above the scroll container background; still non-interactive. */
z-index: 40; z-index: 2;
--xray-scroll-y: 0px;
}
.message-timeline-xray-overlay-inner {
position: absolute;
inset: 0;
transform: translateY(var(--xray-scroll-y));
will-change: transform;
} }
.message-timeline-xray-rib { .message-timeline-xray-rib {
position: fixed; position: absolute;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;