Skip to content

Commit b629292

Browse files
authored
improvement(chat): shimmer active subagent and tool labels instead of spinners (#5612)
* improvement(chat): shimmer active subagent and tool labels instead of spinners * improvement(chat): address review — focus-visible chevron, single shimmer source, reduced-motion rest color * improvement(chat): pulse shimmer text under reduced motion so running state stays visible * improvement(chat): reset background-clip in reduced-motion shimmer fallback * fix(chat): apply reduced-motion shimmer fallback in dark mode too
1 parent fcf4e02 commit b629292

6 files changed

Lines changed: 115 additions & 46 deletions

File tree

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

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

33
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
4-
import { ChevronDown, cn, Expandable, ExpandableContent, PillsRing } from '@sim/emcn'
4+
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
5+
import { ShimmerText } from '@/components/ui'
56
import type { ToolCallData } from '../../../../types'
67
import { getAgentIcon, isToolDone } from '../../utils'
78
import { ToolCallItem } from './tool-call-item'
@@ -63,11 +64,7 @@ export function AgentGroup({
6364
const AgentIcon = getAgentIcon(agentName)
6465
const hasItems = items.length > 0
6566
const resolved = isAgentGroupResolved(items)
66-
// Pure projection of the run's own state: a subagent header spins while it is
67-
// delegating with no resolved work yet. A terminal turn closes the lane (its
68-
// subagent block is stamped ended), which clears `isDelegating`, so no
69-
// transport gating is needed to stop an aborted-before-first-tool spinner.
70-
const showDelegatingSpinner = isDelegating && !resolved
67+
const isWorking = (isDelegating && !resolved) || (isStreaming && isLaneOpen)
7168

7269
// Expand while the turn is live and any of: the lane is open (the subagent is
7370
// actively running), this is the current/latest section, or there is unresolved
@@ -89,33 +86,33 @@ export function AgentGroup({
8986
<button
9087
type='button'
9188
onClick={() => setManualExpanded(!expanded)}
92-
className='flex cursor-pointer items-center gap-2'
89+
className='group/agent flex cursor-pointer items-center gap-2'
9390
>
9491
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
95-
{showDelegatingSpinner ? (
96-
<PillsRing className='size-[15px] text-[var(--text-icon)]' animate />
97-
) : (
98-
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
99-
)}
92+
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
10093
</div>
101-
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
94+
{isWorking ? (
95+
<ShimmerText className='text-sm'>{agentLabel}</ShimmerText>
96+
) : (
97+
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
98+
)}
10299
<ChevronDown
103100
className={cn(
104-
'h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150',
101+
'h-[7px] w-[9px] text-[var(--text-icon)] opacity-0 transition-[transform,opacity] duration-150 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100',
105102
!expanded && '-rotate-90'
106103
)}
107104
/>
108105
</button>
109106
) : (
110107
<div className='flex items-center gap-2'>
111108
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
112-
{showDelegatingSpinner ? (
113-
<PillsRing className='size-[15px] text-[var(--text-icon)]' animate />
114-
) : (
115-
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
116-
)}
109+
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
117110
</div>
118-
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
111+
{isWorking ? (
112+
<ShimmerText className='text-sm'>{agentLabel}</ShimmerText>
113+
) : (
114+
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
115+
)}
119116
</div>
120117
)}
121118
{hasItems && (

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useMemo } from 'react'
2-
import { PillsRing } from '@sim/emcn'
2+
import { ShimmerText } from '@/components/ui'
33
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
44
import type { ToolCallStatus } from '../../../../types'
55
import { getToolIcon, resolveToolDisplayState } from '../../utils'
@@ -57,10 +57,29 @@ function Hyphen({ className }: { className?: string }) {
5757
)
5858
}
5959

60+
function CircleOutline({ className }: { className?: string }) {
61+
return (
62+
<svg
63+
width='16'
64+
height='16'
65+
viewBox='0 0 16 16'
66+
fill='none'
67+
xmlns='http://www.w3.org/2000/svg'
68+
className={className}
69+
>
70+
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
71+
</svg>
72+
)
73+
}
74+
6075
function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
6176
const display = resolveToolDisplayState(status)
6277
if (display === 'spinner') {
63-
return <PillsRing className='size-[15px] text-[var(--text-tertiary)]' animate />
78+
const Icon = getToolIcon(toolName)
79+
if (Icon) {
80+
return <Icon className='size-[15px] text-[var(--text-tertiary)]' />
81+
}
82+
return <CircleOutline className='size-[15px] text-[var(--text-tertiary)]' />
6483
}
6584
if (display === 'cancelled') {
6685
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
@@ -112,14 +131,21 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }:
112131
return `${verb} ${unescaped}`
113132
}, [toolName, streamingArgs])
114133

134+
const isExecuting = resolveToolDisplayState(status) === 'spinner'
135+
const title = liveWorkspaceFileTitle || displayTitle
136+
115137
return (
116138
<div className='flex items-center gap-[8px] pl-[24px]'>
117139
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
118140
<StatusIcon status={status} toolName={toolName} />
119141
</div>
120-
<span className='text-[13px] text-[var(--text-secondary)]'>
121-
{liveWorkspaceFileTitle || displayTitle}
122-
</span>
142+
{isExecuting ? (
143+
<ShimmerText className='text-[13px] [--shimmer-rest:var(--text-secondary)]'>
144+
{title}
145+
</ShimmerText>
146+
) : (
147+
<span className='text-[13px] text-[var(--text-secondary)]'>{title}</span>
148+
)}
123149
</div>
124150
)
125151
}

