Просмотр исходного кода

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.
Yichen Jiang 1 месяц назад
Родитель
Сommit
6af1ee49b1

+ 47 - 12
packages/client/ui-chat/src/client/chat/TurnNavigator.module.css

@@ -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;
   }
 }

+ 140 - 55
packages/client/ui-chat/src/client/chat/TurnNavigator.tsx

@@ -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

+ 39 - 2
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -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 })