mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
fix(history): stabilize native scrollbars and timeline navigation
Replace estimated timeline row measurements with an exact virtual layout derived from the marker and group-gap primitives. Hidden tools no longer contribute estimated heights, and the rail uses the same standard native scrollbar as the transcript: only marker width changes, never icons or vertical spacing. Manual rail browsing suppresses active-marker reveals until a transcript gesture, and intermediate window positions cannot reclaim it. Keep native transcript thumb drags escaped beyond the ordinary gesture timeout, delay adjacent-window paging until release, and recognize wheel gestures at an already-clamped boundary. Publish follow state before handing boundary callbacks control. Preserve access to first/latest commands at local window edges and default history search to excluding tools and reasoning. Exercise the real SessionView with mixed long replies, short replies, tools and idle records. Browser regressions verify exact scroll extent, native held-thumb dragging, streaming anchors, distant landing positions, adjacent windows, global endpoints, keyboard ownership, RTL and fractional zoom. The held-thumb regression is red with native drag ownership disabled and green with it; transcript rendering and nested-scroll regressions and UI typecheck pass.
This commit is contained in:
parent
b390646e0b
commit
a4e1eecaa9
12 changed files with 438 additions and 117 deletions
|
|
@ -80,7 +80,7 @@ Behavior for agents:
|
|||
- Use the `write` tool only when creating new files from scratch.
|
||||
- Browser rendering regressions live in `packages/ui/tests/browser/`, with deterministic HTTP fixtures beside them in `fixtures/`. Exercise the real Solid components and native event dispatcher rather than reimplementing rendering logic.
|
||||
- Transcript rows retain pointer hit testing during virtualizer scrolling (`styles/messaging/virtual-follow-list.css`), so nested code/tool scrollers and message controls receive gestures at their visible target.
|
||||
- Keep the global scrollbar-width default at zero specificity (`:where(...)` in `src/index.css`) so component visibility rules win. The timeline reserves a discreet stable gutter in `styles/messaging/timeline-scrollbar.css`: only marker rectangles narrow, icons and vertical spacing never shrink. The message transcript uses the shared thin scrollbar and nested code/tool outputs retain their own scrollbars. Validate with the full app stylesheet loaded.
|
||||
- Keep the global scrollbar-width default at zero specificity (`:where(...)` in `src/index.css`) so component visibility rules win. The timeline and transcript use the same standard native scrollbar: only marker rectangles narrow, icons and vertical spacing never shrink. `components/timeline-virtual-list.tsx` computes the timeline's exact extent from measured marker/gap primitives; never estimate hidden/offscreen rows. Manual rail browsing owns its position until an explicit transcript gesture. Native thumb drags retain ownership beyond the gesture timeout and defer window paging until release. Validate with the full stylesheet and real thumb gestures on mixed-height transcripts.
|
||||
- Run them with `npm run test:browser --workspace @codenomad/ui` after `npx playwright install chromium`. `CODENOMAD_BROWSER_PATH` optionally selects an existing Chromium executable; it does not target the installed application or user sessions.
|
||||
|
||||
## V2 Runtime Launch
|
||||
|
|
|
|||
|
|
@ -51,11 +51,25 @@ hidden views cancel work. A refresh retains the last successful snapshot and
|
|||
errors provide an explicit retry. The initial outline scan is proportional to the
|
||||
session size; a subsequent jump loads only its bounded destination window.
|
||||
|
||||
The timeline reserves a narrow gutter in `timeline-scrollbar.css`. Hover/focus
|
||||
changes contrast only. Marker rectangles give up width to the gutter; fixed-size
|
||||
icons and vertical geometry never scale with available width. Browser checks load
|
||||
the complete stylesheet and cover overflow, hover, keyboard focus, RTL and 125%
|
||||
zoom.
|
||||
The timeline uses the same standard native scrollbar as the transcript, with one
|
||||
ordinary gutter. Marker rectangles give up width to it; fixed-size icons and
|
||||
vertical geometry never scale with available width. `timeline-virtual-list.tsx`
|
||||
measures the marker height and gap primitives, computes exact offsets (including
|
||||
group spacing), and mounts only the visible range. Hidden tools are excluded from
|
||||
the layout. Browsing offscreen rows cannot change the scrollbar extent.
|
||||
|
||||
Manual rail browsing suppresses active-marker reveals until an explicit transcript
|
||||
gesture. Reveals wait one layout frame and are cancelled by a newer selection or
|
||||
manual rail interaction. Intermediate transcript positions during window loading
|
||||
cannot move the rail. Native transcript scrollbar drags escape following at press,
|
||||
retain ownership beyond the ordinary wheel/key deadline, and postpone boundary
|
||||
paging until release. A wheel gesture at an already-clamped boundary can request
|
||||
the adjacent window even when no DOM scroll event is emitted. First/latest controls
|
||||
also account for off-window messages, not just the local scroll offset.
|
||||
|
||||
Browser checks load the complete stylesheet and cover overflow, hover, keyboard
|
||||
focus, RTL and 125% zoom, mixed text/tool/idle records, actual native thumb dragging,
|
||||
and viewport anchor stability during streaming and distant jumps.
|
||||
|
||||
## Validation
|
||||
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
const [searchRetryGeneration, setSearchRetryGeneration] = createSignal(0)
|
||||
const [searchMatches, setSearchMatches] = createSignal<Array<SessionSearchMatch & { sessionId: string }>>([])
|
||||
const [searchWorkspace, setSearchWorkspace] = createSignal(false)
|
||||
const [includeTechnical, setIncludeTechnical] = createSignal(true)
|
||||
const [includeTechnical, setIncludeTechnical] = createSignal(false)
|
||||
const [searchPageCursor, setSearchPageCursor] = createSignal<string>()
|
||||
const [nextSearchCursor, setNextSearchCursor] = createSignal<string | null>(null)
|
||||
const [skippedSearchMessages, setSkippedSearchMessages] = createSignal(0)
|
||||
|
|
@ -383,11 +383,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
})
|
||||
|
||||
const [activeSegmentId, setActiveSegmentId] = createSignal<string | null>(null)
|
||||
const [revealActiveToken, setRevealActiveToken] = createSignal(0)
|
||||
|
||||
const isActive = createMemo(() => props.isActive !== false)
|
||||
const [listApi, setListApi] = createSignal<VirtualFollowListApi | null>(null)
|
||||
const [listState, setListState] = createSignal<VirtualFollowListState | null>(null)
|
||||
const scrollButtonsCount = createMemo(() => listState()?.scrollButtonsCount() ?? 0)
|
||||
const showFirstButton = () => Boolean(props.hasMoreMessages || listState()?.showScrollTopButton())
|
||||
const showLatestButton = () => !isLatestWindow(store().getMessageWindow(props.sessionId)) || Boolean(listState()?.showScrollBottomButton())
|
||||
const scrollButtonsCount = createMemo(() => Number(showFirstButton()) + Number(showLatestButton()))
|
||||
|
||||
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||
|
|
@ -931,6 +934,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
pagingWindowController = null
|
||||
pagingWindow = false
|
||||
setNavigationPending(false)
|
||||
const activeMessageId = api.captureScrollSnapshot()?.anchorKey ?? listState()?.activeKey()
|
||||
const segment = timelineSegments().find(segment => segment.messageId === activeMessageId)
|
||||
if (segment) setActiveSegmentId(segment.id)
|
||||
if (direction === "latest" || direction === "oldest") setRevealActiveToken(value => value + 1)
|
||||
persistMessageScrollSnapshot({ snapshot: api.captureScrollSnapshot() })
|
||||
} catch (error) {
|
||||
if (isCurrent()) {
|
||||
|
|
@ -1189,13 +1196,16 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
persistMessageScrollSnapshot({ snapshot })
|
||||
}}
|
||||
onUserReachedTop={() => { void pageWindow("older", (api) => api.scrollToBottom({ immediate: true })) }}
|
||||
onScrollIntent={() => { if (navigationPending()) cancelWindowNavigation() }}
|
||||
onScrollIntent={() => {
|
||||
setRevealActiveToken(value => value + 1)
|
||||
if (navigationPending()) cancelWindowNavigation()
|
||||
}}
|
||||
onUserReachedBottom={() => { void pageWindow("newer", (api) => api.scrollToTop({ immediate: true })) }}
|
||||
onJumpTop={() => { void pageWindow("oldest", (api) => api.scrollToTop({ immediate: true })) }}
|
||||
onJumpBottom={() => { void pageWindow("latest", (api) => api.scrollToBottom({ immediate: true })) }}
|
||||
onMouseUp={() => handleStreamMouseUp()}
|
||||
onActiveKeyChange={(messageId) => {
|
||||
if (!messageId) return
|
||||
if (!messageId || pagingWindow) return
|
||||
const firstSeg = timelineSegments().find((s) => s.messageId === messageId)
|
||||
if (firstSeg) {
|
||||
setActiveSegmentId((current) => (current === firstSeg.id ? current : firstSeg.id))
|
||||
|
|
@ -1213,9 +1223,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
||||
registerApi={registerListApi}
|
||||
registerState={(state) => setListState(state)}
|
||||
renderControls={(state) => (
|
||||
renderControls={() => (
|
||||
<div class="message-scroll-controls">
|
||||
<Show when={state.showScrollTopButton()}>
|
||||
<Show when={showFirstButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
|
|
@ -1226,7 +1236,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
<ArrowUp class="message-scroll-icon w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={state.showScrollBottomButton()}>
|
||||
<Show when={showLatestButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
|
|
@ -1511,6 +1521,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
onSegmentClick={handleTimelineSegmentClick}
|
||||
expandedMessageIds={expandedMessageIds}
|
||||
activeSegmentId={activeSegmentId()}
|
||||
revealActiveToken={revealActiveToken()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
showToolSegments={showTimelineToolsPreference()}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Show, createEffect, createMemo, createSignal, onCleanup, on, type Component, type Accessor } from "solid-js"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
import TimelineVirtualList, { type TimelineListHandle } from "./timeline-virtual-list"
|
||||
import { Dynamic, Portal } from "solid-js/web"
|
||||
import MessagePreview from "./message-preview"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
|
|
@ -39,10 +39,10 @@ interface MessageTimelineProps {
|
|||
showToolSegments?: boolean
|
||||
searchMatchedSegmentIds?: Accessor<Set<string>>
|
||||
activeSearchSegmentId?: Accessor<string | null>
|
||||
revealActiveToken?: number
|
||||
}
|
||||
|
||||
const MAX_TOOLTIP_LENGTH = 220
|
||||
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
|
|
@ -382,29 +382,27 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
})
|
||||
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
|
||||
let revealTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const cancelReveal = () => {
|
||||
if (revealTimer !== undefined) clearTimeout(revealTimer)
|
||||
revealTimer = undefined
|
||||
}
|
||||
const [virtualizerHandle, setVirtualizerHandle] = createSignal<TimelineListHandle | undefined>()
|
||||
let browsingRail = false
|
||||
const cancelReveal = () => { browsingRail = true }
|
||||
|
||||
const handleScroll = () => {
|
||||
cancelReveal()
|
||||
if (hoveredSegment()) clearHoverPreview()
|
||||
}
|
||||
|
||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||
if (!activeId) return
|
||||
revealTimer = setTimeout(() => {
|
||||
revealTimer = undefined
|
||||
const index = segmentIndexById().get(activeId)
|
||||
// The rail is globally virtualized: an animated reveal can keep moving
|
||||
// under the pointer while the reader chooses another distant destination.
|
||||
if (index !== undefined) virtualizerHandle()?.scrollToIndex(index, { align: "nearest" })
|
||||
}, 120)
|
||||
onCleanup(cancelReveal)
|
||||
}))
|
||||
createEffect(on(() => props.revealActiveToken, () => { browsingRail = false }))
|
||||
createEffect(() => {
|
||||
props.revealActiveToken
|
||||
const activeId = props.activeSegmentId
|
||||
const index = activeId ? segmentIndexById().get(activeId) : undefined
|
||||
const handle = virtualizerHandle()
|
||||
// Wait for the exact extent to reach layout, but never queue a reveal past
|
||||
// a manual rail gesture or a newer active marker.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!browsingRail && index !== undefined) handle?.scrollToIndex(index)
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const element = tooltipElement()
|
||||
|
|
@ -438,12 +436,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
if (pendingFrame !== null) return
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = null
|
||||
const handle = virtualizerHandle()
|
||||
const offset = getBottomAnchoredViewportOffset(handle?.scrollOffset ?? element.scrollTop, pendingHeightDelta)
|
||||
const offset = getBottomAnchoredViewportOffset(element.scrollTop, pendingHeightDelta)
|
||||
pendingHeightDelta = 0
|
||||
const maxOffset = Math.max((handle?.scrollSize ?? element.scrollHeight) - (handle?.viewportSize ?? element.clientHeight), 0)
|
||||
if (handle) handle.scrollTo(Math.min(offset, maxOffset))
|
||||
else element.scrollTop = Math.min(offset, maxOffset)
|
||||
const maxOffset = Math.max(element.scrollHeight - element.clientHeight, 0)
|
||||
element.scrollTop = Math.min(offset, maxOffset)
|
||||
})
|
||||
})
|
||||
observer.observe(element)
|
||||
|
|
@ -470,12 +466,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
return set
|
||||
})
|
||||
|
||||
const segmentIndexById = createMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
|
||||
return map
|
||||
})
|
||||
|
||||
const segmentStates = createMemo(() => {
|
||||
const expandedMessages = props.expandedMessageIds?.()
|
||||
const resolvedStore = store()
|
||||
|
|
@ -517,9 +507,11 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
const visibleSegments = createMemo(() => props.segments.filter(segment => !segmentStateFor(segment.id).hidden))
|
||||
const segmentIndexById = createMemo(() => new Map(visibleSegments().map((segment, index) => [segment.id, index])))
|
||||
const segmentSpacerHeights = createMemo(() => {
|
||||
const states = segmentStates()
|
||||
const result = new Map<string, string>()
|
||||
const result = new Map<string, number>()
|
||||
let previousVisible: TimelineSegment | null = null
|
||||
|
||||
for (let index = 0; index < props.segments.length; index += 1) {
|
||||
|
|
@ -527,12 +519,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
const state = states.get(segment.id)
|
||||
|
||||
if (state?.hidden) {
|
||||
result.set(segment.id, "0")
|
||||
result.set(segment.id, 0)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!previousVisible) {
|
||||
result.set(segment.id, "0")
|
||||
result.set(segment.id, 0)
|
||||
previousVisible = segment
|
||||
continue
|
||||
}
|
||||
|
|
@ -550,12 +542,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
&& messagesWithTools().has(previousVisible.messageId)
|
||||
|
||||
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
|
||||
result.set(
|
||||
segment.id,
|
||||
gapUnits === 1
|
||||
? "var(--message-timeline-segment-gap)"
|
||||
: "calc(var(--message-timeline-segment-gap) * 2)",
|
||||
)
|
||||
result.set(segment.id, gapUnits)
|
||||
|
||||
previousVisible = segment
|
||||
}
|
||||
|
|
@ -572,13 +559,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
class="message-timeline"
|
||||
data-segment-count={props.segments.length}
|
||||
role="navigation"
|
||||
tabIndex={0}
|
||||
aria-label={t("messageTimeline.ariaLabel")}
|
||||
onScroll={handleScroll}
|
||||
onWheel={cancelReveal}
|
||||
onPointerEnter={cancelReveal}
|
||||
onPointerDown={cancelReveal}
|
||||
onFocusIn={cancelReveal}
|
||||
onTouchStart={cancelReveal}
|
||||
onKeyDown={cancelReveal}
|
||||
>
|
||||
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
|
||||
<TimelineVirtualList register={setVirtualizerHandle} items={visibleSegments()} scrollElement={scrollElement()} gap={segment => segmentSpacerHeights().get(segment.id) ?? 0}>
|
||||
{(segment) => {
|
||||
const isActive = () => props.activeSegmentId === segment.id
|
||||
const isSearchMatch = () => props.searchMatchedSegmentIds?.().has(segment.id) ?? false
|
||||
|
|
@ -610,7 +601,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
|
||||
return (
|
||||
<div class="message-timeline-item">
|
||||
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||
<button
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
|
|
@ -619,7 +609,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
aria-label={segment.tooltip || segment.label}
|
||||
data-message-id={segment.messageId}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
onClick={() => { clearHoverPreview(); props.onSegmentClick?.(segment) }}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
|
|
@ -629,7 +619,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||
</div>
|
||||
)
|
||||
}}
|
||||
</Virtualizer>
|
||||
</TimelineVirtualList>
|
||||
<Show when={previewData()}>
|
||||
{(data) => {
|
||||
onCleanup(() => setTooltipElement(null))
|
||||
|
|
|
|||
83
packages/ui/src/components/timeline-virtual-list.tsx
Normal file
83
packages/ui/src/components/timeline-virtual-list.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { For, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
|
||||
export interface TimelineListHandle {
|
||||
scrollToIndex: (index: number) => void
|
||||
}
|
||||
|
||||
/** Timeline markers have known geometry, unlike transcript messages. Measure
|
||||
* the two CSS primitives once and give the native scrollbar the exact extent,
|
||||
* including offscreen group gaps. No estimated or zero-height hidden rows. */
|
||||
export default function TimelineVirtualList<T>(props: {
|
||||
items: T[]
|
||||
gap: (item: T) => number
|
||||
scrollElement?: HTMLDivElement
|
||||
register: (handle: TimelineListHandle) => void
|
||||
children: (item: T) => JSX.Element
|
||||
}) {
|
||||
const [size, setSize] = createSignal({ marker: 20, gap: 5.6 })
|
||||
const [viewport, setViewport] = createSignal({ top: 0, height: 0 })
|
||||
let marker!: HTMLButtonElement
|
||||
let gap!: HTMLDivElement
|
||||
const layout = createMemo(() => {
|
||||
const geometry = size()
|
||||
let extent = 0
|
||||
const rows = props.items.map(item => {
|
||||
const space = props.gap(item) * geometry.gap
|
||||
const top = extent
|
||||
extent += geometry.marker + space
|
||||
return { item, top, space, height: geometry.marker + space }
|
||||
})
|
||||
return { rows, extent }
|
||||
})
|
||||
const visible = createMemo(() => {
|
||||
const { rows } = layout(), { top, height } = viewport()
|
||||
let low = 0, high = rows.length
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1
|
||||
if (rows[middle].top + rows[middle].height < top - 240) low = middle + 1
|
||||
else high = middle
|
||||
}
|
||||
const start = low
|
||||
while (low < rows.length && rows[low].top < top + height + 240) low++
|
||||
return rows.slice(start, low).map(row => row.item)
|
||||
})
|
||||
const byItem = createMemo(() => new Map(layout().rows.map(row => [row.item, row])))
|
||||
createEffect(() => {
|
||||
const element = props.scrollElement
|
||||
if (!element) return
|
||||
const readViewport = () => setViewport({ top: element.scrollTop, height: element.clientHeight })
|
||||
const measure = () => {
|
||||
const next = { marker: parseFloat(getComputedStyle(marker).height), gap: parseFloat(getComputedStyle(gap).height) }
|
||||
if (next.marker > 0 && (next.marker !== size().marker || next.gap !== size().gap)) setSize(next)
|
||||
readViewport()
|
||||
}
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(element)
|
||||
observer.observe(marker)
|
||||
observer.observe(gap)
|
||||
element.addEventListener("scroll", readViewport, { passive: true })
|
||||
measure()
|
||||
onCleanup(() => { observer.disconnect(); element.removeEventListener("scroll", readViewport) })
|
||||
})
|
||||
props.register({ scrollToIndex: index => {
|
||||
const element = props.scrollElement, row = layout().rows[index]
|
||||
if (!element || !row) return
|
||||
if (row.top < element.scrollTop) element.scrollTop = row.top
|
||||
else if (row.top + row.height > element.scrollTop + element.clientHeight) {
|
||||
element.scrollTop = row.top + row.height - element.clientHeight
|
||||
}
|
||||
setViewport({ top: element.scrollTop, height: element.clientHeight })
|
||||
} })
|
||||
return <>
|
||||
<div class="timeline-measure" aria-hidden="true" inert>
|
||||
<button ref={marker} class="message-timeline-segment" tabIndex={-1} />
|
||||
<div ref={gap} style={{ height: "var(--message-timeline-segment-gap)" }} />
|
||||
</div>
|
||||
<div class="timeline-virtual-content" style={{ height: `${layout().extent}px` }}>
|
||||
<For each={visible()}>{item => <div class="timeline-virtual-row" style={{
|
||||
top: `${byItem().get(item)!.top}px`, height: `${byItem().get(item)!.height}px`,
|
||||
"padding-top": `${byItem().get(item)!.space}px`,
|
||||
}}>{props.children(item)}</div>}</For>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
@ -165,6 +165,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
let windowShiftGeneration = 0
|
||||
let virtualContentResizeObserver: ResizeObserver | null = null
|
||||
let observedVirtualContent: HTMLElement | null = null
|
||||
let nativeScrollbarDragging = false
|
||||
|
||||
function invalidateScrollRestore() {
|
||||
restoreToken.invalidate()
|
||||
|
|
@ -222,6 +223,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
})
|
||||
|
||||
function markUserScrollIntent(direction: "up" | "down" | null) {
|
||||
programmaticScrollUntil = 0
|
||||
props.onScrollIntent?.(direction)
|
||||
cancelActiveScrollRestore()
|
||||
scrollController.setUserIntent(direction, performance.now() + USER_SCROLL_INTENT_WINDOW_MS)
|
||||
|
|
@ -239,6 +241,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
element.dispatchEvent(new Event("scroll"))
|
||||
virtuaHandle()?.scrollBy(0)
|
||||
}
|
||||
// At a clamped edge a wheel/key produces no scroll event. The gesture
|
||||
// itself must be able to request the next historical window.
|
||||
if (direction) updateScrollStateFromDom()
|
||||
}
|
||||
|
||||
function markProgrammaticScroll() {
|
||||
|
|
@ -364,18 +369,25 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
setShowScrollTopButton(hasItems && !atTop)
|
||||
|
||||
const now = performance.now()
|
||||
if (nativeScrollbarDragging) {
|
||||
const previous = scrollController.snapshot()
|
||||
scrollController.setUserIntent(offset < previous.lastObservedOffset ? "up" : offset > previous.lastObservedOffset ? "down" : previous.userIntentDirection, now + USER_SCROLL_INTENT_WINDOW_MS)
|
||||
}
|
||||
const programmatic = hasProgrammaticScrollIntent()
|
||||
const result = scrollController.observeViewport(metrics, now, programmatic)
|
||||
const observed = scrollController.observeViewport(metrics, now, programmatic)
|
||||
const result = nativeScrollbarDragging ? scrollController.setFollow(false) : observed
|
||||
const restoring = result.state.restoring
|
||||
const intent = result.state.userIntentDirection
|
||||
const hasFreshIntent = now <= result.state.userIntentUntil
|
||||
if (shouldNavigateAtBoundary({ atBoundary: atTop, restoring, programmatic, hasFreshIntent, intent, direction: "up" })) {
|
||||
// A boundary callback may synchronously start a historical load and escape
|
||||
// follow mode. Publish this observation before handing it that authority.
|
||||
syncControllerResult(result)
|
||||
if (!nativeScrollbarDragging && shouldNavigateAtBoundary({ atBoundary: atTop, restoring, programmatic, hasFreshIntent, intent, direction: "up" })) {
|
||||
props.onUserReachedTop?.()
|
||||
}
|
||||
if (shouldNavigateAtBoundary({ atBoundary: atBottom, restoring, programmatic, hasFreshIntent, intent, direction: "down" })) {
|
||||
if (!nativeScrollbarDragging && shouldNavigateAtBoundary({ atBoundary: atBottom, restoring, programmatic, hasFreshIntent, intent, direction: "down" })) {
|
||||
props.onUserReachedBottom?.()
|
||||
}
|
||||
syncControllerResult(result)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
|
|
@ -774,6 +786,18 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
}
|
||||
if (event.button === 0) lastPrimaryPointerY = event.clientY
|
||||
if (event.target !== element) return
|
||||
const rect = element.getBoundingClientRect()
|
||||
const scale = rect.width / element.offsetWidth
|
||||
const contentLeft = rect.left + element.clientLeft * scale
|
||||
const contentRight = contentLeft + element.clientWidth * scale
|
||||
const overlayEdge = element.offsetWidth === element.clientWidth && (
|
||||
getComputedStyle(element).direction === "rtl" ? event.clientX < rect.left + 12 * scale : event.clientX >= rect.right - 12 * scale
|
||||
)
|
||||
if (event.button === 0 && element.scrollHeight > element.clientHeight
|
||||
&& (event.clientX < contentLeft || event.clientX >= contentRight || overlayEdge)) {
|
||||
nativeScrollbarDragging = true
|
||||
dispatchFollowEvent({ type: "set-follow", enabled: false })
|
||||
}
|
||||
markUserScrollIntent(null)
|
||||
}
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
|
|
@ -785,7 +809,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
if ((event.buttons & 1) === 0) lastPrimaryPointerY = null
|
||||
}
|
||||
const handlePointerEnd = () => {
|
||||
const wasDragging = nativeScrollbarDragging
|
||||
nativeScrollbarDragging = false
|
||||
lastPrimaryPointerY = null
|
||||
if (wasDragging) {
|
||||
programmaticScrollUntil = 0
|
||||
updateScrollStateFromDom()
|
||||
}
|
||||
}
|
||||
let lastTouchY: number | null = null
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
|
|
@ -838,6 +868,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
element.addEventListener("pointermove", handlePointerMove)
|
||||
element.addEventListener("pointerup", handlePointerEnd)
|
||||
element.addEventListener("pointercancel", handlePointerEnd)
|
||||
window.addEventListener("pointerup", handlePointerEnd)
|
||||
window.addEventListener("mouseup", handlePointerEnd)
|
||||
window.addEventListener("blur", handlePointerEnd)
|
||||
element.addEventListener("touchstart", handleTouchStart, { passive: true })
|
||||
element.addEventListener("touchmove", handleTouchMove, { passive: true })
|
||||
element.addEventListener("touchend", handleTouchEnd, { passive: true })
|
||||
|
|
@ -850,6 +883,10 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||
element.removeEventListener("pointermove", handlePointerMove)
|
||||
element.removeEventListener("pointerup", handlePointerEnd)
|
||||
element.removeEventListener("pointercancel", handlePointerEnd)
|
||||
window.removeEventListener("pointerup", handlePointerEnd)
|
||||
window.removeEventListener("mouseup", handlePointerEnd)
|
||||
window.removeEventListener("blur", handlePointerEnd)
|
||||
nativeScrollbarDragging = false
|
||||
element.removeEventListener("touchstart", handleTouchStart)
|
||||
element.removeEventListener("touchmove", handleTouchMove)
|
||||
element.removeEventListener("touchend", handleTouchEnd)
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@
|
|||
@import "./messaging/session-timeline-rail.css";
|
||||
@import "./messaging/tool-call.css";
|
||||
@import "./messaging/log-view.css";
|
||||
@import './messaging/timeline-virtual-list.css';
|
||||
|
|
|
|||
|
|
@ -1,40 +1,6 @@
|
|||
/* Reserve the same space before overflow and during hover/focus. Symmetric
|
||||
gutters keep the segment axis centered; only thumb contrast changes. */
|
||||
:root body .message-timeline {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
/* Use the application's standard native scrollbar. Its single gutter only
|
||||
consumes marker width; icon size and vertical rhythm are independent. */
|
||||
.message-timeline {
|
||||
scrollbar-gutter: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent !important;
|
||||
}
|
||||
|
||||
:root body .message-timeline:hover,
|
||||
:root body .message-timeline:focus-within {
|
||||
scrollbar-color: var(--scrollbar-thumb-hover) transparent !important;
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
:root body .message-timeline,
|
||||
:root body .message-timeline:hover,
|
||||
:root body .message-timeline:focus-within {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: auto !important;
|
||||
}
|
||||
|
||||
:root body .message-timeline::-webkit-scrollbar {
|
||||
width: calc(var(--scrollbar-size) / 2);
|
||||
}
|
||||
|
||||
:root body .message-timeline::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root body .message-timeline::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:root body .message-timeline:hover::-webkit-scrollbar-thumb,
|
||||
:root body .message-timeline:focus-within::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
packages/ui/src/styles/messaging/timeline-virtual-list.css
Normal file
18
packages/ui/src/styles/messaging/timeline-virtual-list.css
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
.timeline-virtual-content {
|
||||
flex: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.timeline-virtual-row {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
.timeline-measure {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
@ -1,3 +1,18 @@
|
|||
export const navigationMessageId = (index: number) => `msg_${String(index).padStart(5, "0")}`
|
||||
export const navigationMessage = (index: number) => ({ id: navigationMessageId(index), type: "user" as const,
|
||||
time: { created: index + 1 }, text: `Passage ${index}\n\n${"Navigation history content. ".repeat(5 + index % 7)}` })
|
||||
|
||||
export const mixedNavigationMessage = (index: number): any => {
|
||||
const id = navigationMessageId(index)
|
||||
if (index % 5 === 0) return navigationMessage(index)
|
||||
if (index % 5 === 4) return { id, type: "idle", outcome: "completed", time: { created: index + 1 } }
|
||||
return { id, type: "assistant", agent: "build", model: { providerID: "fixture", id: "fixture" },
|
||||
time: { created: index + 1, completed: index + 2 }, content: [
|
||||
{ type: "reasoning", text: "Considering the next operation. ".repeat(5) },
|
||||
...(index % 5 === 1 ? [{ type: "tool", id: `${id}-tool`, name: "shell", time: { created: index + 1 },
|
||||
state: { status: "completed", input: { command: "fixture" }, content: [{ type: "text", text: "Output line\n".repeat(80) }] } }] : []),
|
||||
{ type: "text", text: `Passage ${index}\n\n` + (index % 5 === 2
|
||||
? "Long paragraph with **formatting** and a [link](https://example.org).\n\n".repeat(40)
|
||||
: "Short response.") },
|
||||
] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ import { sessions, setSessions, setActiveSession, setProviders } from "../../../
|
|||
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { loadMessages, loadLatestMessageWindow } from "../../../src/stores/session-api"
|
||||
import { navigationMessage, navigationMessageId } from "./history-navigation-data"
|
||||
import { navigationMessage, navigationMessageId, mixedNavigationMessage } from "./history-navigation-data"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const instanceId = "navigation", sessionId = "s", count = 1500
|
||||
const makeMessage = location.search.includes("mixed") ? mixedNavigationMessage : navigationMessage
|
||||
const model = { providerID: "fixture", id: "fixture" }
|
||||
const assistantId = "msg_streaming"
|
||||
let nativeLists = 0, time = 10000, live = "", streaming = false
|
||||
|
|
@ -32,11 +33,17 @@ const client: any = {
|
|||
instructions: { entry: { remove: async () => {}, put: async () => {} } },
|
||||
},
|
||||
model: { default: async () => ({ data: model }) },
|
||||
message: { list: async ({ cursor, limit = 200 }: any = {}) => {
|
||||
message: { list: async ({ cursor, limit = 200, order }: any = {}) => {
|
||||
nativeLists++
|
||||
if (order === 'asc' || cursor?.startsWith('asc:')) {
|
||||
const start = cursor ? Number(cursor.slice(4)) : 0
|
||||
const end = Math.min(count, start + limit)
|
||||
return { data: Array.from({ length: end - start }, (_, index) => makeMessage(start + index)),
|
||||
cursor: end < count ? { next: `asc:${end}` } : {} }
|
||||
}
|
||||
const end = cursor ? Number(cursor) : count
|
||||
const start = Math.max(0, end - limit + (streaming && !cursor ? 1 : 0))
|
||||
const data: any[] = Array.from({ length: end - start }, (_, index) => navigationMessage(start + index))
|
||||
const data: any[] = Array.from({ length: end - start }, (_, index) => makeMessage(start + index))
|
||||
if (streaming && !cursor) data.push({ id: assistantId, type: "assistant", agent: "build", model, time: { created: 10000 }, content: [{ type: "text", text: live }] })
|
||||
return { data: data.reverse(), cursor: start ? { next: String(start) } : {} }
|
||||
} },
|
||||
|
|
@ -57,6 +64,7 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}
|
|||
</Show></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
;(window as any).fixture = {
|
||||
id: navigationMessageId,
|
||||
tools: (showTimelineTools: boolean) => updatePreferences({ showTimelineTools }),
|
||||
openSearch: () => { updatePreferences({ locale: "en" }); setSessionSearchOpen(instanceId, sessionId, true) },
|
||||
reload: () => loadMessages(instanceId, sessionId, { force: true }),
|
||||
latest: () => loadLatestMessageWindow(instanceId, sessionId),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { chromium, type Browser, type Page } from "playwright"
|
|||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { readNavigationWindow, readSessionOutline } from "../../../server/src/opencode/session-pruning/navigation-store"
|
||||
import { navigationMessage, navigationMessageId } from "./fixtures/history-navigation-data"
|
||||
import { navigationMessage, navigationMessageId, mixedNavigationMessage } from "./fixtures/history-navigation-data"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
|
|
@ -22,21 +22,22 @@ before(async () => {
|
|||
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, server: { host: "127.0.0.1", port: 0, hmr: false, watch: null } })
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined,
|
||||
ignoreDefaultArgs: ["--hide-scrollbars"], args: ["--disable-features=OverlayScrollbar"] })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
async function fixture() {
|
||||
async function fixture(mixed = false) {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
|
||||
const db = new DatabaseSync(":memory:")
|
||||
db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT);
|
||||
CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT);
|
||||
CREATE UNIQUE INDEX seq_idx ON session_message(session_id,seq);
|
||||
INSERT INTO session_v2 VALUES ('s','/fixture','p',NULL,NULL);`)
|
||||
const insert = db.prepare("INSERT INTO session_message VALUES (?,'s','user',?,?)")
|
||||
const insert = db.prepare("INSERT INTO session_message VALUES (?,'s',?,?,?)")
|
||||
for (let index = 0; index < 1500; index++) {
|
||||
const { id, type: _type, ...data } = navigationMessage(index)
|
||||
insert.run(id, index, JSON.stringify(data))
|
||||
const { id, type, ...data } = (mixed ? mixedNavigationMessage : navigationMessage)(index)
|
||||
insert.run(id, type, index, JSON.stringify(data))
|
||||
}
|
||||
const errors: string[] = [], windows: any[] = []
|
||||
const reads = new Set<Promise<unknown>>()
|
||||
|
|
@ -60,7 +61,7 @@ async function fixture() {
|
|||
}
|
||||
await route.fulfill({ contentType: "application/json", body: JSON.stringify(response) }).catch(() => {})
|
||||
})
|
||||
await page.goto(url)
|
||||
await page.goto(url + (mixed ? "?mixed" : ""))
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await page.locator('.message-timeline[data-segment-count="1500"]').waitFor()
|
||||
return { page, windows, errors,
|
||||
|
|
@ -75,6 +76,7 @@ async function fixture() {
|
|||
}
|
||||
|
||||
async function clickTimeline(page: Page, index: number, checkReaderPosition = false) {
|
||||
await page.locator('.message-timeline').hover()
|
||||
await page.locator(".message-timeline").evaluate((element, index) => {
|
||||
element.scrollTop = element.scrollHeight * index / 1500
|
||||
}, index)
|
||||
|
|
@ -90,6 +92,18 @@ async function clickTimeline(page: Page, index: number, checkReaderPosition = fa
|
|||
}
|
||||
const snapshot = (page: Page) => page.evaluate(() => (window as any).fixture.snapshot())
|
||||
|
||||
async function assertPassageAtTop(page: Page, index: number) {
|
||||
await page.waitForFunction(id => {
|
||||
const stream = document.querySelector('.message-stream')!
|
||||
const row = stream.querySelector(`[data-virtual-follow-key="${id}"]`)
|
||||
return row && Math.abs(row.getBoundingClientRect().top - stream.getBoundingClientRect().top) < 2
|
||||
}, navigationMessageId(index))
|
||||
await page.waitForTimeout(500)
|
||||
const offset = await page.locator(`[data-virtual-follow-key="${navigationMessageId(index)}"]`).evaluate(row =>
|
||||
row.getBoundingClientRect().top - document.querySelector('.message-stream')!.getBoundingClientRect().top)
|
||||
assert(Math.abs(offset) < 2, `selected passage ${index} moved by ${offset}px`)
|
||||
}
|
||||
|
||||
test("global timeline jumps over 1200 messages in one bounded window and restores the selected passage", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
|
|
@ -99,6 +113,7 @@ test("global timeline jumps over 1200 messages in one bounded window and restore
|
|||
await clickTimeline(f.page, 250)
|
||||
await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids.includes("msg_00250"))
|
||||
await f.page.locator('.message-stream [data-message-id="msg_00250"]').first().waitFor()
|
||||
await assertPassageAtTop(f.page, 250)
|
||||
const jumped = await snapshot(f.page)
|
||||
assert.equal(f.windows.length, 1)
|
||||
assert.equal(jumped.nativeLists, before.nativeLists, "jump does not replay native pages")
|
||||
|
|
@ -106,6 +121,7 @@ test("global timeline jumps over 1200 messages in one bounded window and restore
|
|||
assert.equal(jumped.window.kind, "history")
|
||||
assert.deepEqual(jumped.model, before.model)
|
||||
await f.page.evaluate(() => (window as any).fixture.reload())
|
||||
await assertPassageAtTop(f.page, 250)
|
||||
const restored = await snapshot(f.page)
|
||||
assert.deepEqual(restored.ids, jumped.ids)
|
||||
assert.equal(f.windows.length, 2)
|
||||
|
|
@ -117,6 +133,7 @@ test("global timeline jumps over 1200 messages in one bounded window and restore
|
|||
await f.page.evaluate(() => (window as any).fixture.switchAway())
|
||||
await f.page.evaluate(() => (window as any).fixture.return())
|
||||
await f.page.locator(".message-stream").waitFor()
|
||||
await assertPassageAtTop(f.page, 250)
|
||||
assert((await snapshot(f.page)).ids.includes(navigationMessageId(250)))
|
||||
assert.deepEqual(f.errors, [])
|
||||
const captures = path.join(os.tmpdir(), "opencode"); await mkdir(captures, { recursive: true })
|
||||
|
|
@ -157,7 +174,146 @@ test("resident streaming keeps a distant timeline marker mounted", async () => {
|
|||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("timeline gutter stays fixed across overflow, hover, focus, RTL and fractional zoom", async () => {
|
||||
test("mixed timeline keeps an exact scrollbar extent throughout manual browsing and hidden tools", async () => {
|
||||
const f = await fixture(true)
|
||||
try {
|
||||
const rail = f.page.locator('.message-timeline')
|
||||
for (const tools of [true, false]) {
|
||||
await f.page.evaluate(tools => (window as any).fixture.tools(tools), tools)
|
||||
await rail.hover()
|
||||
await f.page.waitForTimeout(400)
|
||||
const heights: number[] = []
|
||||
for (const fraction of [0, 0.25, 0.75, 0.5, 1, 0]) {
|
||||
await rail.evaluate((element, fraction) => { element.scrollTop = (element.scrollHeight - element.clientHeight) * fraction }, fraction)
|
||||
await f.page.waitForTimeout(200)
|
||||
heights.push(await rail.evaluate(element => element.scrollHeight))
|
||||
}
|
||||
assert(Math.max(...heights) - Math.min(...heights) <= 1, `scrollbar extent changes while browsing (tools=${tools}): ${heights}`)
|
||||
}
|
||||
assert.deepEqual(f.errors, [])
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("mixed transcript lands on the clicked message after distant jumps and retains it during streaming", async () => {
|
||||
const f = await fixture(true)
|
||||
try {
|
||||
for (const index of [250, 900, 100, 1450]) {
|
||||
await clickTimeline(f.page, index)
|
||||
await assertPassageAtTop(f.page, index)
|
||||
await f.page.evaluate(() => (window as any).fixture.stream('Another token. '))
|
||||
await assertPassageAtTop(f.page, index)
|
||||
}
|
||||
assert.deepEqual(f.errors, [])
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
async function dragNativeThumb(page: Page, selector: string, fraction: number) {
|
||||
await page.locator(selector).hover()
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame))
|
||||
const geometry = await page.locator(selector).evaluate(element => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const inset = element.offsetWidth - element.clientWidth
|
||||
const track = element.clientHeight - inset * 2
|
||||
const height = Math.max(18, track * element.clientHeight / element.scrollHeight)
|
||||
const travel = track - height
|
||||
const ratio = element.scrollTop / (element.scrollHeight - element.clientHeight)
|
||||
return { x: rect.right - inset / 2, top: rect.top + inset, height, travel,
|
||||
y: ratio > 0.99 ? rect.bottom - 16 : ratio < 0.01 ? rect.top + 16 : rect.top + inset + height / 2 + travel * ratio }
|
||||
})
|
||||
await page.mouse.move(geometry.x, geometry.y)
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(750)
|
||||
await page.mouse.move(geometry.x, geometry.top + geometry.height / 2 + geometry.travel * fraction, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
test("native transcript thumb remains under reader control after a held press", async () => {
|
||||
const f = await fixture(true)
|
||||
try {
|
||||
await f.page.waitForTimeout(500)
|
||||
await dragNativeThumb(f.page, '.message-stream', 0.5)
|
||||
await f.page.waitForTimeout(500)
|
||||
const ratio = await f.page.locator('.message-stream').evaluate(element => element.scrollTop / (element.scrollHeight - element.clientHeight))
|
||||
assert(ratio > 0.2 && ratio < 0.8, `native thumb snapped away from middle: ${ratio}`)
|
||||
const before = await f.page.locator('.message-stream').evaluate(element => {
|
||||
const top = element.getBoundingClientRect().top
|
||||
const row = Array.from(element.querySelectorAll<HTMLElement>('[data-virtual-follow-key]')).find(row => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top <= top && rect.bottom > top
|
||||
})!
|
||||
return { key: row.dataset.virtualFollowKey, offset: row.getBoundingClientRect().top - top }
|
||||
})
|
||||
await f.page.evaluate(() => (window as any).fixture.stream('Streaming during manual scrollbar reading.'))
|
||||
await f.page.waitForTimeout(400)
|
||||
const after = await f.page.locator(`[data-virtual-follow-key="${before.key}"]`).evaluate(element =>
|
||||
element.getBoundingClientRect().top - document.querySelector('.message-stream')!.getBoundingClientRect().top)
|
||||
assert(Math.abs(before.offset - after) < 2, `streaming displaced visible passage: ${before.offset} -> ${after}`)
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("wheel at a stationary historical boundary opens the adjacent window", async () => {
|
||||
const f = await fixture(true)
|
||||
try {
|
||||
await clickTimeline(f.page, 250)
|
||||
await assertPassageAtTop(f.page, 250)
|
||||
const before = await snapshot(f.page)
|
||||
await f.page.locator('.message-stream[tabindex]').evaluate(element => { element.scrollTop = 0 })
|
||||
await f.page.waitForTimeout(500)
|
||||
const stream = await f.page.locator('.message-stream[tabindex]').boundingBox()
|
||||
assert(stream)
|
||||
await f.page.mouse.move(stream.x + 25, stream.y + 25)
|
||||
await f.page.mouse.wheel(0, -200)
|
||||
await f.page.waitForFunction(id => (window as any).fixture.snapshot().ids[0] !== id, before.ids[0], { timeout: 4000 })
|
||||
await assertPassageAtTop(f.page, 170)
|
||||
const older = await snapshot(f.page)
|
||||
await f.page.locator('.message-stream[tabindex]').evaluate(element => { element.scrollTop = element.scrollHeight })
|
||||
await f.page.waitForTimeout(500)
|
||||
await f.page.mouse.wheel(0, 200)
|
||||
await f.page.waitForFunction(id => (window as any).fixture.snapshot().ids.at(-1) !== id, older.ids.at(-1), { timeout: 4000 })
|
||||
assert((await snapshot(f.page)).ids.includes(older.ids.at(-2)), 'newer window retains the boundary passage')
|
||||
assert.deepEqual(f.errors, [])
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("first/latest controls remain available at local boundaries and land at actual global ends", async () => {
|
||||
const f = await fixture(true)
|
||||
try {
|
||||
await clickTimeline(f.page, 250)
|
||||
await assertPassageAtTop(f.page, 250)
|
||||
const stream = f.page.locator('.message-stream[tabindex]')
|
||||
await stream.evaluate(element => { element.scrollTop = element.scrollHeight })
|
||||
await f.page.waitForTimeout(500)
|
||||
await f.page.getByRole('button', { name: /Scroll to latest message|Aller au dernier message/ }).click()
|
||||
await f.page.waitForFunction(() => (window as any).fixture.snapshot().window.kind === 'latest')
|
||||
await f.page.waitForTimeout(400)
|
||||
assert(await stream.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop < 2))
|
||||
await f.page.getByRole('button', { name: /Scroll to first message|Aller au premier message/ }).click()
|
||||
await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids[0] === 'msg_00000')
|
||||
await assertPassageAtTop(f.page, 0)
|
||||
const selected = f.page.locator('.message-timeline [aria-current="true"]')
|
||||
await selected.waitFor()
|
||||
assert.equal(await selected.getAttribute('data-message-id'), 'msg_00000')
|
||||
assert.deepEqual(f.errors, [])
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("keyboard scrolling owns the timeline without paging the transcript", async () => {
|
||||
const f = await fixture(true)
|
||||
try {
|
||||
const rail = f.page.locator('.message-timeline')
|
||||
await rail.focus()
|
||||
await rail.press('Home')
|
||||
await f.page.waitForTimeout(400)
|
||||
assert.equal(await rail.evaluate(element => element.scrollTop), 0)
|
||||
await rail.press('End')
|
||||
await f.page.waitForTimeout(400)
|
||||
assert(await rail.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop < 2))
|
||||
assert.equal(f.windows.length, 0)
|
||||
assert.deepEqual(f.errors, [])
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("timeline uses the standard scrollbar and only rectangle width changes in RTL and fractional zoom", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const rail = f.page.locator(".message-timeline")
|
||||
|
|
@ -166,24 +322,25 @@ test("timeline gutter stays fixed across overflow, hover, focus, RTL and fractio
|
|||
const measure = () => rail.evaluate(element => {
|
||||
const style = getComputedStyle(element)
|
||||
return { width: element.clientWidth, gutter: element.offsetWidth - element.clientWidth, scrollbar: style.scrollbarWidth,
|
||||
segment: element.querySelector(".message-timeline-segment")!.getBoundingClientRect().width,
|
||||
height: element.querySelector(".message-timeline-segment")!.getBoundingClientRect().height,
|
||||
segment: element.querySelector(".message-timeline-segment[data-message-id]")!.getBoundingClientRect().width,
|
||||
height: element.querySelector(".message-timeline-segment[data-message-id]")!.getBoundingClientRect().height,
|
||||
icon: element.querySelector(".message-timeline-icon")!.getBoundingClientRect().width }
|
||||
})
|
||||
const normal = await measure()
|
||||
assert.notEqual(normal.scrollbar, "none")
|
||||
assert(normal.gutter > 0 && normal.gutter <= 10, JSON.stringify({ direction, zoom, normal }))
|
||||
const standard = await f.page.locator('.message-stream[tabindex]').evaluate(element => ({
|
||||
gutter: element.offsetWidth - element.clientWidth, scrollbar: getComputedStyle(element).scrollbarWidth,
|
||||
}))
|
||||
assert.equal(normal.gutter, standard.gutter)
|
||||
assert.equal(normal.scrollbar, standard.scrollbar)
|
||||
await rail.hover()
|
||||
const hover = await measure()
|
||||
assert.equal(hover.width, normal.width)
|
||||
assert.equal(hover.segment, normal.segment)
|
||||
assert.equal(hover.height, normal.height)
|
||||
assert.equal(hover.icon, normal.icon)
|
||||
await rail.locator("button").first().focus()
|
||||
await rail.locator("button[data-message-id]").first().focus()
|
||||
assert.equal((await measure()).width, normal.width)
|
||||
await rail.evaluate(element => { element.style.overflowY = "hidden" })
|
||||
assert.equal((await measure()).width, normal.width, "reserved gutter remains without overflow")
|
||||
await rail.evaluate(element => { element.style.overflowY = "auto" })
|
||||
await rail.evaluate(element => { element.style.scrollbarGutter = "auto"; element.style.setProperty("scrollbar-width", "none", "important") })
|
||||
const withoutGutter = await measure()
|
||||
assert(withoutGutter.segment > normal.segment, "only the surrounding rectangle gives up width to the gutter")
|
||||
|
|
@ -194,6 +351,27 @@ test("timeline gutter stays fixed across overflow, hover, focus, RTL and fractio
|
|||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("native timeline thumb follows the pointer without moving the transcript or snapping back during streaming", async () => {
|
||||
const f = await fixture(true)
|
||||
try {
|
||||
const before = await snapshot(f.page)
|
||||
await dragNativeThumb(f.page, '.message-timeline', 0.5)
|
||||
await f.page.waitForTimeout(400)
|
||||
const read = () => f.page.locator('.message-timeline').evaluate(element => ({
|
||||
top: element.scrollTop, extent: element.scrollHeight,
|
||||
ratio: element.scrollTop / (element.scrollHeight - element.clientHeight),
|
||||
}))
|
||||
const middle = await read()
|
||||
assert(Math.abs(middle.ratio - 0.5) < 0.02, JSON.stringify(middle))
|
||||
await f.page.evaluate(() => (window as any).fixture.stream('Live token while browsing the rail.'))
|
||||
await f.page.waitForTimeout(400)
|
||||
assert(Math.abs((await read()).top - middle.top) < 2, 'new transcript content must not reclaim the rail')
|
||||
assert.equal((await snapshot(f.page)).window.kind, before.window.kind)
|
||||
assert.equal(f.windows.length, 0, 'dragging the rail must not page the transcript')
|
||||
assert.deepEqual(f.errors, [])
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("a newer distant destination wins and a hidden session rejects a pending jump", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue