Skip to content

Commit 70f4614

Browse files
committed
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.
1 parent 98948c0 commit 70f4614

8 files changed

Lines changed: 720 additions & 227 deletions

File tree

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

Lines changed: 125 additions & 71 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 { 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,12 +61,31 @@ interface MothershipChatProps {
6161
className?: string
6262
}
6363

64+
/**
65+
* Estimated row heights seed the virtualizer before each row is measured. They
66+
* only affect the scrollbar thumb on not-yet-rendered rows, so an approximate
67+
* value is fine — every visible row is measured precisely via `measureElement`.
68+
* Tuned to the blended average of short user rows and taller assistant rows so
69+
* the scrollbar barely drifts as off-screen rows resolve.
70+
*/
71+
const ESTIMATED_ROW_HEIGHT = {
72+
'mothership-view': 200,
73+
'copilot-view': 130,
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+
6482
const LAYOUT_STYLES = {
6583
'mothership-view': {
6684
scrollContainer:
6785
'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',
69-
userRow: 'flex flex-col items-end gap-[6px] pt-3',
86+
sizer: 'relative mx-auto w-full max-w-[48rem]',
87+
rowGap: 'pb-6',
88+
userRow: 'flex flex-col items-end gap-[6px]',
7089
attachmentWidth: 'max-w-[70%]',
7190
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
7291
assistantRow: 'group/msg',
@@ -75,8 +94,9 @@ const LAYOUT_STYLES = {
7594
},
7695
'copilot-view': {
7796
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
78-
content: 'space-y-4',
79-
userRow: 'flex flex-col items-end gap-[6px] pt-2',
97+
sizer: 'relative w-full',
98+
rowGap: 'pb-4',
99+
userRow: 'flex flex-col items-end gap-[6px]',
80100
attachmentWidth: 'max-w-[85%]',
81101
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
82102
assistantRow: 'group/msg',
@@ -201,39 +221,65 @@ export function MothershipChat({
201221
}: MothershipChatProps) {
202222
const styles = LAYOUT_STYLES[layout]
203223
const isStreamActive = isSending || isReconnecting
204-
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, {
205-
scrollOnMount: true,
206-
})
224+
const scrollElementRef = useRef<HTMLDivElement | null>(null)
225+
const { ref: autoScrollRef } = useAutoScroll(isStreamActive)
226+
const setScrollElement = useCallback(
227+
(el: HTMLDivElement | null) => {
228+
scrollElementRef.current = el
229+
autoScrollRef(el)
230+
},
231+
[autoScrollRef]
232+
)
233+
207234
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(() => {
235+
236+
/**
237+
* Stable per-row identity for virtualizer measurement caching and React
238+
* reconciliation. User rows key on their message id; assistant rows key on
239+
* their turn position (`assistant:<userId>:<ordinal>`) so a streaming
240+
* placeholder keeps the same element — and its smooth-text state — when the
241+
* persisted message arrives with a new id.
242+
*/
243+
const rowKeyByIndex = useMemo(() => {
222244
const out: string[] = []
223245
let lastUserId: string | undefined
224246
let ordinal = 0
225247
for (const [index, message] of messages.entries()) {
226248
if (message.role === 'user') {
227249
lastUserId = message.id
228250
ordinal = 0
251+
out[index] = message.id
229252
} else {
230253
out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id
231254
}
232255
}
233256
return out
234257
}, [messages])
235-
const initialScrollDoneRef = useRef(false)
258+
259+
const precedingUserContentByIndex = useMemo(() => {
260+
const out: Array<string | undefined> = []
261+
let lastUserContent: string | undefined
262+
for (const [index, message] of messages.entries()) {
263+
out[index] = lastUserContent
264+
if (message.role === 'user') lastUserContent = message.content
265+
}
266+
return out
267+
}, [messages])
268+
269+
const virtualizer = useVirtualizer({
270+
count: messages.length,
271+
getScrollElement: () => scrollElementRef.current,
272+
estimateSize: () => ESTIMATED_ROW_HEIGHT[layout],
273+
overscan: OVERSCAN,
274+
getItemKey: (index) => rowKeyByIndex[index] ?? index,
275+
})
276+
277+
const scrolledChatRef = useRef<string | undefined>(undefined)
236278
const userInputRef = useRef<UserInputHandle>(null)
279+
const messageQueueRef = useRef(messageQueue)
280+
useEffect(() => {
281+
messageQueueRef.current = messageQueue
282+
}, [messageQueue])
237283

238284
const onSubmitRef = useRef(onSubmit)
239285
useEffect(() => {
@@ -243,37 +289,40 @@ export function MothershipChat({
243289
onSubmitRef.current(id)
244290
}, [])
245291

246-
function handleSendQueuedHead() {
247-
const topMessage = messageQueue[0]
292+
const handleSendQueuedHead = useCallback(() => {
293+
const topMessage = messageQueueRef.current[0]
248294
if (!topMessage) return
249295
void onSendQueuedMessage(topMessage.id)
250-
}
296+
}, [onSendQueuedMessage])
251297

252-
function handleEditQueued(id: string) {
253-
const msg = onEditQueuedMessage(id)
254-
if (msg) userInputRef.current?.loadQueuedMessage(msg)
255-
}
298+
const handleEditQueued = useCallback(
299+
(id: string) => {
300+
const msg = onEditQueuedMessage(id)
301+
if (msg) userInputRef.current?.loadQueuedMessage(msg)
302+
},
303+
[onEditQueuedMessage]
304+
)
256305

257-
function handleEditQueuedTail() {
258-
const tail = messageQueue[messageQueue.length - 1]
306+
const handleEditQueuedTail = useCallback(() => {
307+
const tail = messageQueueRef.current[messageQueueRef.current.length - 1]
259308
if (!tail) return
260309
handleEditQueued(tail.id)
261-
}
310+
}, [handleEditQueued])
262311

312+
/**
313+
* Land at the most recent message once per chat — on open and when switching
314+
* chats (keyed on `chatId`, so it re-fires even between chats of equal length).
315+
* Runs before paint so a long transcript never flashes at the top. Subsequent
316+
* growth within the same chat is handled by {@link useAutoScroll}'s streaming
317+
* sticky-scroll, not here.
318+
*/
263319
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])
320+
if (!hasMessages || initialScrollBlocked || scrolledChatRef.current === chatId) return
321+
scrolledChatRef.current = chatId
322+
virtualizer.scrollToIndex(messages.length - 1, { align: 'end' })
323+
}, [chatId, hasMessages, initialScrollBlocked, messages.length, virtualizer])
272324

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

278327
return (
279328
<ChatSurfaceProvider
@@ -284,37 +333,42 @@ export function MothershipChat({
284333
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
285334
>
286335
<div className={cn('flex h-full min-h-0 flex-col', className)}>
287-
<div ref={scrollContainerRef} className={styles.scrollContainer}>
336+
<div ref={setScrollElement} className={styles.scrollContainer}>
288337
{isLoading && !hasMessages ? (
289338
<MothershipChatSkeleton layout={layout} />
290339
) : (
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-
340+
<div className={styles.sizer} style={{ height: virtualizer.getTotalSize() }}>
341+
{virtualItems.map((virtualItem) => {
342+
const index = virtualItem.index
343+
const msg = messages[index]
308344
const isLast = index === messages.length - 1
309345
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-
/>
346+
<div
347+
key={virtualItem.key}
348+
data-index={index}
349+
ref={virtualizer.measureElement}
350+
className={cn('absolute top-0 left-0 w-full', styles.rowGap)}
351+
style={{ transform: `translateY(${virtualItem.start}px)` }}
352+
>
353+
{msg.role === 'user' ? (
354+
<UserMessageRow
355+
content={msg.content}
356+
contexts={msg.contexts}
357+
attachments={msg.attachments}
358+
rowClassName={styles.userRow}
359+
bubbleClassName={styles.userBubble}
360+
attachmentWidthClassName={styles.attachmentWidth}
361+
/>
362+
) : (
363+
<AssistantMessageRow
364+
message={msg}
365+
isStreaming={isStreamActive && isLast}
366+
precedingUserContent={precedingUserContentByIndex[index]}
367+
rowClassName={styles.assistantRow}
368+
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
369+
/>
370+
)}
371+
</div>
318372
)
319373
})}
320374
</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)