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