11'use client'
22
33import { memo , useCallback , useEffect , useLayoutEffect , useMemo , useRef } from 'react'
4+ import { useVirtualizer } from '@tanstack/react-virtual'
45import { cn } from '@/lib/core/utils/cn'
56import { MessageActions } from '@/app/workspace/[workspaceId]/components'
67import { 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'
2829import { useAutoScroll } from '@/hooks/use-auto-scroll'
29- import { useProgressiveList } from '@/hooks/use-progressive-list'
3030import type { ChatContext } from '@/stores/panel'
3131import { 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+
6482const 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 >
0 commit comments