mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Restore reliable long chat scrolling
The fixed-height virtual window introduced after v0.2.6 assumed every rendered chat item was 96px tall. Real desktop messages include markdown, math, code blocks, tool groups, and agent cards with highly variable heights, so scrolling history could replace real content with incorrectly sized blank spacer regions and make tab restoration janky. Remove that unsafe virtualization layer and keep the existing direct scroll restoration behavior. The regression tests now keep long mixed-height transcripts and tool-call histories mounted so variable-height rows cannot disappear behind fake spacer math again. Constraint: Desktop chat rows have unbounded variable height from markdown, KaTeX, code, tool, and agent output. Rejected: Tune the 96px estimate | any constant row height still fails for mixed markdown and tool output. Rejected: Add measured virtualization now | larger change surface than needed for the production white-gap regression. Confidence: high Scope-risk: narrow Directive: Do not reintroduce chat virtualization without real row-height measurement and scroll restore coverage. Tested: cd desktop && bun run test -- MessageList.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: git diff --check
This commit is contained in:
parent
7f74f1bde7
commit
5e899bb1a1
@ -120,14 +120,22 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('window-renders long transcripts while keeping the latest messages visible', () => {
|
||||
it('keeps full long transcripts mounted so variable-height messages cannot leave spacer gaps', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: Array.from({ length: 220 }, (_, index) => ({
|
||||
id: `assistant-${index}`,
|
||||
type: 'assistant_text',
|
||||
content: `assistant transcript line ${index}`,
|
||||
content: index % 25 === 0
|
||||
? [
|
||||
`assistant transcript line ${index}`,
|
||||
'',
|
||||
'```ts',
|
||||
'const value = "this intentionally makes the row much taller"',
|
||||
'```',
|
||||
].join('\n')
|
||||
: `assistant transcript line ${index}`,
|
||||
timestamp: index,
|
||||
})),
|
||||
}),
|
||||
@ -136,9 +144,10 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
|
||||
expect(screen.queryByText('assistant transcript line 0')).toBeNull()
|
||||
expect(screen.getByText('assistant transcript line 0')).toBeTruthy()
|
||||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(140)
|
||||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220)
|
||||
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders goal events as visible status cards', () => {
|
||||
@ -221,7 +230,7 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220)
|
||||
})
|
||||
|
||||
it('window-renders long histories that include tool-call groups', async () => {
|
||||
it('keeps long histories with tool-call groups mounted while scrolling history', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
@ -259,7 +268,7 @@ describe('MessageList nested tool calls', () => {
|
||||
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 222 * 112 })
|
||||
await waitForProgrammaticScrollReset()
|
||||
|
||||
expect(screen.queryByText('Read')).toBeNull()
|
||||
expect(screen.getByText('Read')).toBeTruthy()
|
||||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||||
|
||||
scrollArea.scrollTop = 0
|
||||
@ -269,7 +278,7 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
expect(screen.getByText('Read')).toBeTruthy()
|
||||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||||
expect(container.querySelectorAll('[data-virtual-message-item]').length).toBe(221)
|
||||
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
|
||||
|
||||
@ -537,8 +537,6 @@ type MessageListProps = {
|
||||
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
|
||||
const MAX_SCROLL_SNAPSHOTS = 100
|
||||
const VIRTUALIZE_MIN_ITEMS = 160
|
||||
const VIRTUAL_INITIAL_ITEMS = 96
|
||||
const CHAT_SCROLL_AREA_CLASS = [
|
||||
'chat-scroll-area',
|
||||
'[scrollbar-width:auto]',
|
||||
@ -590,10 +588,6 @@ function getBottomScrollTop(element: HTMLElement) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
}
|
||||
|
||||
function getRenderItemKey(item: RenderItem) {
|
||||
return item.kind === 'tool_group' ? item.id : item.message.id
|
||||
}
|
||||
|
||||
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const resolvedSessionId = sessionId ?? activeTabId
|
||||
@ -623,7 +617,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const isProgrammaticScrollingRef = useRef(false)
|
||||
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
|
||||
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
|
||||
const updateVirtualWindowRef = useRef<((element: HTMLElement) => void) | null>(null)
|
||||
const t = useTranslation()
|
||||
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
|
||||
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
|
||||
@ -693,7 +686,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
if (resolvedSessionId) {
|
||||
rememberSessionScroll(resolvedSessionId, container)
|
||||
}
|
||||
updateVirtualWindowRef.current?.(container)
|
||||
}, [resolvedSessionId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@ -759,58 +751,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
() => buildRenderModel(messages),
|
||||
[messages],
|
||||
)
|
||||
const shouldVirtualize = renderItems.length > VIRTUALIZE_MIN_ITEMS
|
||||
const [virtualRange, setVirtualRange] = useState(() => ({ start: 0, end: 0 }))
|
||||
|
||||
const updateVirtualWindow = useCallback((container: HTMLElement | null) => {
|
||||
if (!shouldVirtualize) {
|
||||
setVirtualRange((current) =>
|
||||
current.start === 0 && current.end === renderItems.length
|
||||
? current
|
||||
: { start: 0, end: renderItems.length },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!container || shouldAutoScrollRef.current) {
|
||||
const start = Math.max(0, renderItems.length - VIRTUAL_INITIAL_ITEMS)
|
||||
setVirtualRange((current) =>
|
||||
current.start === start && current.end === renderItems.length
|
||||
? current
|
||||
: { start, end: renderItems.length },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setVirtualRange((current) =>
|
||||
current.start === 0 && current.end === renderItems.length
|
||||
? current
|
||||
: { start: 0, end: renderItems.length },
|
||||
)
|
||||
}, [renderItems.length, shouldVirtualize])
|
||||
|
||||
useEffect(() => {
|
||||
updateVirtualWindowRef.current = updateVirtualWindow
|
||||
return () => {
|
||||
if (updateVirtualWindowRef.current === updateVirtualWindow) {
|
||||
updateVirtualWindowRef.current = null
|
||||
}
|
||||
}
|
||||
}, [updateVirtualWindow])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateVirtualWindow(scrollContainerRef.current)
|
||||
}, [renderItems.length, resolvedSessionId, updateVirtualWindow])
|
||||
|
||||
const visibleRenderItems = shouldVirtualize
|
||||
? renderItems.slice(virtualRange.start, virtualRange.end)
|
||||
: renderItems
|
||||
const topVirtualSpacerHeight = shouldVirtualize
|
||||
? Math.max(0, virtualRange.start * 96)
|
||||
: 0
|
||||
const bottomVirtualSpacerHeight = shouldVirtualize
|
||||
? Math.max(0, renderItems.length - virtualRange.end) * 96
|
||||
: 0
|
||||
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
|
||||
const latestCompletedTurnId =
|
||||
completedTurnTargets.length > 0
|
||||
@ -961,25 +901,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
<div
|
||||
ref={scrollContentRef}
|
||||
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
|
||||
role={shouldVirtualize ? 'list' : undefined}
|
||||
>
|
||||
{topVirtualSpacerHeight > 0 && (
|
||||
<div aria-hidden="true" style={{ height: topVirtualSpacerHeight }} />
|
||||
)}
|
||||
|
||||
{visibleRenderItems.map((item, visibleIndex) => {
|
||||
const index = shouldVirtualize ? virtualRange.start + visibleIndex : visibleIndex
|
||||
const itemKey = getRenderItemKey(item)
|
||||
{renderItems.map((item, index) => {
|
||||
const itemKey = item.kind === 'tool_group' ? item.id : item.message.id
|
||||
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemKey}
|
||||
data-virtual-message-item={shouldVirtualize ? itemKey : undefined}
|
||||
role={shouldVirtualize ? 'listitem' : undefined}
|
||||
aria-posinset={shouldVirtualize ? index + 1 : undefined}
|
||||
aria-setsize={shouldVirtualize ? renderItems.length : undefined}
|
||||
>
|
||||
<div key={itemKey}>
|
||||
{item.kind === 'tool_group' ? (
|
||||
<ToolCallGroup
|
||||
toolCalls={item.toolCalls}
|
||||
@ -1027,10 +955,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
)
|
||||
})}
|
||||
|
||||
{bottomVirtualSpacerHeight > 0 && (
|
||||
<div aria-hidden="true" style={{ height: bottomVirtualSpacerHeight }} />
|
||||
)}
|
||||
|
||||
{streamingText.trim() && (
|
||||
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user