| 1 | // Ported from DeepSeek Harness c291e7961a (MIT). The rail lists the complete |
| 2 | // conversation outline; a turn whose body is not loaded yet still navigates. |
| 3 | import { |
| 4 | memo, useEffect, useId, useRef, useState, |
| 5 | type CSSProperties, type MouseEvent, type PointerEvent, |
| 6 | } from 'react' |
| 7 | import type { ReactNode } from 'react' |
| 8 | import type { useT } from '../../lib/i18n' |
| 9 | /** |
| 10 | * A mark either already has a mounted node — then `key` is the DOM anchor to |
| 11 | * scroll to — or must page history in first. |
| 12 | */ |
| 13 | export type TurnRailAnchor = { kind: 'loaded'; key: string } | { kind: 'unloaded'; recordId: string; messageId?: string } |
| 14 | /** |
| 15 | * `turn` is the mark's stable identity and React key: it is the outline record |
| 16 | * id, so it does not change when that turn finishes loading. `anchor` carries |
| 17 | * where to scroll and how to resolve an unloaded target. |
| 18 | */ |
| 19 | export interface TurnRailItem { turn: string; ordinal: number; prompt: string; response: string; answerKey?: string; anchor: TurnRailAnchor; unloaded?: boolean } |
| 20 | import css from './TurnNavigator.styles' |
| 21 | |
| 22 | interface TurnNavigatorProps { |
| 23 | readonly items: readonly TurnRailItem[] |
| 24 | readonly activeTurn: string | null |
| 25 | /** Turn whose jump is still paging history in; its mark pulses. */ |
| 26 | readonly busyTurn: string | null |
| 27 | readonly onNavigate: (item: TurnRailItem) => void |
| 28 | readonly renderPreview: (item: TurnRailItem) => ReactNode |
| 29 | readonly t: ReturnType<typeof useT> |
| 30 | /** |
| 31 | * The conversation outline is known to hold more than one turn. Keeps the |
| 32 | * rail's area while the outline loads, so a returning reader does not see |
| 33 | * navigation appear and disappear. |
| 34 | */ |
| 35 | readonly loading?: boolean |
| 36 | /** The outline could not be read; known markers stay and a retry is offered. */ |
| 37 | readonly failed?: boolean |
| 38 | /** True when the offered retry re-runs a failed jump rather than the read. */ |
| 39 | readonly jumpFailed?: boolean |
| 40 | /** Localized explanation of why the last jump failed. */ |
| 41 | readonly jumpReasonKey?: Parameters<ReturnType<typeof useT>>[0] |
| 42 | /** The outline stopped short of the whole session; the rail says so. */ |
| 43 | readonly truncated?: boolean |
| 44 | readonly onRetry?: () => void |
| 45 | /** Present while a jump can still be abandoned. */ |
| 46 | readonly onCancelJump?: () => void |
| 47 | } |
| 48 | |
| 49 | /** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */ |
| 50 | const TURN_SPACING_PX = 10 |
| 51 | /** Rail padding above the first mark and below the last one, per end. */ |
| 52 | const RAIL_INSET_PX = 6 |
| 53 | /** Fade band the mask reserves at a scrollable end. */ |
| 54 | const FADE_PX = 24 |
| 55 | |
| 56 | type TurnPositionStyle = CSSProperties & { |
| 57 | readonly '--turn-natural-position': string |
| 58 | } |
| 59 | |
| 60 | type TurnFrameStyle = CSSProperties & { |
| 61 | readonly '--turn-natural-height': string |
| 62 | readonly '--turn-rail-inset': string |
| 63 | readonly '--turn-scroll-top': string |
| 64 | } |
| 65 | |
| 66 | function itemPosition(index: number): TurnPositionStyle { |
| 67 | return { '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px` } |
| 68 | } |
| 69 | |
| 70 | function frameStyle(count: number, scrollTop: number): TurnFrameStyle { |
| 71 | return { |
| 72 | '--turn-natural-height': `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`, |
| 73 | '--turn-rail-inset': `${String(RAIL_INSET_PX)}px`, |
| 74 | '--turn-scroll-top': `${String(scrollTop)}px`, |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | function itemAtPointer( |
| 79 | items: readonly TurnRailItem[], |
| 80 | frame: HTMLElement, |
| 81 | scrollTop: number, |
| 82 | clientY: number, |
| 83 | ): TurnRailItem | undefined { |
| 84 | const rect = frame.getBoundingClientRect() |
| 85 | const offset = clientY - rect.top + scrollTop - RAIL_INSET_PX |
| 86 | const index = Math.max(0, Math.min(items.length - 1, Math.round(offset / TURN_SPACING_PX))) |
| 87 | return items[index] |
| 88 | } |
| 89 | |
| 90 | /** Scroll state the mask fades and follow logic read together. */ |
| 91 | interface RailScrollState { |
| 92 | readonly top: number |
| 93 | readonly viewportHeight: number |
| 94 | } |
| 95 | |
| 96 | const RAIL_AT_REST: RailScrollState = { top: 0, viewportHeight: 0 } |
| 97 | |
| 98 | function railScrollState(scroller: HTMLElement, viewportHeight: number): RailScrollState { |
| 99 | return { top: scroller.scrollTop, viewportHeight } |
| 100 | } |
| 101 | |
| 102 | function sameRailScrollState(left: RailScrollState, right: RailScrollState): boolean { |
| 103 | return left.top === right.top |
| 104 | && left.viewportHeight === right.viewportHeight |
| 105 | } |
| 106 | |
| 107 | function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, renderPreview, t, loading, failed, jumpFailed, jumpReasonKey, truncated, onRetry, onCancelJump }: TurnNavigatorProps) { |
| 108 | const [previewTurn, setPreviewTurn] = useState<string | null>(null) |
| 109 | const [scrollState, setScrollState] = useState<RailScrollState>(RAIL_AT_REST) |
| 110 | const scrollerRef = useRef<HTMLDivElement | null>(null) |
| 111 | /** While the pointer works the rail, follow must not move it under the hand. */ |
| 112 | const pointerInsideRef = useRef(false) |
| 113 | const previewId = useId() |
| 114 | const hasItems = items.length > 1 |
| 115 | |
| 116 | const syncScrollState = (viewportHeight?: number): void => { |
| 117 | const scroller = scrollerRef.current |
| 118 | if (scroller === null) return |
| 119 | setScrollState(current => { |
| 120 | const next = railScrollState(scroller, viewportHeight ?? current.viewportHeight) |
| 121 | return sameRailScrollState(current, next) ? current : next |
| 122 | }) |
| 123 | } |
| 124 | |
| 125 | // Frame resizes (band/composer changes) move the overflow edges without a |
| 126 | // scroll event; item count changes move the content height the same way. |
| 127 | useEffect(() => { |
| 128 | const scroller = scrollerRef.current |
| 129 | if (scroller === null || typeof ResizeObserver === 'undefined') return |
| 130 | const observer = new ResizeObserver(entries => { syncScrollState(entries[0]?.contentRect.height ?? 0) }) |
| 131 | observer.observe(scroller) |
| 132 | return () => { observer.disconnect() } |
| 133 | }, [hasItems]) |
| 134 | |
| 135 | // Keep the active mark visible: centre it whenever it leaves the scrollport, |
| 136 | // unless the reader's pointer is working the rail. |
| 137 | useEffect(() => { |
| 138 | const scroller = scrollerRef.current |
| 139 | const index = items.findIndex(item => item.turn === activeTurn) |
| 140 | if (scroller === null || index < 0 || pointerInsideRef.current) return |
| 141 | const markTop = index * TURN_SPACING_PX + RAIL_INSET_PX |
| 142 | const viewTop = scrollState.top |
| 143 | const viewHeight = scrollState.viewportHeight |
| 144 | if (viewHeight <= 0 || (markTop >= viewTop + FADE_PX && markTop <= viewTop + viewHeight - FADE_PX)) return |
| 145 | const target = Math.max(0, markTop - viewHeight / 2) |
| 146 | const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches |
| 147 | if (typeof scroller.scrollTo === 'function') { |
| 148 | scroller.scrollTo({ top: target, behavior: reduced ? 'auto' : 'smooth' }) |
| 149 | } else { |
| 150 | scroller.scroll?.({ top: target }) |
| 151 | } |
| 152 | }, [activeTurn, items, scrollState.top, scrollState.viewportHeight]) |
| 153 | |
| 154 | // A known multi-turn conversation keeps its rail area while the outline is |
| 155 | // still loading, and keeps the markers it already has after a failure. |
| 156 | if (items.length < 2) { |
| 157 | if (!loading && !failed) return null |
| 158 | return ( |
| 159 | <div className={css.slot}> |
| 160 | <nav className={css.frame} aria-label={t('chat.turnNavigation.label')} aria-busy={loading ? 'true' : undefined} |
| 161 | style={frameStyle(Math.max(items.length, 2), 0)}> |
| 162 | <div className={css.scroller}> |
| 163 | <div className={css.marks}> |
| 164 | {items.map((item, index) => ( |
| 165 | <div key={item.turn} className={css.markPosition} style={itemPosition(index)}> |
| 166 | <span className={css.mark} /> |
| 167 | </div> |
| 168 | ))} |
| 169 | </div> |
| 170 | </div> |
| 171 | </nav> |
| 172 | {failed && onRetry !== undefined && ( |
| 173 | <button type="button" className="btn" onClick={onRetry}>{t('chat.turnNavigation.retry')}</button> |
| 174 | )} |
| 175 | </div> |
| 176 | ) |
| 177 | } |
| 178 | const previewIndex = items.findIndex(item => item.turn === previewTurn) |
| 179 | const preview = previewIndex < 0 ? undefined : items[previewIndex] |
| 180 | const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex) |
| 181 | const previewAtPointer = (event: PointerEvent<HTMLElement>): void => { |
| 182 | const scrollTop = scrollerRef.current?.scrollTop ?? 0 |
| 183 | setPreviewTurn(itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)?.turn ?? null) |
| 184 | } |
| 185 | const navigateAtPointer = (event: MouseEvent<HTMLElement>): void => { |
| 186 | const scrollTop = scrollerRef.current?.scrollTop ?? 0 |
| 187 | const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY) |
| 188 | if (item !== undefined) onNavigate(item) |
| 189 | } |
| 190 | const fadeClasses = [css.scroller] |
| 191 | const naturalHeight = (items.length - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX |
| 192 | const viewportHeight = scrollState.viewportHeight || Math.min(naturalHeight, 420) |
| 193 | const firstVisible = Math.max(0, Math.floor((scrollState.top - RAIL_INSET_PX) / TURN_SPACING_PX) - 4) |
| 194 | const lastVisible = Math.min(items.length, Math.ceil((scrollState.top + viewportHeight) / TURN_SPACING_PX) + 4) |
| 195 | if (scrollState.top > 1) fadeClasses.push(css.fadeTop) |
| 196 | if (scrollState.top < naturalHeight - viewportHeight - 1) fadeClasses.push(css.fadeBottom) |
| 197 | return ( |
| 198 | <div className={css.slot}> |
| 199 | <nav |
| 200 | className={css.frame} |
| 201 | style={frameStyle(items.length, scrollState.top)} |
| 202 | aria-label={t('chat.turnNavigation.label')} |
| 203 | onClick={navigateAtPointer} |
| 204 | onPointerMove={previewAtPointer} |
| 205 | onPointerEnter={() => { pointerInsideRef.current = true }} |
| 206 | onPointerLeave={() => { |
| 207 | pointerInsideRef.current = false |
| 208 | setPreviewTurn(null) |
| 209 | }} |
| 210 | > |
| 211 | <div |
| 212 | ref={scrollerRef} |
| 213 | className={fadeClasses.join(' ')} |
| 214 | onScroll={() => { syncScrollState() }} |
| 215 | data-nav-truncated={truncated ? 'true' : undefined} |
| 216 | title={truncated ? t('chat.turnNavigation.truncated') : undefined} |
| 217 | > |
| 218 | <div className={css.marks}> |
| 219 | {items.slice(firstVisible, lastVisible).map((item, visibleIndex) => { |
| 220 | const index = firstVisible + visibleIndex |
| 221 | // The active mark is the mounted node, not the outline identity. |
| 222 | const active = item.anchor.kind === 'loaded' && item.anchor.key === activeTurn |
| 223 | const showingPreview = item.turn === previewTurn |
| 224 | const previewDistance = previewIndex < 0 ? -1 : Math.abs(index - previewIndex) |
| 225 | const classes = [css.mark] |
| 226 | |
| 227 | if (active) classes.push(css.markActive) |
| 228 | else if (showingPreview) classes.push(css.markPreview) |
| 229 | if (item.turn === busyTurn) classes.push(css.markBusy) |
| 230 | return ( |
| 231 | <div key={item.turn} className={css.markPosition} style={itemPosition(index)}> |
| 232 | <button |
| 233 | data-nav-turn={item.turn} |
| 234 | data-nav-unloaded={item.anchor.kind === 'unloaded' ? 'true' : undefined} |
| 235 | type="button" |
| 236 | className={classes.join(' ')} |
| 237 | aria-label={t( |
| 238 | 'chat.turnNavigation.jump', |
| 239 | { turn: item.ordinal }, |
| 240 | )} |
| 241 | aria-current={active ? 'true' : undefined} |
| 242 | aria-busy={item.turn === busyTurn ? 'true' : undefined} |
| 243 | data-preview-distance={previewDistance >= 0 && previewDistance <= 2 ? previewDistance : undefined} |
| 244 | aria-describedby={showingPreview ? previewId : undefined} |
| 245 | onClick={(event) => { |
| 246 | event.stopPropagation() |
| 247 | onNavigate(item) |
| 248 | }} |
| 249 | onFocus={() => { setPreviewTurn(item.turn) }} |
| 250 | onBlur={() => { setPreviewTurn(null) }} |
| 251 | /> |
| 252 | </div> |
| 253 | ) |
| 254 | })} |
| 255 | </div> |
| 256 | </div> |
| 257 | {preview !== undefined && previewPosition !== undefined && ( |
| 258 | <div id={previewId} role="tooltip" className={css.preview} style={previewPosition}> |
| 259 | {renderPreview(preview)} |
| 260 | </div> |
| 261 | )} |
| 262 | </nav> |
| 263 | {onRetry !== undefined && ( |
| 264 | <button type="button" className="btn chat-turn-navigation-retry" data-nav-retry={jumpFailed ? 'jump' : 'outline'} |
| 265 | onClick={onRetry} title={jumpReasonKey === undefined ? undefined : t(jumpReasonKey)}> |
| 266 | {t(jumpFailed ? 'chat.turnNavigation.retryJump' : 'chat.turnNavigation.retry')} |
| 267 | </button> |
| 268 | )} |
| 269 | {onCancelJump !== undefined && ( |
| 270 | <button type="button" className="btn chat-turn-navigation-cancel" onClick={onCancelJump}> |
| 271 | {t('chat.turnNavigation.cancel')} |
| 272 | </button> |
| 273 | )} |
| 274 | </div> |
| 275 | ) |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * Fixed-pitch rail of loaded turns with hover and focus previews. History is |
| 280 | * loaded by the transcript's existing paging action. Overflow scrolls |
| 281 | * inside the frame, gradient fades marking each scrollable end, and the |
| 282 | * active mark keeps itself in view while the pointer is elsewhere. |
| 283 | * |
| 284 | * Memoized because it renders two host elements per Turn while the |
| 285 | * enclosing view re-renders on every streaming delta: without the guard a long |
| 286 | * session rebuilds hundreds of marks per commit for a rail that only changes |
| 287 | * when a Turn is added, removed, or becomes active. Its props must therefore |
| 288 | * stay referentially stable across those commits. |
| 289 | */ |
| 290 | export const TurnNavigator = memo(TurnNavigatorRail) |
| 291 |