@@ -21,6 +21,8 @@ import type { Message } from '../types/message'
2121import { useChatViewport } from '../features/chat/chatViewport'
2222import { buildOutlineSourceEntries , truncateOutlineLabel , type OutlineSourceEntry } from './outlineIndexModel'
2323
24+ const EMPTY_MESSAGES : Message [ ] = [ ]
25+
2426// ─── Types ──────────────────────────────────
2527
2628interface OutlineEntry {
@@ -31,8 +33,9 @@ interface OutlineEntry {
3133}
3234
3335interface OutlineIndexProps {
34- messages : Message [ ]
36+ messages ? : Message [ ]
3537 sourceEntries ?: OutlineSourceEntry [ ]
38+ ownerByMessageId ?: Map < string , string >
3639 visibleMessageIds ?: string [ ]
3740 currentHighlightEnabled ?: boolean
3841 onScrollToMessageId : ( messageId : string ) => void
@@ -62,8 +65,8 @@ interface FisheyeProps {
6265 entries : OutlineEntry [ ]
6366 onSelect : ( messageId : string ) => void
6467 visual : VisualConfig
65- /** 包含可见消息所属 user prompt 的 ID 集合(territory 概念) */
66- ownerVisibleIds ?: Set < string >
68+ /** 当前可见区域所属 user prompt 在 entries 中的位置。 */
69+ ownerVisibleIndex : number
6770}
6871
6972// ─── Fisheye Math ───────────────────────────
@@ -183,20 +186,18 @@ function applyTickVisual(tick: HTMLElement, state: 'focused' | 'visible' | 'defa
183186 tick . style . boxShadow = 'none'
184187}
185188
186- function applyVisibleTickHighlight ( items : CachedItem [ ] , entries : OutlineEntry [ ] , ownerVisibleIds ?: Set < string > ) {
187- const firstVisibleIndex = findBiasedVisibleIndex ( entries , ownerVisibleIds )
189+ function applyVisibleTickHighlight ( items : CachedItem [ ] , visibleIndex : number ) {
188190 for ( let i = 0 ; i < items . length ; i ++ ) {
189- applyTickVisual ( items [ i ] . tick , i === firstVisibleIndex ? 'visible' : 'default' )
191+ applyTickVisual ( items [ i ] . tick , i === visibleIndex ? 'visible' : 'default' )
190192 }
191193}
192194
193195function applyFisheye (
194196 items : CachedItem [ ] ,
195- entries : OutlineEntry [ ] ,
196197 cursorY : number | null ,
197198 strengths : number [ ] ,
198199 config : FisheyeConfig ,
199- ownerVisibleIds ?: Set < string > ,
200+ visibleIndex : number ,
200201) : { alive : boolean ; focusIndex : number ; maxStrength : number } {
201202 let alive = false
202203 let focusIndex = - 1
@@ -220,8 +221,6 @@ function applyFisheye(
220221
221222 // Pass 2: 应用视觉
222223 // 优先级:focused(鼠标悬停)> firstVisible(territory 内偏置后的 user prompt)> 默认
223- const firstVisibleIndex = findBiasedVisibleIndex ( entries , ownerVisibleIds )
224-
225224 for ( let i = 0 ; i < items . length ; i ++ ) {
226225 const { el, tick, label } = items [ i ]
227226 const s = strengths [ i ]
@@ -230,7 +229,7 @@ function applyFisheye(
230229 tick . style . width = `${ config . tickWidth . min + s * ( config . tickWidth . max - config . tickWidth . min ) } px`
231230 if ( focused ) {
232231 applyTickVisual ( tick , 'focused' )
233- } else if ( i === firstVisibleIndex ) {
232+ } else if ( i === visibleIndex ) {
234233 applyTickVisual ( tick , 'visible' )
235234 } else {
236235 applyTickVisual ( tick , 'default' )
@@ -336,8 +335,9 @@ function TickRail({ entries, visual }: TickRailProps) {
336335// ─── Entry Point ────────────────────────────
337336
338337export const OutlineIndex = memo ( function OutlineIndex ( {
339- messages,
338+ messages = EMPTY_MESSAGES ,
340339 sourceEntries,
340+ ownerByMessageId,
341341 visibleMessageIds,
342342 currentHighlightEnabled = true ,
343343 onScrollToMessageId,
@@ -350,36 +350,43 @@ export const OutlineIndex = memo(function OutlineIndex({
350350 ( ) => sliceAroundVisible ( allEntries , visibleMessageIds ?? [ ] , visual . maxEntries ) ,
351351 [ allEntries , visibleMessageIds , visual . maxEntries ] ,
352352 )
353+ const resolvedOwnerByMessageId = useMemo ( ( ) => {
354+ if ( ownerByMessageId ) return ownerByMessageId
353355
354- // 构建 territory 映射:每个消息 ID → 所属 user prompt 的 ID
355- const ownerVisibleIds = useMemo ( ( ) => {
356- const set = new Set < string > ( )
357- if ( ! currentHighlightEnabled || ! visibleMessageIds || ! messages . length ) return set
358356 let lastUserMsgId : string | null = null
359357 const ownerMap = new Map < string , string > ( )
360358 for ( const msg of messages ) {
361359 if ( msg . info . role === 'user' ) lastUserMsgId = msg . info . id
362360 if ( lastUserMsgId ) ownerMap . set ( msg . info . id , lastUserMsgId )
363361 }
362+ return ownerMap
363+ } , [ messages , ownerByMessageId ] )
364+
365+ // 构建 territory 映射:每个消息 ID → 所属 user prompt 的 ID
366+ const ownerVisibleIds = useMemo ( ( ) => {
367+ const set = new Set < string > ( )
368+ if ( ! currentHighlightEnabled || ! visibleMessageIds ) return set
369+
364370 for ( const vid of visibleMessageIds ) {
365- const owner = ownerMap . get ( vid )
371+ const owner = resolvedOwnerByMessageId . get ( vid )
366372 if ( owner ) set . add ( owner )
367373 }
368374 return set
369- } , [ currentHighlightEnabled , messages , visibleMessageIds ] )
375+ } , [ currentHighlightEnabled , resolvedOwnerByMessageId , visibleMessageIds ] )
376+ const ownerVisibleIndex = useMemo ( ( ) => findBiasedVisibleIndex ( entries , ownerVisibleIds ) , [ entries , ownerVisibleIds ] )
370377
371378 if ( entries . length < 2 ) return null
372379
373380 return interaction . outlineInteraction === 'touch' ? (
374- < TouchFisheye entries = { entries } onSelect = { onScrollToMessageId } visual = { visual } ownerVisibleIds = { ownerVisibleIds } />
381+ < TouchFisheye entries = { entries } onSelect = { onScrollToMessageId } visual = { visual } ownerVisibleIndex = { ownerVisibleIndex } />
375382 ) : (
376- < PointerFisheye entries = { entries } onSelect = { onScrollToMessageId } visual = { visual } ownerVisibleIds = { ownerVisibleIds } />
383+ < PointerFisheye entries = { entries } onSelect = { onScrollToMessageId } visual = { visual } ownerVisibleIndex = { ownerVisibleIndex } />
377384 )
378385} )
379386
380387// ─── PointerFisheye ─────────────────────────
381388
382- const PointerFisheye = memo ( function PointerFisheye ( { entries, onSelect, visual, ownerVisibleIds } : FisheyeProps ) {
389+ const PointerFisheye = memo ( function PointerFisheye ( { entries, onSelect, visual, ownerVisibleIndex } : FisheyeProps ) {
383390 const zoneRef = useRef < HTMLDivElement > ( null )
384391 const railRef = useRef < HTMLDivElement > ( null )
385392 const cursorYRef = useRef < number | null > ( null )
@@ -390,8 +397,8 @@ const PointerFisheye = memo(function PointerFisheye({ entries, onSelect, visual,
390397 const focusIdxRef = useRef ( - 1 )
391398 const entriesRef = useRef ( entries )
392399 entriesRef . current = entries
393- const ownerVisibleIdsRef = useRef ( ownerVisibleIds )
394- ownerVisibleIdsRef . current = ownerVisibleIds
400+ const ownerVisibleIndexRef = useRef ( ownerVisibleIndex )
401+ ownerVisibleIndexRef . current = ownerVisibleIndex
395402 // 追踪 rAF 是否正在运行
396403 const loopRunningRef = useRef ( false )
397404
@@ -408,7 +415,7 @@ const PointerFisheye = memo(function PointerFisheye({ entries, onSelect, visual,
408415
409416 /** 根据 ownerVisibleIds 更新 tick DOM 颜色 */
410417 const applyVisibleTicks = useCallback ( ( ) => {
411- applyVisibleTickHighlight ( getItems ( ) , entriesRef . current , ownerVisibleIdsRef . current )
418+ applyVisibleTickHighlight ( getItems ( ) , ownerVisibleIndexRef . current )
412419 } , [ getItems ] )
413420
414421 // 当 ownerVisibleIds 变化时更新 tick,仅 loop 未激活时生效
@@ -418,12 +425,12 @@ const PointerFisheye = memo(function PointerFisheye({ entries, onSelect, visual,
418425 applyVisibleTicks ( )
419426 } )
420427 return ( ) => cancelAnimationFrame ( raf )
421- } , [ ownerVisibleIds , applyVisibleTicks , entries ] )
428+ } , [ ownerVisibleIndex , applyVisibleTicks , entries ] )
422429
423430 const loop = useCallback (
424431 function tick ( ) {
425432 loopRunningRef . current = true
426- const { alive, focusIndex } = applyFisheye ( getItems ( ) , entriesRef . current , cursorYRef . current , strengthsRef . current , visual . fisheye , ownerVisibleIdsRef . current )
433+ const { alive, focusIndex } = applyFisheye ( getItems ( ) , cursorYRef . current , strengthsRef . current , visual . fisheye , ownerVisibleIndexRef . current )
427434 focusIdxRef . current = focusIndex
428435 if ( hoveringRef . current || alive ) {
429436 rafIdRef . current = requestAnimationFrame ( tick )
@@ -505,7 +512,7 @@ const PointerFisheye = memo(function PointerFisheye({ entries, onSelect, visual,
505512
506513// ─── TouchFisheye ───────────────────────────
507514
508- const TouchFisheye = memo ( function TouchFisheye ( { entries, onSelect, visual, ownerVisibleIds } : FisheyeProps ) {
515+ const TouchFisheye = memo ( function TouchFisheye ( { entries, onSelect, visual, ownerVisibleIndex } : FisheyeProps ) {
509516 const [ overlayVisible , setOverlayVisible ] = useState ( false )
510517 const railRef = useRef < HTMLDivElement > ( null )
511518 const overlayRef = useRef < HTMLDivElement > ( null )
@@ -523,8 +530,8 @@ const TouchFisheye = memo(function TouchFisheye({ entries, onSelect, visual, own
523530 onSelectRef . current = onSelect
524531 const visualRef = useRef ( visual )
525532 visualRef . current = visual
526- const ownerVisibleIdsRef = useRef ( ownerVisibleIds )
527- ownerVisibleIdsRef . current = ownerVisibleIds
533+ const ownerVisibleIndexRef = useRef ( ownerVisibleIndex )
534+ ownerVisibleIndexRef . current = ownerVisibleIndex
528535
529536 useEffect ( ( ) => {
530537 strengthsRef . current = entries . map ( ( ) => 0 )
@@ -538,7 +545,7 @@ const TouchFisheye = memo(function TouchFisheye({ entries, onSelect, visual, own
538545
539546 /** 根据 ownerVisibleIds 更新 tick DOM 颜色 */
540547 const applyVisibleTicks = useCallback ( ( ) => {
541- applyVisibleTickHighlight ( getItems ( ) , entriesRef . current , ownerVisibleIdsRef . current )
548+ applyVisibleTickHighlight ( getItems ( ) , ownerVisibleIndexRef . current )
542549 } , [ getItems ] )
543550
544551 // 当 ownerVisibleIds 变化时更新 tick,仅 loop 未激活时生效
@@ -548,7 +555,7 @@ const TouchFisheye = memo(function TouchFisheye({ entries, onSelect, visual, own
548555 applyVisibleTicks ( )
549556 } )
550557 return ( ) => cancelAnimationFrame ( raf )
551- } , [ ownerVisibleIds , applyVisibleTicks , entries ] )
558+ } , [ ownerVisibleIndex , applyVisibleTicks , entries ] )
552559
553560 const vibrate = useCallback ( ( ) => {
554561 try {
@@ -569,11 +576,10 @@ const TouchFisheye = memo(function TouchFisheye({ entries, onSelect, visual, own
569576 loopRunningRef . current = true
570577 const { alive, focusIndex, maxStrength } = applyFisheye (
571578 getItems ( ) ,
572- entriesRef . current ,
573579 touchYRef . current ,
574580 strengthsRef . current ,
575581 visualRef . current . fisheye ,
576- ownerVisibleIdsRef . current ,
582+ ownerVisibleIndexRef . current ,
577583 )
578584
579585 if ( focusIndex >= 0 && maxStrength > 0.5 && focusIndex !== prevFocusRef . current ) {
@@ -664,7 +670,8 @@ const TouchFisheye = memo(function TouchFisheye({ entries, onSelect, visual, own
664670 return (
665671 < div >
666672 { overlayVisible && (
667- < div className = "absolute inset-0 z-[14] bg-bg-100/40 backdrop-blur-sm flex items-start justify-center pt-[30%] pointer-events-none" >
673+ < div className = "absolute inset-0 z-[14] bg-bg-100/40 backdrop-blur-sm flex items-start justify-center pointer-events-none"
674+ style = { { paddingTop : `calc(30% + var(--app-safe-top, 0px))` } } >
668675 < div
669676 ref = { overlayRef }
670677 className = { `px-5 py-2 max-w-[75vw] text-center ${ visual . overlayClassName } ` }
0 commit comments