Merge main (anchored-turn chat scrolling) into perf/switch-and-scroll
Main replaced the chat timeline scroll engine while this branch was in flight, which obsoletes two of its subareas and reshapes a third: - Chat timeline: main's LegendList-based MessageList/ChatContainer win; the activation-overscan staircase targeted the removed tanstack path (LegendList provides adaptive rendering natively) and is dropped along with its test. - Scroll shadows: main's hook-based masks stay (the virtualized list owns its scroll element — there is no wrapper to hand the styling to); the viewport-wrapper ScrollShadow rewrite, its index.css replacement, its test, and the call-site viewportClassName adaptations are reverted to main. The chat OverlayScrollbar keeps this branch's disableHorizontal. - OverlayScrollbar: the direct-DOM rewrite lands, but binding now follows the live container node instead of binding once per ref object — the chat scroller remounts on every session switch, and a bind-once contract left the scrollbar attached to a dead element. - Markdown renderer: the detached-DOM cache and warm-block fast path merge with main's block-commit reveal (enter cascade), streaming code highlighting, and gutter reservation; the per-block reconcile keeps both the decoration-refresh path and the reveal cascade.
This commit is contained in:
@@ -2702,6 +2702,48 @@ export function useSessionParts(messageID: string, directory?: string) {
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_PARTS_BY_MESSAGE: Record<string, Part[]> = {}
|
||||
|
||||
/**
|
||||
* Get parts for several messages at once, keyed by message id. The snapshot
|
||||
* keeps its identity until one of the requested part arrays changes, so a
|
||||
* streaming turn can overlay every one of its step messages — not only the
|
||||
* currently streaming one — without tearing between them when the stream
|
||||
* moves to the next message.
|
||||
*/
|
||||
export function useSessionPartsForMessages(messageIDs: readonly string[], directory?: string): Record<string, Part[]> {
|
||||
const store = useDirectoryStore(directory)
|
||||
const cacheRef = React.useRef<{ ids: readonly string[]; parts: Record<string, Part[]> } | null>(null)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (messageIDs.length === 0) return EMPTY_PARTS_BY_MESSAGE
|
||||
const state = store.getState()
|
||||
const cached = cacheRef.current
|
||||
if (
|
||||
cached
|
||||
&& cached.ids === messageIDs
|
||||
&& messageIDs.every((id) => (state.part[id] ?? EMPTY_PARTS) === (cached.parts[id] ?? EMPTY_PARTS))
|
||||
) {
|
||||
return cached.parts
|
||||
}
|
||||
const parts: Record<string, Part[]> = {}
|
||||
for (const id of messageIDs) parts[id] = state.part[id] ?? EMPTY_PARTS
|
||||
cacheRef.current = { ids: messageIDs, parts }
|
||||
return parts
|
||||
}, [messageIDs, store])
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (messageIDs.length === 0) return () => undefined
|
||||
return store.subscribe((state, previous) => {
|
||||
for (const id of messageIDs) {
|
||||
if (state.part[id] !== previous.part[id]) {
|
||||
notify()
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [messageIDs, store])
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/** Get status for a specific session */
|
||||
export function useSessionStatus(sessionID: string, directory?: string) {
|
||||
const store = useDirectoryStore(directory)
|
||||
|
||||
Reference in New Issue
Block a user