返回 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 }
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
291 lines Plain Text