apps/sim/components/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ export {
1414
SelectTrigger,
1515
SelectValue,
1616
} from './select'
17+
export { ShimmerText } from './shimmer-text'
1718
export { ThinkingLoader, type ThinkingLoaderVariant } from './thinking-loader'
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Claude-style text shimmer: the text paints from a gradient with a light band
3+
* that sweeps across the glyphs via background-clip. This is the single source
4+
* of truth for the treatment — the ThinkingLoader label composes it. Under
5+
* reduced motion the sweep is replaced by a gentle opacity pulse in solid ink:
6+
* the shimmer conveys essential in-progress state, so it needs a vestibular-safe
7+
* fallback rather than none. Consumers whose resting text is not body ink set
8+
* `--shimmer-rest` to their resting color.
9+
*/
10+
.shimmer {
11+
background-image: linear-gradient(90deg, #4a4a4a 40%, #b0b0b0 50%, #4a4a4a 60%);
12+
background-size: 200% 100%;
13+
-webkit-background-clip: text;
14+
background-clip: text;
15+
color: transparent;
16+
animation: shimmer-sweep 2.2s linear infinite;
17+
}
18+
19+
:global(.dark) .shimmer {
20+
background-image: linear-gradient(90deg, #b9b9b9 40%, #f8f8f8 50%, #b9b9b9 60%);
21+
}
22+
23+
@keyframes shimmer-sweep {
24+
to {
25+
background-position: 200% 0;
26+
}
27+
}
28+
29+
@media (prefers-reduced-motion: reduce) {
30+
.shimmer,
31+
:global(.dark) .shimmer {
32+
background-image: none;
33+
-webkit-background-clip: initial;
34+
background-clip: initial;
35+
color: var(--shimmer-rest, var(--text-body));
36+
animation: shimmer-pulse 2.2s ease-in-out infinite;
37+
}
38+
}
39+
40+
@keyframes shimmer-pulse {
41+
50% {
42+
opacity: 0.55;
43+
}
44+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { cn } from '@sim/emcn'
2+
import styles from '@/components/ui/shimmer-text.module.css'
3+
4+
interface ShimmerTextProps {
5+
children: React.ReactNode
6+
className?: string
7+
}
8+
9+
/**
10+
* Sweeping-highlight shimmer over a text phrase — the same treatment as the
11+
* ThinkingLoader's "Thinking…" label, reusable on any active/streaming row.
12+
* Size and weight come from the consumer's className; the gradient replaces
13+
* the text color, so color classes are ignored while shimmering.
14+
*/
15+
export function ShimmerText({ children, className }: ShimmerTextProps) {
16+
return <span className={cn(styles.shimmer, className)}>{children}</span>
17+
}

apps/sim/components/ui/thinking-loader.module.css

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -92,23 +92,14 @@
9292
white-space: nowrap;
9393
}
9494

95-
/* Claude-style shimmer: the text paints from a gradient with a light band
96-
that sweeps across the glyphs via background-clip. */
95+
/* The sweeping-band treatment itself (gradient, timing, dark mode, reduced
96+
motion) is owned by the shared shimmer-text module; this class only adds the
97+
loader-scaled font sizing. Canonical normal weight per emcn rules: body text
98+
is 400, never medium. */
9799
.label {
100+
composes: shimmer from "./shimmer-text.module.css";
98101
font-size: var(--tl-label-size, 14px);
99-
/* Canonical normal weight (per emcn rules: body text is 400, never medium).
100-
Reads light and clean under the shimmer gradient on light surfaces. */
101102
font-weight: 400;
102-
background-image: linear-gradient(90deg, #4a4a4a 40%, #b0b0b0 50%, #4a4a4a 60%);
103-
background-size: 200% 100%;
104-
-webkit-background-clip: text;
105-
background-clip: text;
106-
color: transparent;
107-
animation: label-shimmer 2.2s linear infinite;
108-
}
109-
110-
:global(.dark) .label {
111-
background-image: linear-gradient(90deg, #b9b9b9 40%, #f8f8f8 50%, #b9b9b9 60%);
112103
}
113104

114105
/* Static label (shimmer off): the phrase in solid body ink, no gradient sweep. */
@@ -118,12 +109,6 @@
118109
color: var(--text-body);
119110
}
120111

121-
@keyframes label-shimmer {
122-
to {
123-
background-position: 200% 0;
124-
}
125-
}
126-
127112
/* Phrase crossfade — the incoming phrase rises and fades in while the outgoing
128113
one rises and fades out (stacked over it), so phrases rotate smoothly. */
129114
.labelStack {
@@ -366,7 +351,6 @@
366351

367352
@media (prefers-reduced-motion: reduce) {
368353
.frame *,
369-
.label,
370354
.labelIn {
371355
animation: none;
372356
}

0 commit comments

Comments
 (0)