返回 DeepSeek-Reasonix
TurnNavigator.tsx
根目录 / desktop / frontend / src / components / harness-chat / TurnNavigator.tsx
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
314 lines Plain Text