11'use client'
22
3- import { memo , useCallback , useEffect , useMemo , useRef , useState } from 'react'
3+ import { memo , useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
44import { Read as ReadTool , WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
55import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
66import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
@@ -14,6 +14,7 @@ import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } fro
1414import { deriveMessagePhase , isToolDone , type MessagePhase } from './utils'
1515
1616const FILE_SUBAGENT_ID = 'file'
17+ const STREAM_IDLE_DELAY_MS = 1_500
1718
1819interface TextSegment {
1920 type : 'text'
@@ -96,6 +97,17 @@ function getOverrideDisplayTitle(tc: NonNullable<ContentBlock['toolCall']>): str
9697 if ( tc . name === ReadTool . id || tc . name === 'respond' || tc . name . endsWith ( '_respond' ) ) {
9798 return resolveToolDisplay ( tc . name , mapToolStatusToClientState ( tc . status ) , tc . params ) ?. text
9899 }
100+ if ( tc . name === 'manage_credential' && tc . params ?. operation === 'rename' ) {
101+ const output = tc . result ?. output
102+ const result = output && typeof output === 'object' ? ( output as Record < string , unknown > ) : null
103+ const previousDisplayName = result ?. previousDisplayName
104+ if ( typeof previousDisplayName === 'string' && previousDisplayName . trim ( ) ) {
105+ return getToolDisplayTitle ( tc . name , {
106+ ...tc . params ,
107+ previousDisplayName : previousDisplayName . trim ( ) ,
108+ } )
109+ }
110+ }
99111 return undefined
100112}
101113
@@ -734,6 +746,28 @@ export function shouldSmoothTextSegment({
734746 return isStreaming && segmentIndex === segmentCount - 1
735747}
736748
749+ export function shouldShowTrailingThinking ( {
750+ isStreaming,
751+ isStreamIdle,
752+ isRenderingStream,
753+ hasExecutingTool,
754+ lastSegmentType,
755+ } : {
756+ isStreaming : boolean
757+ isStreamIdle : boolean
758+ isRenderingStream : boolean
759+ hasExecutingTool : boolean
760+ lastSegmentType ?: 'text' | 'agent_group' | 'options' | 'stopped'
761+ } ) : boolean {
762+ return (
763+ isStreaming &&
764+ isStreamIdle &&
765+ ! isRenderingStream &&
766+ ! hasExecutingTool &&
767+ lastSegmentType !== 'stopped'
768+ )
769+ }
770+
737771interface MessageContentProps {
738772 blocks : ContentBlock [ ]
739773 fallbackContent : string
@@ -759,6 +793,25 @@ function MessageContentInner({
759793 const handleTrailingRevealChange = useCallback ( ( revealing : boolean ) => {
760794 setTrailingRevealing ( revealing )
761795 } , [ ] )
796+ const [ trailingStreamActivity , setTrailingStreamActivity ] = useState ( false )
797+ const handleTrailingStreamActivityChange = useCallback ( ( active : boolean ) => {
798+ setTrailingStreamActivity ( active )
799+ } , [ ] )
800+ const [ isStreamIdle , setIsStreamIdle ] = useState ( false )
801+
802+ // Every rendered stream snapshot restarts the quiet-period clock. A layout
803+ // effect clears an already-visible indicator before paint, so a chunk from
804+ // any parallel lane hides the one turn-level loader without a stale flash.
805+ useLayoutEffect ( ( ) => {
806+ if ( ! isStreaming ) {
807+ setIsStreamIdle ( false )
808+ return
809+ }
810+
811+ setIsStreamIdle ( false )
812+ const timeout = setTimeout ( ( ) => setIsStreamIdle ( true ) , STREAM_IDLE_DELAY_MS )
813+ return ( ) => clearTimeout ( timeout )
814+ } , [ blocks , fallbackContent , isStreaming ] )
762815
763816 const segments : MessageSegment [ ] =
764817 parsed . length > 0
@@ -789,14 +842,18 @@ function MessageContentInner({
789842 return null
790843 }
791844
792- const hasTrailingContent = lastSegment . type === 'text' || lastSegment . type === 'stopped'
793-
794- // Deterministic "between steps" signal: the turn is still streaming, nothing
795- // is actively running (a running tool/subagent renders its own spinner), and
796- // no trailing text is being revealed. Derived from explicit node state rather
797- // than guessing from the shape of the last segment.
798- const hasRunningWork = assistantMessageHasRunningWork ( blocks )
799- const showTrailingThinking = phase === 'streaming' && ! hasTrailingContent && ! hasRunningWork
845+ // Executing tools already render an active row. An open subagent lane does
846+ // not suppress the turn-level indicator: once its latest visible chunk has
847+ // settled, the loader can bridge the wait until that lane (or a parallel
848+ // sibling) emits again.
849+ const hasExecutingTool = blocks . some ( ( b ) => b . toolCall ?. status === 'executing' )
850+ const showTrailingThinking = shouldShowTrailingThinking ( {
851+ isStreaming : phase === 'streaming' ,
852+ isStreamIdle,
853+ isRenderingStream : trailingStreamActivity ,
854+ hasExecutingTool,
855+ lastSegmentType : lastSegment . type ,
856+ } )
800857
801858 return (
802859 < div className = 'space-y-[10px]' >
@@ -818,6 +875,9 @@ function MessageContentInner({
818875 onRevealStateChange = {
819876 i === segments . length - 1 ? handleTrailingRevealChange : undefined
820877 }
878+ onStreamActivityChange = {
879+ i === segments . length - 1 ? handleTrailingStreamActivityChange : undefined
880+ }
821881 />
822882 )
823883 case 'agent_group' : {
@@ -854,11 +914,7 @@ function MessageContentInner({
854914 )
855915 }
856916 } ) }
857- { showTrailingThinking && (
858- < div className = 'animate-stream-fade-in-delayed opacity-0' >
859- < PendingTagIndicator />
860- </ div >
861- ) }
917+ { showTrailingThinking && < PendingTagIndicator /> }
862918 </ div >
863919 )
864920}
0 commit comments