mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
perf(desktop): persist virtual height cache across tab switches and defer tab-switch work
Tab switches into a long session paid for everything that was already paid for
the last time the user was on that session. Specifically the per-message height
and metric maps were cleared on every switch, so the new commit could only feed
estimated heights into buildVirtualTranscriptWindow and then chase them with
ResizeObserver callbacks. The first commit also ran getBranchableMessageTargets
and getCompletedTurnTargets — two extra O(N) walks over the messages array —
synchronously, and scrollToBottom('auto') was called inside the switch's
useLayoutEffect, which (on JSDOM and depending on layout state) added a
scrollHeight read to that critical path.
Introduces virtualHeightCache, a small module-level LRU keyed by sessionId that
holds the height and metric maps. MessageList now reads the prior maps on
switch instead of clearing them, so revisiting a session uses real measured
heights from the previous visit and skips the estimate→measure-correction
cascade. tabStore.closeTab calls dropSession so closed tabs don't leak.
The two O(N) branch / completed-turn computations now read from
useDeferredValue(messages), so they run as a low-priority follow-up render
after the first paint instead of blocking the switch commit. The switch's
useLayoutEffect now writes the bottom sentinel directly instead of calling
scrollToBottom, keeping the layout-read path out of the synchronous switch.
Tested: 731/731 desktop vitest suites pass, including 5 new virtualHeightCache
LRU/isolation/restore cases
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b8645b0122
commit
4a55b11c16
@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredValue, useLayoutEffect, type ReactNode } from 'react'
|
||||
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
@ -24,6 +24,11 @@ import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
|
||||
import {
|
||||
getHeightsForSession,
|
||||
getMetricsForSession,
|
||||
type VirtualRenderItemMetric,
|
||||
} from './virtualHeightCache'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
@ -831,12 +836,6 @@ type VirtualTranscriptWindow = {
|
||||
items: VirtualTranscriptItem[]
|
||||
}
|
||||
|
||||
type VirtualRenderItemMetric = {
|
||||
signature: string
|
||||
contentWeight: number
|
||||
estimatedHeight: number
|
||||
}
|
||||
|
||||
const sessionScrollSnapshots = new Map<string, SessionScrollSnapshot>()
|
||||
|
||||
function isNearScrollBottom(element: HTMLElement) {
|
||||
@ -1233,8 +1232,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
(chatState === 'thinking' && Boolean(activeThinkingId))
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContentRef = useRef<HTMLDivElement>(null)
|
||||
const virtualItemHeightsRef = useRef(new Map<string, number>())
|
||||
const virtualItemMetricCacheRef = useRef(new Map<string, VirtualRenderItemMetric>())
|
||||
const virtualItemHeightsRef = useRef<Map<string, number>>(
|
||||
resolvedSessionId ? getHeightsForSession(resolvedSessionId) : new Map<string, number>(),
|
||||
)
|
||||
const virtualItemMetricCacheRef = useRef<Map<string, VirtualRenderItemMetric>>(
|
||||
resolvedSessionId ? getMetricsForSession(resolvedSessionId) : new Map<string, VirtualRenderItemMetric>(),
|
||||
)
|
||||
const pendingMeasuredHeightsRef = useRef(false)
|
||||
const measureFlushFrameRef = useRef<number | null>(null)
|
||||
const lastAutoScrollAtRef = useRef(0)
|
||||
@ -1393,8 +1396,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
|
||||
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
|
||||
lastSessionIdRef.current = resolvedSessionId
|
||||
virtualItemHeightsRef.current.clear()
|
||||
virtualItemMetricCacheRef.current.clear()
|
||||
virtualItemHeightsRef.current = resolvedSessionId
|
||||
? getHeightsForSession(resolvedSessionId)
|
||||
: new Map<string, number>()
|
||||
virtualItemMetricCacheRef.current = resolvedSessionId
|
||||
? getMetricsForSession(resolvedSessionId)
|
||||
: new Map<string, VirtualRenderItemMetric>()
|
||||
pendingMeasuredHeightsRef.current = false
|
||||
if (measureFlushFrameRef.current !== null) {
|
||||
cancelAnimationFrame(measureFlushFrameRef.current)
|
||||
@ -1412,7 +1419,29 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
||||
}))
|
||||
setShowJumpToLatest(true)
|
||||
} else if (container) {
|
||||
// Switch to a session we were at the bottom of (or first visit): write
|
||||
// the bottom sentinel without going through scrollToBottom's read path,
|
||||
// so we never force a layout flush during the switch's commit.
|
||||
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
||||
ignoreProgrammaticScrollTopRef.current = null
|
||||
lastAutoScrollAtRef.current = performance.now()
|
||||
shouldAutoScrollRef.current = true
|
||||
setScrollToBottomWithoutLayoutRead(container, 'auto')
|
||||
setVirtualViewport((current) => ({
|
||||
scrollTop: SCROLL_BOTTOM_SENTINEL,
|
||||
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
||||
}))
|
||||
setShowJumpToLatest(false)
|
||||
if (resolvedSessionId) {
|
||||
sessionScrollSnapshots.set(resolvedSessionId, {
|
||||
scrollTop: container.scrollTop,
|
||||
wasAtBottom: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// No container yet (initial mount before ref settles): fall back to the
|
||||
// existing scrollToBottom path which is safe pre-mount.
|
||||
scrollToBottom('auto')
|
||||
}
|
||||
}
|
||||
@ -1465,11 +1494,21 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
() => buildRenderModel(messages, activeAskUserQuestionToolUseId),
|
||||
[activeAskUserQuestionToolUseId, messages],
|
||||
)
|
||||
// Defer the per-message branchable / completed-turn computations so the first
|
||||
// commit on tab switch can render the virtualization window without doing two
|
||||
// additional O(N) walks synchronously. They re-run in a low-priority render
|
||||
// once the initial frame is painted.
|
||||
const deferredMessages = useDeferredValue(messages)
|
||||
const branchableMessageTargets = useMemo(
|
||||
() => branchActionsDisabled ? new Map<string, BranchableMessageTarget>() : getBranchableMessageTargets(messages),
|
||||
[branchActionsDisabled, messages],
|
||||
() => branchActionsDisabled
|
||||
? new Map<string, BranchableMessageTarget>()
|
||||
: getBranchableMessageTargets(deferredMessages),
|
||||
[branchActionsDisabled, deferredMessages],
|
||||
)
|
||||
const completedTurnTargets = useMemo(
|
||||
() => getCompletedTurnTargets(deferredMessages),
|
||||
[deferredMessages],
|
||||
)
|
||||
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
|
||||
const latestCompletedTurnId =
|
||||
completedTurnTargets.length > 0
|
||||
? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null
|
||||
|
||||
61
desktop/src/components/chat/virtualHeightCache.test.ts
Normal file
61
desktop/src/components/chat/virtualHeightCache.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, it, expect } from 'vitest'
|
||||
import {
|
||||
__virtualHeightCacheInternals,
|
||||
dropSession,
|
||||
getHeightsForSession,
|
||||
getMetricsForSession,
|
||||
} from './virtualHeightCache'
|
||||
|
||||
describe('virtualHeightCache', () => {
|
||||
beforeEach(() => {
|
||||
__virtualHeightCacheInternals.reset()
|
||||
})
|
||||
|
||||
it('returns the same map instance across calls for the same session', () => {
|
||||
const a = getHeightsForSession('session-a')
|
||||
a.set('msg-1', 100)
|
||||
const aAgain = getHeightsForSession('session-a')
|
||||
expect(aAgain).toBe(a)
|
||||
expect(aAgain.get('msg-1')).toBe(100)
|
||||
})
|
||||
|
||||
it('isolates per-session entries', () => {
|
||||
getHeightsForSession('s1').set('x', 42)
|
||||
getHeightsForSession('s2').set('x', 99)
|
||||
expect(getHeightsForSession('s1').get('x')).toBe(42)
|
||||
expect(getHeightsForSession('s2').get('x')).toBe(99)
|
||||
})
|
||||
|
||||
it('evicts the least recently used session beyond the LRU bound', () => {
|
||||
for (let i = 0; i < 18; i++) {
|
||||
getHeightsForSession(`session-${i}`).set('marker', i)
|
||||
}
|
||||
expect(__virtualHeightCacheInternals.size()).toBe(16)
|
||||
// session-0 should have been evicted (oldest, never re-touched)
|
||||
const restored = getHeightsForSession('session-0')
|
||||
expect(restored.size).toBe(0)
|
||||
})
|
||||
|
||||
it('dropSession removes both height and metric maps', () => {
|
||||
getHeightsForSession('s').set('a', 1)
|
||||
getMetricsForSession('s').set('a', { signature: 'sig', contentWeight: 10, estimatedHeight: 20 })
|
||||
|
||||
dropSession('s')
|
||||
|
||||
expect(getHeightsForSession('s').size).toBe(0)
|
||||
expect(getMetricsForSession('s').size).toBe(0)
|
||||
})
|
||||
|
||||
it('switching back to a previously visited session preserves measurements', () => {
|
||||
const original = getHeightsForSession('long-session')
|
||||
original.set('item-1', 240)
|
||||
original.set('item-2', 360)
|
||||
|
||||
// simulate switching to another session, then back
|
||||
getHeightsForSession('other-session').set('item-1', 100)
|
||||
const restored = getHeightsForSession('long-session')
|
||||
|
||||
expect(restored.get('item-1')).toBe(240)
|
||||
expect(restored.get('item-2')).toBe(360)
|
||||
})
|
||||
})
|
||||
68
desktop/src/components/chat/virtualHeightCache.ts
Normal file
68
desktop/src/components/chat/virtualHeightCache.ts
Normal file
@ -0,0 +1,68 @@
|
||||
// Module-level caches keyed by sessionId so switching tabs back to a previously
|
||||
// rendered long transcript can skip estimate→measure thrash. Bounded by an LRU
|
||||
// across sessions to avoid unbounded growth across long-running sessions.
|
||||
|
||||
export type VirtualRenderItemMetric = {
|
||||
signature: string
|
||||
contentWeight: number
|
||||
estimatedHeight: number
|
||||
}
|
||||
|
||||
const MAX_TRACKED_SESSIONS = 16
|
||||
|
||||
const sessionHeightCache = new Map<string, Map<string, number>>()
|
||||
const sessionMetricCache = new Map<string, Map<string, VirtualRenderItemMetric>>()
|
||||
|
||||
function touchSession(sessionId: string, map: Map<string, Map<string, unknown>>) {
|
||||
// Reinsert to move to LRU tail.
|
||||
const existing = map.get(sessionId)
|
||||
if (existing) {
|
||||
map.delete(sessionId)
|
||||
map.set(sessionId, existing)
|
||||
}
|
||||
}
|
||||
|
||||
function evictSessionsBeyondLimit(): void {
|
||||
while (sessionHeightCache.size > MAX_TRACKED_SESSIONS) {
|
||||
const oldest = sessionHeightCache.keys().next().value
|
||||
if (typeof oldest !== 'string') break
|
||||
sessionHeightCache.delete(oldest)
|
||||
sessionMetricCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function getHeightsForSession(sessionId: string): Map<string, number> {
|
||||
let heights = sessionHeightCache.get(sessionId)
|
||||
if (!heights) {
|
||||
heights = new Map<string, number>()
|
||||
sessionHeightCache.set(sessionId, heights)
|
||||
evictSessionsBeyondLimit()
|
||||
} else {
|
||||
touchSession(sessionId, sessionHeightCache as Map<string, Map<string, unknown>>)
|
||||
}
|
||||
return heights
|
||||
}
|
||||
|
||||
export function getMetricsForSession(sessionId: string): Map<string, VirtualRenderItemMetric> {
|
||||
let metrics = sessionMetricCache.get(sessionId)
|
||||
if (!metrics) {
|
||||
metrics = new Map<string, VirtualRenderItemMetric>()
|
||||
sessionMetricCache.set(sessionId, metrics)
|
||||
} else {
|
||||
touchSession(sessionId, sessionMetricCache as Map<string, Map<string, unknown>>)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
export function dropSession(sessionId: string): void {
|
||||
sessionHeightCache.delete(sessionId)
|
||||
sessionMetricCache.delete(sessionId)
|
||||
}
|
||||
|
||||
export const __virtualHeightCacheInternals = {
|
||||
size: () => sessionHeightCache.size,
|
||||
reset: () => {
|
||||
sessionHeightCache.clear()
|
||||
sessionMetricCache.clear()
|
||||
},
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { dropSession as dropVirtualHeightSession } from '../components/chat/virtualHeightCache'
|
||||
|
||||
const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
|
||||
|
||||
@ -108,6 +109,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
|
||||
set({ tabs: newTabs, activeTabId: newActiveId })
|
||||
get().saveTabs()
|
||||
dropVirtualHeightSession(sessionId)
|
||||
},
|
||||
|
||||
setActiveTab: (sessionId) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user