@@ -3,6 +3,7 @@ import type {
33 SessionNotification ,
44} from "@agentclientprotocol/sdk" ;
55import {
6+ type QueuedMessage ,
67 usePendingPermissionsForTask ,
78 useQueuedMessagesForTask ,
89} from "@features/sessions/stores/sessionStore" ;
@@ -17,15 +18,7 @@ import {
1718 isJsonRpcResponse ,
1819 type UserShellExecuteParams ,
1920} from "@shared/types/session-events" ;
20- import {
21- memo ,
22- useCallback ,
23- useEffect ,
24- useLayoutEffect ,
25- useMemo ,
26- useRef ,
27- useState ,
28- } from "react" ;
21+ import { memo , useCallback , useEffect , useMemo , useRef , useState } from "react" ;
2922import { GitActionMessage , parseGitActionMessage } from "./GitActionMessage" ;
3023import { GitActionResult } from "./GitActionResult" ;
3124import { SessionFooter } from "./SessionFooter" ;
@@ -39,6 +32,7 @@ import {
3932 type UserShellExecute ,
4033 UserShellExecuteView ,
4134} from "./session-update/UserShellExecuteView" ;
35+ import { VirtualizedList , type VirtualizedListHandle } from "./VirtualizedList" ;
4236
4337interface Turn {
4438 type : "turn" ;
@@ -53,7 +47,14 @@ interface Turn {
5347 toolCalls : Map < string , ToolCall > ;
5448}
5549
50+ interface QueuedItem {
51+ type : "queued" ;
52+ id : string ;
53+ message : QueuedMessage ;
54+ }
55+
5656type ConversationItem = Turn | UserShellExecute ;
57+ type VirtualizedItem = ConversationItem | QueuedItem ;
5758
5859interface ConversationViewProps {
5960 events : AcpMessage [ ] ;
@@ -63,8 +64,8 @@ interface ConversationViewProps {
6364 taskId ?: string ;
6465}
6566
66- const SCROLL_THRESHOLD = 100 ;
6767const SHOW_BUTTON_THRESHOLD = 300 ;
68+ const ESTIMATE_SIZE = 200 ;
6869
6970export function ConversationView ( {
7071 events,
@@ -73,110 +74,125 @@ export function ConversationView({
7374 repoPath,
7475 taskId,
7576} : ConversationViewProps ) {
76- const scrollRef = useRef < HTMLDivElement > ( null ) ;
77- const items = useMemo ( ( ) => buildConversationItems ( events ) , [ events ] ) ;
78- const lastTurn = items . filter ( ( i ) : i is Turn => i . type === "turn" ) . pop ( ) ;
77+ const listRef = useRef < VirtualizedListHandle > ( null ) ;
78+ const conversationItems = useMemo (
79+ ( ) => buildConversationItems ( events ) ,
80+ [ events ] ,
81+ ) ;
82+ const lastTurn = conversationItems
83+ . filter ( ( i ) : i is Turn => i . type === "turn" )
84+ . pop ( ) ;
7985
8086 const pendingPermissions = usePendingPermissionsForTask ( taskId ?? "" ) ;
8187 const pendingPermissionsCount = pendingPermissions . size ;
8288
8389 const queuedMessages = useQueuedMessagesForTask ( taskId ) ;
8490 const { saveScrollPosition, getScrollPosition } = useSessionViewActions ( ) ;
8591
86- const prevItemsLengthRef = useRef ( 0 ) ;
87- const prevPendingCountRef = useRef ( 0 ) ;
88- const prevScrollHeightRef = useRef ( 0 ) ;
8992 const [ showScrollButton , setShowScrollButton ] = useState ( false ) ;
9093 const hasRestoredScrollRef = useRef ( false ) ;
94+ const prevItemCountRef = useRef ( 0 ) ;
9195
92- useEffect ( ( ) => {
93- hasRestoredScrollRef . current = false ;
94- } , [ ] ) ;
95-
96- useLayoutEffect ( ( ) => {
97- const el = scrollRef . current ;
98- if ( ! el || ! taskId ) return ;
96+ const virtualizedItems = useMemo < VirtualizedItem [ ] > ( ( ) => {
97+ const items : VirtualizedItem [ ] = [ ...conversationItems ] ;
9998
100- const handleScroll = ( ) => {
101- const distanceFromBottom =
102- el . scrollHeight - el . scrollTop - el . clientHeight ;
103- setShowScrollButton ( distanceFromBottom > SHOW_BUTTON_THRESHOLD ) ;
104- saveScrollPosition ( taskId , el . scrollTop ) ;
105- } ;
99+ for ( const msg of queuedMessages ) {
100+ items . push ( { type : "queued" , id : msg . id , message : msg } ) ;
101+ }
106102
107- el . addEventListener ( "scroll" , handleScroll ) ;
108- return ( ) => {
109- el . removeEventListener ( "scroll" , handleScroll ) ;
110- saveScrollPosition ( taskId , el . scrollTop ) ;
111- } ;
112- } , [ taskId , saveScrollPosition ] ) ;
103+ return items ;
104+ } , [ conversationItems , queuedMessages ] ) ;
113105
114- useLayoutEffect ( ( ) => {
115- const el = scrollRef . current ;
116- if ( ! el || ! taskId ) return ;
106+ useEffect ( ( ) => {
107+ if ( ! taskId || hasRestoredScrollRef . current ) return ;
117108
118- if ( ! hasRestoredScrollRef . current ) {
119- const savedPosition = getScrollPosition ( taskId ) ;
120- if ( savedPosition > 0 ) {
121- el . scrollTop = savedPosition ;
109+ const savedPosition = getScrollPosition ( taskId ) ;
110+ if ( savedPosition > 0 ) {
111+ const virtualizer = listRef . current ?. getVirtualizer ( ) ;
112+ if ( virtualizer ) {
113+ virtualizer . scrollOffset = savedPosition ;
122114 hasRestoredScrollRef . current = true ;
123- return ;
124115 }
125116 }
117+ } , [ taskId , getScrollPosition ] ) ;
126118
127- const isNewContent = items . length > prevItemsLengthRef . current ;
128- const isNewPending = pendingPermissionsCount > prevPendingCountRef . current ;
129- prevItemsLengthRef . current = items . length ;
130- prevPendingCountRef . current = pendingPermissionsCount ;
119+ const isStreaming = lastTurn && ! lastTurn . isComplete ;
131120
132- const prevScrollHeight = prevScrollHeightRef . current || el . scrollHeight ;
133- const wasNearBottom =
134- prevScrollHeight - el . scrollTop - el . clientHeight <= SCROLL_THRESHOLD ;
135- prevScrollHeightRef . current = el . scrollHeight ;
121+ useEffect ( ( ) => {
122+ const isNewContent = virtualizedItems . length > prevItemCountRef . current ;
123+ prevItemCountRef . current = virtualizedItems . length ;
136124
137- if ( wasNearBottom || isNewContent || isNewPending ) {
138- el . scrollTop = el . scrollHeight ;
125+ if ( isNewContent && ! showScrollButton ) {
126+ listRef . current ?. scrollToBottom ( ) ;
139127 }
140- } , [ items , pendingPermissionsCount , taskId , getScrollPosition ] ) ;
128+ } , [ virtualizedItems . length , showScrollButton ] ) ;
141129
142- const scrollToBottom = useCallback ( ( ) => {
143- const el = scrollRef . current ;
144- if ( el ) {
145- el . scrollTo ( { top : el . scrollHeight , behavior : "smooth" } ) ;
130+ useEffect ( ( ) => {
131+ if ( isStreaming && ! showScrollButton ) {
132+ listRef . current ?. scrollToBottom ( ) ;
146133 }
134+ } , [ isStreaming , showScrollButton ] ) ;
135+
136+ const handleScroll = useCallback (
137+ ( scrollOffset : number , scrollHeight : number , clientHeight : number ) => {
138+ const distanceFromBottom = scrollHeight - scrollOffset - clientHeight ;
139+ setShowScrollButton ( distanceFromBottom > SHOW_BUTTON_THRESHOLD ) ;
140+
141+ if ( taskId ) {
142+ saveScrollPosition ( taskId , scrollOffset ) ;
143+ }
144+ } ,
145+ [ taskId , saveScrollPosition ] ,
146+ ) ;
147+
148+ const scrollToBottom = useCallback ( ( ) => {
149+ listRef . current ?. scrollToBottom ( ) ;
147150 } , [ ] ) ;
148151
152+ const renderItem = useCallback (
153+ ( item : VirtualizedItem ) => {
154+ switch ( item . type ) {
155+ case "turn" :
156+ return < TurnView turn = { item } repoPath = { repoPath } /> ;
157+ case "user_shell_execute" :
158+ return < UserShellExecuteView item = { item } /> ;
159+ case "queued" :
160+ return < QueuedMessageView message = { item . message } /> ;
161+ }
162+ } ,
163+ [ repoPath ] ,
164+ ) ;
165+
166+ const getItemKey = useCallback ( ( item : VirtualizedItem ) => item . id , [ ] ) ;
167+
149168 return (
150169 < div className = "relative flex-1" >
151- < div
152- ref = { scrollRef }
153- className = "absolute inset-0 overflow-auto bg-gray-1 p-2 pb-16"
154- >
155- < div className = "mx-auto max-w-[750px]" >
156- < div className = "flex flex-col gap-3" >
157- { items . map ( ( item ) =>
158- item . type === "turn" ? (
159- < TurnView key = { item . id } turn = { item } repoPath = { repoPath } />
160- ) : (
161- < UserShellExecuteView key = { item . id } item = { item } />
162- ) ,
163- ) }
164- { queuedMessages . map ( ( msg ) => (
165- < QueuedMessageView key = { msg . id } message = { msg } />
166- ) ) }
170+ < VirtualizedList
171+ ref = { listRef }
172+ items = { virtualizedItems }
173+ estimateSize = { ESTIMATE_SIZE }
174+ gap = { 12 }
175+ overscan = { 5 }
176+ getItemKey = { getItemKey }
177+ renderItem = { renderItem }
178+ onScroll = { handleScroll }
179+ className = "absolute inset-0 bg-gray-1 p-2"
180+ innerClassName = "mx-auto max-w-[750px]"
181+ footer = {
182+ < div className = "pb-16" >
183+ < SessionFooter
184+ isPromptPending = { isPromptPending }
185+ promptStartedAt = { promptStartedAt }
186+ lastGenerationDuration = {
187+ lastTurn ?. isComplete ? lastTurn . durationMs : null
188+ }
189+ lastStopReason = { lastTurn ?. stopReason }
190+ queuedCount = { queuedMessages . length }
191+ hasPendingPermission = { pendingPermissionsCount > 0 }
192+ />
167193 </ div >
168- < SessionFooter
169- isPromptPending = { isPromptPending }
170- promptStartedAt = { promptStartedAt }
171- lastGenerationDuration = {
172- lastTurn ?. isComplete ? lastTurn . durationMs : null
173- }
174- lastStopReason = { lastTurn ?. stopReason }
175- queuedCount = { queuedMessages . length }
176- hasPendingPermission = { pendingPermissionsCount > 0 }
177- />
178- </ div >
179- </ div >
194+ }
195+ />
180196 { showScrollButton && (
181197 < Box className = "absolute right-4 bottom-4 z-10" >
182198 < Button size = "1" variant = "solid" onClick = { scrollToBottom } >
0 commit comments