Skip to content

Commit 38648e0

Browse files
committed
perf: stabilize outline index during streaming
1 parent 60016c0 commit 38648e0

3 files changed

Lines changed: 107 additions & 38 deletions

File tree

src/components/OutlineIndex.tsx

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import type { Message } from '../types/message'
2121
import { useChatViewport } from '../features/chat/chatViewport'
2222
import { buildOutlineSourceEntries, truncateOutlineLabel, type OutlineSourceEntry } from './outlineIndexModel'
2323

24+
const EMPTY_MESSAGES: Message[] = []
25+
2426
// ─── Types ──────────────────────────────────
2527

2628
interface OutlineEntry {
@@ -31,8 +33,9 @@ interface OutlineEntry {
3133
}
3234

3335
interface 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

193195
function 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

338337
export 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}`}

src/features/chat/ChatPane.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,8 +820,8 @@ export const ChatPane = memo(function ChatPane({
820820
</div>
821821

822822
<OutlineIndex
823-
messages={renderedMessages}
824823
sourceEntries={chatPageViewModel.outlineSourceEntries}
824+
ownerByMessageId={chatPageViewModel.outlineOwnerByMessageId}
825825
visibleMessageIds={visibleMessageIds}
826826
currentHighlightEnabled={outlineCurrentHighlight}
827827
onScrollToMessageId={handleOutlineScrollToMessage}
Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useMemo } from 'react'
2-
import type { Message } from '../../types/message'
2+
import { getMessageText, type Message } from '../../types/message'
33
import { buildOutlineSourceEntries, type OutlineSourceEntry } from '../../components/outlineIndexModel'
44
import { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility'
55
import { buildContentKeyedChatPages, buildTurnDurationMap, type StableChatPage } from './chatPageModel'
@@ -8,10 +8,65 @@ export interface ChatPageViewModel {
88
visibleMessages: Message[]
99
pageRecords: StableChatPage[]
1010
outlineSourceEntries: OutlineSourceEntry[]
11+
outlineOwnerByMessageId: Map<string, string>
1112
forkTargetIdMap: Map<string, string | undefined>
1213
turnDurationMap: Map<string, number>
1314
}
1415

16+
interface StableOutlineModel {
17+
signature: string
18+
entries: OutlineSourceEntry[]
19+
ownerByMessageId: Map<string, string>
20+
}
21+
22+
const OUTLINE_MODEL_CACHE_LIMIT = 16
23+
const outlineModelCache = new Map<string, StableOutlineModel>()
24+
25+
function buildOutlineSignature(messages: Message[]): string {
26+
let signature = ''
27+
for (const message of messages) {
28+
signature += `${message.info.id}:${message.info.role}|`
29+
if (message.info.role === 'user') {
30+
signature += `${message.info.summary?.title ?? getMessageText(message)}|`
31+
}
32+
}
33+
return signature
34+
}
35+
36+
function buildOutlineOwnerByMessageId(messages: Message[]): Map<string, string> {
37+
const ownerByMessageId = new Map<string, string>()
38+
let lastUserMessageId: string | null = null
39+
40+
for (const message of messages) {
41+
if (message.info.role === 'user') lastUserMessageId = message.info.id
42+
if (lastUserMessageId) ownerByMessageId.set(message.info.id, lastUserMessageId)
43+
}
44+
45+
return ownerByMessageId
46+
}
47+
48+
function getStableOutlineModel(messages: Message[]): StableOutlineModel {
49+
const signature = buildOutlineSignature(messages)
50+
const cached = outlineModelCache.get(signature)
51+
if (cached) {
52+
outlineModelCache.delete(signature)
53+
outlineModelCache.set(signature, cached)
54+
return cached
55+
}
56+
57+
const next: StableOutlineModel = {
58+
signature,
59+
entries: buildOutlineSourceEntries(messages),
60+
ownerByMessageId: buildOutlineOwnerByMessageId(messages),
61+
}
62+
outlineModelCache.set(signature, next)
63+
if (outlineModelCache.size > OUTLINE_MODEL_CACHE_LIMIT) {
64+
const oldestKey = outlineModelCache.keys().next().value
65+
if (oldestKey) outlineModelCache.delete(oldestKey)
66+
}
67+
return next
68+
}
69+
1570
export function useChatPageViewModel(messages: Message[]): ChatPageViewModel {
1671
const visibleMessageEntries = useMemo(() => buildVisibleMessageEntries(messages), [messages])
1772
const visibleMessages = useMemo(() => visibleMessageEntries.map(entry => entry.message), [visibleMessageEntries])
@@ -20,8 +75,15 @@ export function useChatPageViewModel(messages: Message[]): ChatPageViewModel {
2075
() => new Map(visibleMessageEntries.map(entry => [entry.message.info.id, getVisibleMessageForkTargetId(entry)])),
2176
[visibleMessageEntries],
2277
)
23-
const outlineSourceEntries = useMemo(() => buildOutlineSourceEntries(visibleMessages), [visibleMessages])
78+
const outlineModel = useMemo(() => getStableOutlineModel(visibleMessages), [visibleMessages])
2479
const turnDurationMap = useMemo(() => buildTurnDurationMap(messages, visibleMessages), [messages, visibleMessages])
2580

26-
return { visibleMessages, pageRecords, outlineSourceEntries, forkTargetIdMap, turnDurationMap }
81+
return {
82+
visibleMessages,
83+
pageRecords,
84+
outlineSourceEntries: outlineModel.entries,
85+
outlineOwnerByMessageId: outlineModel.ownerByMessageId,
86+
forkTargetIdMap,
87+
turnDurationMap,
88+
}
2789
}

0 commit comments

Comments
 (0)