Skip to content

Commit 005fa10

Browse files
authored
perf(mothership): virtualize chat transcript and isolate input from stream re-renders (#5019)
* perf(mothership): virtualize chat transcript and isolate input from stream re-renders Long chats rendered every message into the DOM with no windowing — a custom rAF "progressive list" only smeared the mount cost across frames without capping it. At ~1000 messages this was 52k DOM nodes and a 21s main-thread block on open, and the input toolbar re-rendered on every streamed token. - Virtualize the message list with @tanstack/react-virtual using dynamic measureElement, stable per-row keys, and a tuned size estimate. Only the visible window mounts, so load cost is now flat regardless of transcript length. Remove the now-redundant useProgressiveList hook. - Memoize UserInput and stabilize its callbacks (useCallback in MothershipChat and home) so streaming ticks no longer re-render the entire input toolbar. - Keep the existing useAutoScroll for streaming stick-to-bottom (it reads the virtualizer's real scrollHeight) and add a per-chat scrollToIndex for initial positioning before paint. Measured on a cloned 1032-message chat: time-to-rendered 26.3s -> 1.7s, main-thread blocked 21.4s -> 0.8s, DOM nodes 52k -> 1.4k, typing-while- streaming p-max 104ms -> 26ms. Adds scripts/perf/ harness used to validate. * address review: pending-chat scroll, flash on tail unmount, empty-row gap, per-role estimate - Pending-chat initial scroll (Cursor, high): seed the scrolled-chat guard with a unique sentinel so a not-yet-persisted chat (undefined chatId) with messages still scrolls to bottom instead of being treated as already-scrolled. - Streaming-row flash (review of virtualization): pin the last row in the rendered window via rangeExtractor so scrolling it out of the overscan window and back mid-stream can't unmount/remount it and re-fire the reveal fade. - Empty assistant row gap (Greptile): move the row gap from the virtual-item wrapper into the row content so a null-rendering (finalised, empty) assistant turn collapses to zero height instead of leaving a pb-6 blank slot. - Per-role row-height estimate instead of a single blended constant, so the scrollbar drifts less as off-screen rows resolve. - Drop the scripts/perf harness from the PR. * fix(mothership): preserve user-row top spacing in virtualized layout Restoring pt-3/pt-2 on the user row keeps the exact inter-row rhythm from the old space-y-6 layout: assistant→user gaps stay 24+12px and user→assistant stay 24px, instead of becoming uniform when the gap moved to per-row pb. * fix(mothership): don't re-scroll when a pending chat persists its id The per-chat initial-scroll guard treated undefined→persisted-id as a chat switch and scrolled to the bottom again, yanking the viewport down if the user had scrolled up mid-stream. Treat that transition as the same conversation: adopt the id without re-scrolling. Genuine chat switches (id→different id) still re-scroll.
1 parent 26224b8 commit 005fa10

4 files changed

Lines changed: 193 additions & 226 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 163 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
4+
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
45
import { cn } from '@/lib/core/utils/cn'
56
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
67
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
@@ -26,7 +27,6 @@ import type {
2627
QueuedMessage,
2728
} from '@/app/workspace/[workspaceId]/home/types'
2829
import { useAutoScroll } from '@/hooks/use-auto-scroll'
29-
import { useProgressiveList } from '@/hooks/use-progressive-list'
3030
import type { ChatContext } from '@/stores/panel'
3131
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
3232

@@ -61,11 +61,38 @@ interface MothershipChatProps {
6161
className?: string
6262
}
6363

64+
/**
65+
* Per-role row-height estimates seed the virtualizer before each row is measured.
66+
* They only size the scrollbar for not-yet-rendered rows — every visible row is
67+
* measured precisely via `measureElement` — so approximate values suffice. Split
68+
* by role because user bubbles are short and assistant turns are tall; a single
69+
* blended number would over/under-shoot both and drift the scrollbar more.
70+
*/
71+
const ROW_HEIGHT_ESTIMATE = {
72+
'mothership-view': { user: 64, assistant: 280 },
73+
'copilot-view': { user: 48, assistant: 180 },
74+
} as const
75+
76+
/**
77+
* Rows render farther beyond the viewport edges than the default so fast scroll
78+
* and the streaming tail stay painted without a blank flash before measurement.
79+
*/
80+
const OVERSCAN = 6
81+
82+
/**
83+
* Initial-scroll sentinel. Distinct from every real `chatId` value — including
84+
* `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires
85+
* even before a chat has an id, instead of treating `undefined` as "already
86+
* scrolled this chat".
87+
*/
88+
const UNSCROLLED = Symbol('unscrolled')
89+
6490
const LAYOUT_STYLES = {
6591
'mothership-view': {
6692
scrollContainer:
6793
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]',
68-
content: 'mx-auto max-w-[48rem] space-y-6',
94+
sizer: 'relative mx-auto w-full max-w-[48rem]',
95+
rowGap: 'pb-6',
6996
userRow: 'flex flex-col items-end gap-[6px] pt-3',
7097
attachmentWidth: 'max-w-[70%]',
7198
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
@@ -75,7 +102,8 @@ const LAYOUT_STYLES = {
75102
},
76103
'copilot-view': {
77104
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
78-
content: 'space-y-4',
105+
sizer: 'relative w-full',
106+
rowGap: 'pb-4',
79107
userRow: 'flex flex-col items-end gap-[6px] pt-2',
80108
attachmentWidth: 'max-w-[85%]',
81109
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
@@ -201,39 +229,88 @@ export function MothershipChat({
201229
}: MothershipChatProps) {
202230
const styles = LAYOUT_STYLES[layout]
203231
const isStreamActive = isSending || isReconnecting
204-
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, {
205-
scrollOnMount: true,
206-
})
232+
const scrollElementRef = useRef<HTMLDivElement | null>(null)
233+
const { ref: autoScrollRef } = useAutoScroll(isStreamActive)
234+
const setScrollElement = useCallback(
235+
(el: HTMLDivElement | null) => {
236+
scrollElementRef.current = el
237+
autoScrollRef(el)
238+
},
239+
[autoScrollRef]
240+
)
241+
207242
const hasMessages = messages.length > 0
208-
const stagingKey = chatId ?? 'pending-chat'
209-
const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey)
210-
const stagedMessageCount = stagedMessages.length
211-
const stagedOffset = messages.length - stagedMessages.length
212-
const precedingUserContentByIndex = useMemo(() => {
213-
const out: Array<string | undefined> = []
214-
let lastUserContent: string | undefined
215-
for (const [index, message] of messages.entries()) {
216-
out[index] = lastUserContent
217-
if (message.role === 'user') lastUserContent = message.content
218-
}
219-
return out
220-
}, [messages])
221-
const assistantTurnKeyByIndex = useMemo(() => {
243+
244+
/**
245+
* Stable per-row identity for virtualizer measurement caching and React
246+
* reconciliation. User rows key on their message id; assistant rows key on
247+
* their turn position (`assistant:<userId>:<ordinal>`) so a streaming
248+
* placeholder keeps the same element — and its smooth-text state — when the
249+
* persisted message arrives with a new id.
250+
*/
251+
const rowKeyByIndex = useMemo(() => {
222252
const out: string[] = []
223253
let lastUserId: string | undefined
224254
let ordinal = 0
225255
for (const [index, message] of messages.entries()) {
226256
if (message.role === 'user') {
227257
lastUserId = message.id
228258
ordinal = 0
259+
out[index] = message.id
229260
} else {
230261
out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id
231262
}
232263
}
233264
return out
234265
}, [messages])
235-
const initialScrollDoneRef = useRef(false)
266+
267+
const precedingUserContentByIndex = useMemo(() => {
268+
const out: Array<string | undefined> = []
269+
let lastUserContent: string | undefined
270+
for (const [index, message] of messages.entries()) {
271+
out[index] = lastUserContent
272+
if (message.role === 'user') lastUserContent = message.content
273+
}
274+
return out
275+
}, [messages])
276+
277+
/**
278+
* Always keep the last row in the rendered window. It is the live/streaming
279+
* row; unmounting it (by scrolling far enough up that it leaves the overscan
280+
* window) and remounting it mid-stream would reset its smooth-text reveal
281+
* state and re-fire the fade-in animation — a visible flash. Pinning it costs
282+
* one extra always-mounted row.
283+
*/
284+
const lastIndex = messages.length - 1
285+
const rangeExtractor = useCallback(
286+
(range: Range) => {
287+
const indexes = defaultRangeExtractor(range)
288+
if (lastIndex >= 0 && !indexes.includes(lastIndex)) {
289+
indexes.push(lastIndex)
290+
}
291+
return indexes
292+
},
293+
[lastIndex]
294+
)
295+
296+
const virtualizer = useVirtualizer({
297+
count: messages.length,
298+
getScrollElement: () => scrollElementRef.current,
299+
estimateSize: (index) => {
300+
const estimate = ROW_HEIGHT_ESTIMATE[layout]
301+
return messages[index]?.role === 'user' ? estimate.user : estimate.assistant
302+
},
303+
overscan: OVERSCAN,
304+
getItemKey: (index) => rowKeyByIndex[index] ?? index,
305+
rangeExtractor,
306+
})
307+
308+
const scrolledChatRef = useRef<string | undefined | typeof UNSCROLLED>(UNSCROLLED)
236309
const userInputRef = useRef<UserInputHandle>(null)
310+
const messageQueueRef = useRef(messageQueue)
311+
useEffect(() => {
312+
messageQueueRef.current = messageQueue
313+
}, [messageQueue])
237314

238315
const onSubmitRef = useRef(onSubmit)
239316
useEffect(() => {
@@ -243,37 +320,48 @@ export function MothershipChat({
243320
onSubmitRef.current(id)
244321
}, [])
245322

246-
function handleSendQueuedHead() {
247-
const topMessage = messageQueue[0]
323+
const handleSendQueuedHead = useCallback(() => {
324+
const topMessage = messageQueueRef.current[0]
248325
if (!topMessage) return
249326
void onSendQueuedMessage(topMessage.id)
250-
}
327+
}, [onSendQueuedMessage])
251328

252-
function handleEditQueued(id: string) {
253-
const msg = onEditQueuedMessage(id)
254-
if (msg) userInputRef.current?.loadQueuedMessage(msg)
255-
}
329+
const handleEditQueued = useCallback(
330+
(id: string) => {
331+
const msg = onEditQueuedMessage(id)
332+
if (msg) userInputRef.current?.loadQueuedMessage(msg)
333+
},
334+
[onEditQueuedMessage]
335+
)
256336

257-
function handleEditQueuedTail() {
258-
const tail = messageQueue[messageQueue.length - 1]
337+
const handleEditQueuedTail = useCallback(() => {
338+
const tail = messageQueueRef.current[messageQueueRef.current.length - 1]
259339
if (!tail) return
260340
handleEditQueued(tail.id)
261-
}
341+
}, [handleEditQueued])
262342

343+
/**
344+
* Land at the most recent message once per chat — on open and when switching
345+
* chats. The ref tracks which `chatId` we last scrolled for (seeded with
346+
* {@link UNSCROLLED} so a pending, id-less chat still scrolls on first mount),
347+
* so it re-fires on a genuine chat switch, including between chats of equal
348+
* length. A pending chat persisting its id (`undefined` → string) is the SAME
349+
* conversation, so adopt the id without re-scrolling — otherwise the viewport
350+
* would snap back to the bottom after the user scrolled up mid-stream. Runs
351+
* before paint so a long transcript never flashes at the top. Subsequent
352+
* growth within the same chat is handled by {@link useAutoScroll}'s streaming
353+
* sticky-scroll, not here.
354+
*/
263355
useLayoutEffect(() => {
264-
if (!hasMessages) {
265-
initialScrollDoneRef.current = false
266-
return
267-
}
268-
if (initialScrollDoneRef.current || initialScrollBlocked) return
269-
initialScrollDoneRef.current = true
270-
scrollToBottom()
271-
}, [hasMessages, initialScrollBlocked, scrollToBottom])
356+
const scrolledFor = scrolledChatRef.current
357+
if (!hasMessages || initialScrollBlocked || scrolledFor === chatId) return
358+
const isPendingPersist = scrolledFor === undefined && chatId !== undefined
359+
scrolledChatRef.current = chatId
360+
if (isPendingPersist) return
361+
virtualizer.scrollToIndex(lastIndex, { align: 'end' })
362+
}, [chatId, hasMessages, initialScrollBlocked, lastIndex, virtualizer])
272363

273-
useLayoutEffect(() => {
274-
if (!isStaging || initialScrollBlocked || !initialScrollDoneRef.current) return
275-
scrollToBottom()
276-
}, [isStaging, stagedMessageCount, initialScrollBlocked, scrollToBottom])
364+
const virtualItems = virtualizer.getVirtualItems()
277365

278366
return (
279367
<ChatSurfaceProvider
@@ -284,37 +372,42 @@ export function MothershipChat({
284372
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
285373
>
286374
<div className={cn('flex h-full min-h-0 flex-col', className)}>
287-
<div ref={scrollContainerRef} className={styles.scrollContainer}>
375+
<div ref={setScrollElement} className={styles.scrollContainer}>
288376
{isLoading && !hasMessages ? (
289377
<MothershipChatSkeleton layout={layout} />
290378
) : (
291-
<div className={styles.content}>
292-
{stagedMessages.map((msg, localIndex) => {
293-
const index = stagedOffset + localIndex
294-
if (msg.role === 'user') {
295-
return (
296-
<UserMessageRow
297-
key={msg.id}
298-
content={msg.content}
299-
contexts={msg.contexts}
300-
attachments={msg.attachments}
301-
rowClassName={styles.userRow}
302-
bubbleClassName={styles.userBubble}
303-
attachmentWidthClassName={styles.attachmentWidth}
304-
/>
305-
)
306-
}
307-
308-
const isLast = index === messages.length - 1
379+
<div className={styles.sizer} style={{ height: virtualizer.getTotalSize() }}>
380+
{virtualItems.map((virtualItem) => {
381+
const index = virtualItem.index
382+
const msg = messages[index]
383+
const isLast = index === lastIndex
309384
return (
310-
<AssistantMessageRow
311-
key={assistantTurnKeyByIndex[index] ?? msg.id}
312-
message={msg}
313-
isStreaming={isStreamActive && isLast}
314-
precedingUserContent={precedingUserContentByIndex[index]}
315-
rowClassName={styles.assistantRow}
316-
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
317-
/>
385+
<div
386+
key={virtualItem.key}
387+
data-index={index}
388+
ref={virtualizer.measureElement}
389+
className='absolute top-0 left-0 w-full'
390+
style={{ transform: `translateY(${virtualItem.start}px)` }}
391+
>
392+
{msg.role === 'user' ? (
393+
<UserMessageRow
394+
content={msg.content}
395+
contexts={msg.contexts}
396+
attachments={msg.attachments}
397+
rowClassName={cn(styles.userRow, styles.rowGap)}
398+
bubbleClassName={styles.userBubble}
399+
attachmentWidthClassName={styles.attachmentWidth}
400+
/>
401+
) : (
402+
<AssistantMessageRow
403+
message={msg}
404+
isStreaming={isStreamActive && isLast}
405+
precedingUserContent={precedingUserContentByIndex[index]}
406+
rowClassName={cn(styles.assistantRow, styles.rowGap)}
407+
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
408+
/>
409+
)}
410+
</div>
318411
)
319412
})}
320413
</div>

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type React from 'react'
44
import {
55
forwardRef,
6+
memo,
67
useCallback,
78
useEffect,
89
useImperativeHandle,
@@ -145,7 +146,7 @@ export interface UserInputHandle {
145146
populatePrompt: (text: string) => void
146147
}
147148

148-
export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function UserInput(
149+
const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserInput(
149150
{
150151
defaultValue = '',
151152
draftScopeKey,
@@ -1445,3 +1446,10 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
14451446
</div>
14461447
)
14471448
})
1449+
1450+
/**
1451+
* Memoized so streaming ticks in the parent transcript — which re-render
1452+
* {@link MothershipChat} on every chunk — do not re-render the entire input
1453+
* toolbar. Relies on callers passing stable callbacks (see `MothershipChat`).
1454+
*/
1455+
export const UserInput = memo(UserInputImpl)

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -233,36 +233,35 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
233233
}
234234
}, [resources, collapseResource])
235235

236-
function handleStopGeneration() {
236+
const handleStopGeneration = useCallback(() => {
237237
captureEvent(posthogRef.current, 'task_generation_aborted', {
238238
workspace_id: workspaceId,
239239
view: 'mothership',
240240
request_id: getCurrentRequestId(),
241241
})
242242
void stopGeneration().catch(() => {})
243-
}
244-
245-
function handleSubmit(
246-
text: string,
247-
fileAttachments?: FileAttachmentForApi[],
248-
contexts?: ChatContext[]
249-
) {
250-
const trimmed = text.trim()
251-
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
252-
253-
captureEvent(posthogRef.current, 'task_message_sent', {
254-
workspace_id: workspaceId,
255-
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
256-
has_contexts: !!(contexts && contexts.length > 0),
257-
is_new_task: !chatId,
258-
})
243+
}, [workspaceId, getCurrentRequestId, stopGeneration])
244+
245+
const handleSubmit = useCallback(
246+
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
247+
const trimmed = text.trim()
248+
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
249+
250+
captureEvent(posthogRef.current, 'task_message_sent', {
251+
workspace_id: workspaceId,
252+
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
253+
has_contexts: !!(contexts && contexts.length > 0),
254+
is_new_task: !chatId,
255+
})
259256

260-
if (initialViewInputRef.current) {
261-
setIsInputEntering(true)
262-
}
257+
if (initialViewInputRef.current) {
258+
setIsInputEntering(true)
259+
}
263260

264-
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
265-
}
261+
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
262+
},
263+
[workspaceId, chatId, sendMessage]
264+
)
266265

267266
useEffect(() => {
268267
const handler = (e: Event) => {

0 commit comments

Comments
 (0)