@@ -5,14 +5,16 @@ import MessageItem from '../shared/MessageItem';
55import UnifiedDiffView from '../shared/UnifiedDiffView' ;
66import type { Item } from '@shared/types' ;
77import type { CheckpointDiff } from '../lib/core-api' ;
8- import { useAgent } from '../hooks/useAgent' ;
8+ import { useAgentApproval , useAgentRollback } from '../hooks/useAgent' ;
99
1010interface MessageStreamProps {
1111 threadId : string ;
1212}
1313
1414// ---- Top-level TurnDiffPanel (avoids unmount/remount on parent re-render) ----
1515
16+ const EMPTY_MAPPING : Record < number , string > = { } ;
17+
1618interface TurnDiffPanelProps {
1719 uiTurnId : string ;
1820 isInterrupted ?: boolean ;
@@ -205,11 +207,14 @@ function TurnDiffPanel({
205207// ---- Main component ----
206208
207209export default function MessageStream ( { threadId } : MessageStreamProps ) {
208- const thread = useGlobalStore ( ( s ) => s . agent . threads [ threadId ] ) ;
210+ // Fine-grained subscriptions: only subscribe to what we actually use
211+ const turns = useGlobalStore ( ( s ) => s . agent . threads [ threadId ] ?. turns ?? [ ] ) ;
209212 const setCurrentThread = useGlobalStore ( ( s ) => s . setCurrentThread ) ;
210213 const {
211214 approveTool,
212215 rejectTool,
216+ } = useAgentApproval ( ) ;
217+ const {
213218 loadCheckpointDiff,
214219 revertFile,
215220 revertAgentFiles,
@@ -221,68 +226,81 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
221226 undoCodeRollback,
222227 forkThread,
223228 revertedFilesByTurnId,
224- } = useAgent ( ) ;
229+ } = useAgentRollback ( ) ;
225230 const virtuosoRef = useRef < VirtuosoHandle > ( null ) ;
226- const scrollContainerRef = useRef < HTMLDivElement > ( null ) ;
227- const wasAtBottomRef = useRef ( true ) ;
228231
229- // Fine-grained selectors: only subscribe to current thread's data
230- const checkpointDiffs = useGlobalStore ( ( s ) => {
232+ // Subscribe to raw store data (stable references), derive with useMemo below
233+ const rawCheckpointDiffByTurnId = useGlobalStore ( ( s ) => s . rollback . checkpointDiffByTurnId ) ;
234+ const rawTurnCheckpointMapping = useGlobalStore ( ( s ) => s . rollback . turnCheckpointMapping ) ;
235+ const markScopeRestored = useGlobalStore ( ( s ) => s . markScopeRestored ) ;
236+ const markFileRestored = useGlobalStore ( ( s ) => s . markFileRestored ) ;
237+ const setPendingInput = useGlobalStore ( ( s ) => s . setPendingInput ) ;
238+
239+ // Derive thread-scoped data with useMemo for stable references
240+ const checkpointDiffs = useMemo ( ( ) => {
231241 const prefix = `${ threadId } :` ;
232242 const result : Record < string , CheckpointDiff > = { } ;
233- for ( const [ k , v ] of Object . entries ( s . rollback . checkpointDiffByTurnId ) ) {
243+ for ( const [ k , v ] of Object . entries ( rawCheckpointDiffByTurnId ) ) {
234244 if ( k . startsWith ( prefix ) ) result [ k ] = v ;
235245 }
236246 return result ;
237- } ) ;
238- const turnCheckpointMapping = useGlobalStore (
239- ( s ) => s . rollback . turnCheckpointMapping [ threadId ] ?? { }
240- ) ;
241- const markScopeRestored = useGlobalStore ( ( s ) => s . markScopeRestored ) ;
242- const markFileRestored = useGlobalStore ( ( s ) => s . markFileRestored ) ;
243- const setPendingInput = useGlobalStore ( ( s ) => s . setPendingInput ) ;
247+ } , [ threadId , rawCheckpointDiffByTurnId ] ) ;
248+
249+ const turnCheckpointMapping = rawTurnCheckpointMapping [ threadId ] ?? EMPTY_MAPPING ;
244250
245251 // Rollback modal state
246252 const [ showRollbackPanel , setShowRollbackPanel ] = useState < {
247253 turnId : string ;
248254 preview : any ;
249255 } | null > ( null ) ;
250256
257+ // ---- Structure signature: only changes when turns structure changes (not content) ----
258+ const turnsStructureKey = useMemo (
259+ ( ) =>
260+ turns
261+ . map ( ( t ) => `${ t . id } :${ t . status } :${ t . items . length } :${ t . items . map ( ( i ) => `${ i . type } :${ i . id } ` ) . join ( ',' ) } ` )
262+ . join ( '|' ) ,
263+ [ turns ]
264+ ) ;
265+
251266 // ---- Memoized rendering data ----
267+ // Depends on turnsStructureKey instead of thread, so content updates don't trigger rebuild
252268
253- const { renderEntries, callIdToToolName } = useMemo ( ( ) => {
269+ const { renderEntries, callIdToToolName, entryCountByTurnId , turnById } = useMemo ( ( ) => {
254270 const entries : Array < {
255271 item : Item ;
256272 turnId : string ;
257273 toolResult ?: Item & { type : 'tool_result' } ;
258274 } > = [ ] ;
259275 const toolResultByCallId : Record < string , Item & { type : 'tool_result' } > = { } ;
260276 const nameMap : Record < string , string > = { } ;
277+ const countMap = new Map < string , number > ( ) ;
278+ const turnMap = new Map < string , ( typeof turns ) [ number ] > ( ) ;
261279
262- if ( thread ) {
263- for ( const turn of thread . turns ) {
264- for ( const item of turn . items ) {
265- if ( item . type === 'tool_result' ) {
266- toolResultByCallId [ item . callId ] = item as any ;
267- } else if ( item . type === 'tool_call' ) {
268- nameMap [ item . id ] = item . name ;
269- }
280+ for ( const turn of turns ) {
281+ turnMap . set ( turn . id , turn ) ;
282+ for ( const item of turn . items ) {
283+ if ( item . type === 'tool_result' ) {
284+ toolResultByCallId [ item . callId ] = item as any ;
285+ } else if ( item . type === 'tool_call' ) {
286+ nameMap [ item . id ] = item . name ;
270287 }
271288 }
272- for ( const turn of thread . turns ) {
273- for ( const item of turn . items ) {
274- if ( item . type === 'tool_result' ) continue ;
275- if ( item . type === 'tool_call ' ) {
276- entries . push ( { item, turnId : turn . id , toolResult : toolResultByCallId [ item . id ] } ) ;
277- } else {
278- entries . push ( { item , turnId : turn . id } ) ;
279- }
289+ }
290+ for ( const turn of turns ) {
291+ for ( const item of turn . items ) {
292+ if ( item . type === 'tool_result ' ) continue ;
293+ if ( item . type === 'tool_call' ) {
294+ entries . push ( { item , turnId : turn . id , toolResult : toolResultByCallId [ item . id ] } ) ;
295+ } else {
296+ entries . push ( { item , turnId : turn . id } ) ;
280297 }
298+ countMap . set ( turn . id , ( countMap . get ( turn . id ) ?? 0 ) + 1 ) ;
281299 }
282300 }
283301
284- return { renderEntries : entries , callIdToToolName : nameMap } ;
285- } , [ thread ] ) ;
302+ return { renderEntries : entries , callIdToToolName : nameMap , entryCountByTurnId : countMap , turnById : turnMap } ;
303+ } , [ turnsStructureKey ] ) ;
286304
287305 const { turnEndIndices, turnRollbackCallbacks } = useMemo ( ( ) => {
288306 const endIndices = new Set < number > ( ) ;
@@ -293,10 +311,9 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
293311 onForkFromHere ?: ( ) => void ;
294312 }
295313 > ( ) ;
296- const turns = thread ?. turns ?? [ ] ;
297314 let idx = 0 ;
298315 for ( const turn of turns ) {
299- const turnEntryCount = renderEntries . filter ( ( e ) => e . turnId === turn . id ) . length ;
316+ const turnEntryCount = entryCountByTurnId . get ( turn . id ) ?? 0 ;
300317 idx += turnEntryCount - 1 ;
301318 if ( turn . status === 'completed' || turn . status === 'error' ) {
302319 endIndices . add ( idx ) ;
@@ -328,32 +345,16 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
328345 idx ++ ;
329346 }
330347 return { turnEndIndices : endIndices , turnRollbackCallbacks : rollbackCbs } ;
331- } , [ thread , renderEntries , threadId , previewRollback , forkThread , setCurrentThread , setPendingInput ] ) ;
348+ } , [ turns , entryCountByTurnId , threadId , previewRollback , forkThread , setCurrentThread , setPendingInput ] ) ;
332349
333350 const totalCount = renderEntries . length ;
334- const isLargeList = totalCount > 100 ;
335- const turns = thread ?. turns ?? [ ] ;
336351
337352 // Memoized turnStatusKey for auto-load diff effect
338353 const turnStatusKey = useMemo (
339354 ( ) => turns . map ( ( t ) => `${ t . id } :${ t . status } ` ) . join ( ',' ) ,
340355 [ turns ]
341356 ) ;
342357
343- const handleScroll = useCallback ( ( ) => {
344- const el = scrollContainerRef . current ;
345- if ( ! el ) return ;
346- wasAtBottomRef . current = el . scrollHeight - el . scrollTop - el . clientHeight < 80 ;
347- } , [ ] ) ;
348-
349- useEffect ( ( ) => {
350- if ( totalCount === 0 || isLargeList ) return ;
351- if ( ! wasAtBottomRef . current ) return ;
352- const el = scrollContainerRef . current ;
353- if ( ! el ) return ;
354- el . scrollTo ( { top : el . scrollHeight , behavior : 'smooth' } ) ;
355- } , [ totalCount , isLargeList ] ) ;
356-
357358 // Auto-load diff when a turn completes or errors
358359 const handleLoadDiff = useCallback (
359360 async ( uiTurnId : string ) => {
@@ -416,7 +417,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
416417 const isLastInTurn = turnEndIndices . has ( index ) ;
417418 const cbs = turnRollbackCallbacks . get ( entry . turnId ) ;
418419 const isUserMsg = entry . item . type === 'message' && entry . item . role === 'user' ;
419- const turn = turns . find ( ( t ) => t . id === entry . turnId ) ;
420+ const turn = turnById . get ( entry . turnId ) ;
420421 const isInterrupted = turn ?. status === 'error' ;
421422
422423 return (
@@ -454,7 +455,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
454455 renderEntries ,
455456 turnEndIndices ,
456457 turnRollbackCallbacks ,
457- turns ,
458+ turnById ,
458459 threadId ,
459460 approveTool ,
460461 rejectTool ,
@@ -469,7 +470,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
469470
470471 // ---- Empty state ----
471472
472- if ( ! thread || renderEntries . length === 0 ) {
473+ if ( turns . length === 0 || renderEntries . length === 0 ) {
473474 return (
474475 < div className = "flex-1 flex items-center justify-center text-[#444] text-[15px]" >
475476 发送消息开始对话
@@ -521,40 +522,18 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
521522 </ div >
522523 ) ;
523524
524- // ---- Virtuoso path (many items) ----
525-
526- if ( isLargeList ) {
527- return (
528- < div className = "flex-1 flex flex-col min-h-0" >
529- < Virtuoso
530- ref = { virtuosoRef }
531- className = "flex-1 select-text"
532- totalCount = { totalCount }
533- itemContent = { renderItem }
534- followOutput = { ( isAtBottom : boolean ) => ( isAtBottom ? 'smooth' : false ) }
535- style = { { flex : 1 } }
536- />
537- { rollbackModal }
538- </ div >
539- ) ;
540- }
541-
542- // ---- Non-Virtuoso path (few items) ----
525+ // ---- Unified Virtuoso path ----
543526
544527 return (
545528 < div className = "flex-1 flex flex-col min-h-0" >
546- < div
547- ref = { scrollContainerRef }
548- onScroll = { handleScroll }
549- className = "flex-1 overflow-y-auto select-text"
550- >
551- < div className = "pt-8 pb-4 max-w-[820px] mx-auto" >
552- { renderEntries . map ( ( entry , i ) => {
553- const key = entry . item . id + ( entry . toolResult ? '-' + entry . toolResult . id : '' ) ;
554- return < div key = { key } > { renderItem ( i ) } </ div > ;
555- } ) }
556- </ div >
557- </ div >
529+ < Virtuoso
530+ ref = { virtuosoRef }
531+ className = "flex-1 select-text"
532+ totalCount = { totalCount }
533+ itemContent = { renderItem }
534+ followOutput = { ( isAtBottom : boolean ) => ( isAtBottom ? 'smooth' : false ) }
535+ style = { { flex : 1 } }
536+ />
558537 { rollbackModal }
559538 </ div >
560539 ) ;
0 commit comments