Skip to content

Commit 6235576

Browse files
committed
fix(ui): thinking loader and rool names
1 parent 7417a53 commit 6235576

13 files changed

Lines changed: 613 additions & 139 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,8 @@ interface ChatContentProps {
310310
onOptionSelect?: (id: string) => void
311311
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
312312
onRevealStateChange?: (isRevealing: boolean) => void
313+
/** Reports whether this segment is actively painting text or its own pending-tag indicator. */
314+
onStreamActivityChange?: (active: boolean) => void
313315
}
314316

315317
function ChatContentInner({
@@ -319,6 +321,7 @@ function ChatContentInner({
319321
onOptionSelect,
320322
onWorkspaceResourceSelect,
321323
onRevealStateChange,
324+
onStreamActivityChange,
322325
}: ChatContentProps) {
323326
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
324327
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
@@ -328,7 +331,8 @@ function ChatContentInner({
328331

329332
const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content])
330333
const streamedContent = useSmoothText(displayContent, isStreaming)
331-
const isRevealing = isStreaming || streamedContent.length < displayContent.length
334+
const hasRevealBacklog = streamedContent.length < displayContent.length
335+
const isRevealing = isStreaming || hasRevealBacklog
332336

333337
useEffect(() => {
334338
onRevealStateChangeRef.current?.(isRevealing)
@@ -438,6 +442,12 @@ function ChatContentInner({
438442
() => parseSpecialTags(streamedContent, isRevealing),
439443
[streamedContent, isRevealing]
440444
)
445+
const hasPendingIndicator = parsed.hasPendingTag && isRevealing
446+
447+
useEffect(() => {
448+
onStreamActivityChange?.(hasRevealBacklog || hasPendingIndicator)
449+
return () => onStreamActivityChange?.(false)
450+
}, [hasPendingIndicator, hasRevealBacklog, onStreamActivityChange])
441451

442452
type BlockSegment = Exclude<
443453
ContentSegment,
@@ -524,7 +534,7 @@ function ChatContentInner({
524534
/>
525535
)
526536
})}
527-
{parsed.hasPendingTag && isRevealing && <PendingTagIndicator />}
537+
{hasPendingIndicator && <PendingTagIndicator />}
528538
</div>
529539
)
530540
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ContentBlock } from '../../types'
66
import {
77
assistantMessageHasRunningWork,
88
parseBlocks,
9+
shouldShowTrailingThinking,
910
shouldSmoothTextSegment,
1011
} from './message-content'
1112

@@ -40,6 +41,35 @@ function mainToolCall(id: string, name: string): ContentBlock {
4041
}
4142

4243
describe('parseBlocks span-identity tree', () => {
44+
it('refines a completed credential rename with its previous and new names', () => {
45+
const segments = parseBlocks([
46+
{
47+
type: 'tool_call',
48+
toolCall: {
49+
id: 'rename-credential',
50+
name: 'manage_credential',
51+
status: 'success',
52+
params: { operation: 'rename', displayName: 'Production Stripe' },
53+
result: {
54+
success: true,
55+
output: {
56+
previousDisplayName: 'Stripe',
57+
displayName: 'Production Stripe',
58+
},
59+
},
60+
},
61+
timestamp: 1,
62+
},
63+
])
64+
65+
expect(segments).toHaveLength(1)
66+
const group = segments[0]
67+
if (group.type !== 'agent_group') throw new Error('expected mothership group')
68+
const tool = group.items[0]
69+
if (tool?.type !== 'tool') throw new Error('expected credential tool')
70+
expect(tool.data.displayTitle).toBe('Renaming Stripe to Production Stripe')
71+
})
72+
4373
it('nests a deploy subagent inside the workflow subagent that spawned it', () => {
4474
const blocks: ContentBlock[] = [
4575
subagentStart('workflow', 'S1', 'main'),
@@ -338,6 +368,62 @@ describe('shouldSmoothTextSegment', () => {
338368
})
339369
})
340370

371+
describe('shouldShowTrailingThinking', () => {
372+
it('shows one turn-level indicator while an open subagent waits between completed steps', () => {
373+
expect(
374+
shouldShowTrailingThinking({
375+
isStreaming: true,
376+
isStreamIdle: true,
377+
isRenderingStream: false,
378+
hasExecutingTool: false,
379+
lastSegmentType: 'agent_group',
380+
})
381+
).toBe(true)
382+
})
383+
384+
it('stays hidden while a chunk is rendering or before the stream becomes idle', () => {
385+
expect(
386+
shouldShowTrailingThinking({
387+
isStreaming: true,
388+
isStreamIdle: true,
389+
isRenderingStream: true,
390+
hasExecutingTool: false,
391+
lastSegmentType: 'text',
392+
})
393+
).toBe(false)
394+
expect(
395+
shouldShowTrailingThinking({
396+
isStreaming: true,
397+
isStreamIdle: false,
398+
isRenderingStream: false,
399+
hasExecutingTool: false,
400+
lastSegmentType: 'agent_group',
401+
})
402+
).toBe(false)
403+
})
404+
405+
it('does not duplicate an executing tool row or survive a stopped turn', () => {
406+
expect(
407+
shouldShowTrailingThinking({
408+
isStreaming: true,
409+
isStreamIdle: true,
410+
isRenderingStream: false,
411+
hasExecutingTool: true,
412+
lastSegmentType: 'agent_group',
413+
})
414+
).toBe(false)
415+
expect(
416+
shouldShowTrailingThinking({
417+
isStreaming: true,
418+
isStreamIdle: true,
419+
isRenderingStream: false,
420+
hasExecutingTool: false,
421+
lastSegmentType: 'stopped',
422+
})
423+
).toBe(false)
424+
})
425+
})
426+
341427
describe('parseBlocks legacy — thinking between top-level tools', () => {
342428
it('keeps consecutive mothership tools in one group across intervening thinking', () => {
343429
const blocks: ContentBlock[] = [

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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'
44
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
55
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
66
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
@@ -14,6 +14,7 @@ import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } fro
1414
import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
1515

1616
const FILE_SUBAGENT_ID = 'file'
17+
const STREAM_IDLE_DELAY_MS = 1_500
1718

1819
interface 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+
737771
interface 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

Comments
 (0)