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:
parent
b3064cca77
commit
6af1ee49b1
3 changed files with 226 additions and 69 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue