feat(ui-chat): scrollable fixed-pitch turn rail

Marks keep a fixed 10px pitch instead of compressing into the frame:
overflow scrolls inside a hidden-scrollbar scroller with gradient fades
over each still-scrollable end, the preview compensates the rail scroll,
and the active mark keeps itself centred while the pointer is off the
rail. Pointer-to-mark mapping now works in ladder coordinates.
This commit is contained in:
Yichen Jiang 2026-08-30 13:11:31 +08:00 • committed by imccyu
parent b3064cca77
commit 6af1ee49b1
3 changed files with 226 additions and 69 deletions

View file

@ -8,7 +8,7 @@
pointer-events: none;
}
.rail {
.frame {
/* The band a reader actually sees: the scrollport minus the sticky composer
stack covering its floor. ConversationRoot publishes both measurements on
the scrollport; the fallbacks carry the first paint before its observer
@ -24,9 +24,9 @@
padding, so the rail gives that inset back and keeps 12px of its own. */
right: calc(12px - (var(--dsh-composer-side-clearance) + 16px));
width: 28px;
/* Never taller than the band it centers in: a short window (a tall composer,
a low viewport) shrinks the rail instead of pushing marks under the
composer or above the scrollport. */
/* Fixed-pitch marks never compress: a ladder taller than the band scrolls
inside this frame instead of pushing marks under the composer or above
the scrollport. */
height: min(
var(--turn-natural-height),
max(0px, calc(var(--turn-rail-band) - 64px)),
@ -38,14 +38,46 @@
transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.marks {
/* The frame's inner scroller: no visible scrollbar, no scroll chaining into
the transcript, and gradient fades over the ends that can still scroll. */
.scroller {
position: absolute;
inset: var(--turn-rail-inset) 0;
inset: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.scroller::-webkit-scrollbar {
display: none;
}
.fadeTop {
mask-image: linear-gradient(to bottom, transparent 0, #000 24px, #000 100%);
}
.fadeBottom {
mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%);
}
.fadeTop.fadeBottom {
mask-image: linear-gradient(
to bottom,
transparent 0,
#000 24px,
#000 calc(100% - 24px),
transparent 100%
);
}
.marks {
position: relative;
height: var(--turn-natural-height);
}
.markPosition {
position: absolute;
top: min(var(--turn-natural-position), var(--turn-position));
top: calc(var(--turn-natural-position) + var(--turn-rail-inset));
right: 0;
left: 0;
height: 10px;
@ -118,13 +150,14 @@
.preview {
position: absolute;
/* Centered on its mark (mark positions are measured inside the rail inset),
then held clear of both rail ends. */
/* Centered on its mark. Mark positions live in the scrolled ladder, so the
frame-level preview subtracts the scroller's offset, then holds clear of
both frame ends. */
top: clamp(
0px,
calc(
min(var(--turn-natural-position), var(--turn-position))
+ var(--turn-rail-inset) - var(--turn-preview-height) / 2
var(--turn-natural-position) + var(--turn-rail-inset)
- var(--turn-scroll-top, 0px) - var(--turn-preview-height) / 2
),
calc(100% - var(--turn-preview-height))
);
@ -186,12 +219,14 @@
}
@media (prefers-reduced-motion: reduce) {
.rail,
.frame,
.scroller,
.markPosition,
.mark::before,
.markBusy::before,
.preview {
transition: none;
animation: none;
scroll-behavior: auto;
}
}

View file

@ -1,5 +1,6 @@
import {
memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent,
memo, useEffect, useId, useRef, useState,
type CSSProperties, type MouseEvent, type PointerEvent,
} from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { TurnRailItem } from './turn-rail-items.ts'
@ -14,102 +15,184 @@ interface TurnNavigatorProps {
readonly t: ChatViewSlotProps['t']
}
/** Resting gap between neighbouring marks before the rail compresses to fit. */
/** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */
const TURN_SPACING_PX = 10
/** Rail padding above the first mark and below the last one, per end. */
const RAIL_INSET_PX = 6
/** Fade band the mask reserves at a scrollable end. */
const FADE_PX = 24
type TurnPositionStyle = CSSProperties & {
readonly '--turn-natural-position': string
readonly '--turn-position': string
}
type TurnRailStyle = CSSProperties & {
type TurnFrameStyle = CSSProperties & {
readonly '--turn-natural-height': string
readonly '--turn-rail-inset': string
readonly '--turn-scroll-top': string
}
function itemPosition(index: number, count: number): TurnPositionStyle {
const ratio = count <= 1 ? 0 : index / (count - 1)
return {
'--turn-natural-position': `${String(index * TURN_SPACING_PX)}px`,
'--turn-position': `${String(ratio * 100)}%`,
}
function itemPosition(index: number): TurnPositionStyle {
return { '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px` }
}
function railSize(count: number): TurnRailStyle {
function frameStyle(count: number, scrollTop: number): TurnFrameStyle {
return {
'--turn-natural-height': `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`,
'--turn-rail-inset': `${String(RAIL_INSET_PX)}px`,
'--turn-scroll-top': `${String(scrollTop)}px`,
}
}
function itemAtPointer(
items: readonly TurnRailItem[],
rail: HTMLElement,
frame: HTMLElement,
scrollTop: number,
clientY: number,
): TurnRailItem | undefined {
const rect = rail.getBoundingClientRect()
const usableHeight = Math.max(1, rect.height - 2 * RAIL_INSET_PX)
const ratio = Math.max(0, Math.min(1, (clientY - rect.top - RAIL_INSET_PX) / usableHeight))
return items[Math.round(ratio * (items.length - 1))]
const rect = frame.getBoundingClientRect()
const offset = clientY - rect.top + scrollTop - RAIL_INSET_PX
const index = Math.max(0, Math.min(items.length - 1, Math.round(offset / TURN_SPACING_PX)))
return items[index]
}
/** Scroll state the mask fades and follow logic read together. */
interface RailScrollState {
readonly top: number
readonly canScrollUp: boolean
readonly canScrollDown: boolean
}
const RAIL_AT_REST: RailScrollState = { top: 0, canScrollUp: false, canScrollDown: false }
function railScrollState(scroller: HTMLElement): RailScrollState {
const top = scroller.scrollTop
return {
top,
canScrollUp: top > 1,
canScrollDown: top < scroller.scrollHeight - scroller.clientHeight - 1,
}
}
function sameRailScrollState(left: RailScrollState, right: RailScrollState): boolean {
return left.top === right.top
&& left.canScrollUp === right.canScrollUp
&& left.canScrollDown === right.canScrollDown
}
function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnNavigatorProps) {
const [previewTurn, setPreviewTurn] = useState<number | null>(null)
const [scrollState, setScrollState] = useState<RailScrollState>(RAIL_AT_REST)
const scrollerRef = useRef<HTMLDivElement | null>(null)
/** While the pointer works the rail, follow must not move it under the hand. */
const pointerInsideRef = useRef(false)
const previewId = useId()
const syncScrollState = (): void => {
const scroller = scrollerRef.current
if (scroller === null) return
const next = railScrollState(scroller)
setScrollState(current => sameRailScrollState(current, next) ? current : next)
}
// Frame resizes (band/composer changes) move the overflow edges without a
// scroll event; item count changes move the content height the same way.
useEffect(() => {
const scroller = scrollerRef.current
if (scroller === null || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(syncScrollState)
observer.observe(scroller)
return () => { observer.disconnect() }
}, [])
useEffect(syncScrollState, [items.length])
// Keep the active mark visible: centre it whenever it leaves the scrollport,
// unless the reader's pointer is working the rail.
useEffect(() => {
const scroller = scrollerRef.current
const index = items.findIndex(item => item.turn === activeTurn)
if (scroller === null || index < 0 || pointerInsideRef.current) return
const markTop = index * TURN_SPACING_PX + RAIL_INSET_PX
const viewTop = scroller.scrollTop
const viewHeight = scroller.clientHeight
if (viewHeight <= 0 || (markTop >= viewTop + FADE_PX && markTop <= viewTop + viewHeight - FADE_PX)) return
const target = Math.max(0, markTop - viewHeight / 2)
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
if (typeof scroller.scrollTo === 'function') {
scroller.scrollTo({ top: target, behavior: reduced ? 'auto' : 'smooth' })
} else {
scroller.scrollTop = target
}
syncScrollState()
}, [activeTurn, items])
if (items.length < 2) return null
const previewIndex = items.findIndex(item => item.turn === previewTurn)
const preview = previewIndex < 0 ? undefined : items[previewIndex]
const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex, items.length)
const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex)
const previewAtPointer = (event: PointerEvent<HTMLElement>): void => {
setPreviewTurn(itemAtPointer(items, event.currentTarget, event.clientY)?.turn ?? null)
const scrollTop = scrollerRef.current?.scrollTop ?? 0
setPreviewTurn(itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)?.turn ?? null)
}
const navigateAtPointer = (event: MouseEvent<HTMLElement>): void => {
const item = itemAtPointer(items, event.currentTarget, event.clientY)
const scrollTop = scrollerRef.current?.scrollTop ?? 0
const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)
if (item !== undefined) onNavigate(item)
}
const fadeClasses = [css.scroller]
if (scrollState.canScrollUp) fadeClasses.push(css.fadeTop)
if (scrollState.canScrollDown) fadeClasses.push(css.fadeBottom)
return (
<div className={css.slot}>
<nav
className={css.rail}
style={railSize(items.length)}
className={css.frame}
style={frameStyle(items.length, scrollState.top)}
aria-label={t('chat.turnNavigation.label')}
onClick={navigateAtPointer}
onPointerMove={previewAtPointer}
onPointerLeave={() => { setPreviewTurn(null) }}
onPointerEnter={() => { pointerInsideRef.current = true }}
onPointerLeave={() => {
pointerInsideRef.current = false
setPreviewTurn(null)
}}
>
<div className={css.marks}>
{items.map((item, index) => {
const active = item.turn === activeTurn
const showingPreview = item.turn === previewTurn
const classes = [css.mark]
if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded)
if (active) classes.push(css.markActive)
else if (showingPreview) classes.push(css.markPreview)
if (item.turn === busyTurn) classes.push(css.markBusy)
return (
<div key={item.turn} className={css.markPosition} style={itemPosition(index, items.length)}>
<button
type="button"
className={classes.join(' ')}
aria-label={t(
item.anchor.kind === 'loaded' ? 'chat.turnNavigation.jump' : 'chat.turnNavigation.jumpLoad',
{ turn: item.turn },
)}
aria-current={active ? 'true' : undefined}
aria-busy={item.turn === busyTurn ? 'true' : undefined}
aria-describedby={showingPreview ? previewId : undefined}
onClick={(event) => {
event.stopPropagation()
onNavigate(item)
}}
onFocus={() => { setPreviewTurn(item.turn) }}
onBlur={() => { setPreviewTurn(null) }}
/>
</div>
)
})}
<div
ref={scrollerRef}
className={fadeClasses.join(' ')}
onScroll={() => { syncScrollState() }}
>
<div className={css.marks}>
{items.map((item, index) => {
const active = item.turn === activeTurn
const showingPreview = item.turn === previewTurn
const classes = [css.mark]
if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded)
if (active) classes.push(css.markActive)
else if (showingPreview) classes.push(css.markPreview)
if (item.turn === busyTurn) classes.push(css.markBusy)
return (
<div key={item.turn} className={css.markPosition} style={itemPosition(index)}>
<button
type="button"
className={classes.join(' ')}
aria-label={t(
item.anchor.kind === 'loaded' ? 'chat.turnNavigation.jump' : 'chat.turnNavigation.jumpLoad',
{ turn: item.turn },
)}
aria-current={active ? 'true' : undefined}
aria-busy={item.turn === busyTurn ? 'true' : undefined}
aria-describedby={showingPreview ? previewId : undefined}
onClick={(event) => {
event.stopPropagation()
onNavigate(item)
}}
onFocus={() => { setPreviewTurn(item.turn) }}
onBlur={() => { setPreviewTurn(null) }}
/>
</div>
)
})}
</div>
</div>
{preview !== undefined && previewPosition !== undefined && (
<div id={previewId} role="tooltip" className={css.preview} style={previewPosition}>
@ -125,8 +208,10 @@ function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnN
}
/**
* Compact rail of every known Turn — loaded marks scroll, unloaded marks page
* history in first — with hover and focus previews.
* Fixed-pitch rail of every known Turn — loaded marks scroll, unloaded marks
* page history in first — with hover and focus previews. Overflow scrolls
* inside the frame, gradient fades marking each scrollable end, and the
* active mark keeps itself in view while the pointer is elsewhere.
*
* Memoized because it renders two host elements per Turn while the
* enclosing view re-renders on every streaming delta: without the guard a long

View file

@ -578,7 +578,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
const second = view.getByRole('button', { name: '跳转到第 2 轮' })
const secondPosition = second.parentElement as HTMLElement
expect(secondPosition.style.getPropertyValue('--turn-position')).toBe('0%')
expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('0px')
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
@ -598,8 +598,8 @@ describe('ChatView', () => {
})
const movedSecond = view.getByRole('button', { name: '跳转到第 2 轮' })
expect(movedSecond.parentElement).toBe(secondPosition)
// Fixed pitch: the mark moves one slot down and never compresses.
expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('10px')
expect(secondPosition.style.getPropertyValue('--turn-position')).toBe('50%')
})
it('extends the rail with unloaded outline turns, pages on click, and falls back when nothing lands', async () => {
@ -632,6 +632,43 @@ describe('ChatView', () => {
expect(view.getByRole('button', { name: '跳转到第 3 轮' }).getAttribute('aria-current')).toBe('true')
})
it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => {
const h = makeHarness(
{ nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },
{ hasMore: true },
)
h.setOutline({
turns: Array.from({ length: 60 }, (_, index) => ({
turn: index + 1,
seq: index * 4,
prompt: `p${String(index + 1)}`,
})),
})
const view = render(<h.ChatView {...h.props} />)
const nav = view.getByRole('navigation', { name: '轮次导航' })
// 60 marks at the fixed 10px pitch: the ladder keeps its natural height.
expect(nav.style.getPropertyValue('--turn-natural-height')).toBe('602px')
const scroller = nav.querySelector('[class*="scroller"]') as HTMLElement
Object.defineProperty(scroller, 'scrollHeight', { value: 602, configurable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, configurable: true })
scroller.scrollTop = 0
fireEvent.scroll(scroller)
expect(scroller.className).toContain('fadeBottom')
expect(scroller.className).not.toContain('fadeTop')
scroller.scrollTop = 150
fireEvent.scroll(scroller)
expect(scroller.className).toContain('fadeTop')
expect(scroller.className).toContain('fadeBottom')
expect(nav.style.getPropertyValue('--turn-scroll-top')).toBe('150px')
// Pointer mapping subtracts the rail scroll: y=94 with scrollTop 150 is
// natural offset 238px → the 25th mark.
vi.spyOn(nav, 'getBoundingClientRect').mockReturnValue({ top: 0 } as DOMRect)
fireEvent.pointerMove(nav, { clientY: 94 })
expect(view.getByRole('tooltip').textContent).toContain('p25')
})
it('lands a jump on its turn once the paged rows commit', async () => {
const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
const h = makeHarness({ nodes: later }, { hasMore: true })