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
This commit is contained in:
Waleed
2026-07-11 18:41:06 -07:00
committed by GitHub
parent fcf4e02930
commit b629292d5b
6 changed files with 115 additions and 46 deletions
@@ -1,7 +1,8 @@
'use client'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown, cn, Expandable, ExpandableContent, PillsRing } from '@sim/emcn'
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { ShimmerText } from '@/components/ui'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon, isToolDone } from '../../utils'
import { ToolCallItem } from './tool-call-item'
@@ -63,11 +64,7 @@ export function AgentGroup({
const AgentIcon = getAgentIcon(agentName)
const hasItems = items.length > 0
const resolved = isAgentGroupResolved(items)
// Pure projection of the run's own state: a subagent header spins while it is
// delegating with no resolved work yet. A terminal turn closes the lane (its
// subagent block is stamped ended), which clears `isDelegating`, so no
// transport gating is needed to stop an aborted-before-first-tool spinner.
const showDelegatingSpinner = isDelegating && !resolved
const isWorking = (isDelegating && !resolved) || (isStreaming && isLaneOpen)
// Expand while the turn is live and any of: the lane is open (the subagent is
// actively running), this is the current/latest section, or there is unresolved
@@ -89,19 +86,19 @@ export function AgentGroup({
<button
type='button'
onClick={() => setManualExpanded(!expanded)}
className='flex cursor-pointer items-center gap-2'
className='group/agent flex cursor-pointer items-center gap-2'
>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
{showDelegatingSpinner ? (
<PillsRing className='size-[15px] text-[var(--text-icon)]' animate />
) : (
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
)}
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
</div>
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
{isWorking ? (
<ShimmerText className='text-sm'>{agentLabel}</ShimmerText>
) : (
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
)}
<ChevronDown
className={cn(
'h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150',
'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',
!expanded && '-rotate-90'
)}
/>
@@ -109,13 +106,13 @@ export function AgentGroup({
) : (
<div className='flex items-center gap-2'>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
{showDelegatingSpinner ? (
<PillsRing className='size-[15px] text-[var(--text-icon)]' animate />
) : (
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
)}
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
</div>
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
{isWorking ? (
<ShimmerText className='text-sm'>{agentLabel}</ShimmerText>
) : (
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
)}
</div>
)}
{hasItems && (
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { PillsRing } from '@sim/emcn'
import { ShimmerText } from '@/components/ui'
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import type { ToolCallStatus } from '../../../../types'
import { getToolIcon, resolveToolDisplayState } from '../../utils'
@@ -57,10 +57,29 @@ function Hyphen({ className }: { className?: string }) {
)
}
function CircleOutline({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
</svg>
)
}
function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
const display = resolveToolDisplayState(status)
if (display === 'spinner') {
return <PillsRing className='size-[15px] text-[var(--text-tertiary)]' animate />
const Icon = getToolIcon(toolName)
if (Icon) {
return <Icon className='size-[15px] text-[var(--text-tertiary)]' />
}
return <CircleOutline className='size-[15px] text-[var(--text-tertiary)]' />
}
if (display === 'cancelled') {
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
@@ -112,14 +131,21 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }:
return `${verb} ${unescaped}`
}, [toolName, streamingArgs])
const isExecuting = resolveToolDisplayState(status) === 'spinner'
const title = liveWorkspaceFileTitle || displayTitle
return (
<div className='flex items-center gap-[8px] pl-[24px]'>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<StatusIcon status={status} toolName={toolName} />
</div>
<span className='text-[13px] text-[var(--text-secondary)]'>
{liveWorkspaceFileTitle || displayTitle}
</span>
{isExecuting ? (
<ShimmerText className='text-[13px] [--shimmer-rest:var(--text-secondary)]'>
{title}
</ShimmerText>
) : (
<span className='text-[13px] text-[var(--text-secondary)]'>{title}</span>
)}
</div>
)
}
+1
View File
@@ -14,4 +14,5 @@ export {
SelectTrigger,
SelectValue,
} from './select'
export { ShimmerText } from './shimmer-text'
export { ThinkingLoader, type ThinkingLoaderVariant } from './thinking-loader'
@@ -0,0 +1,44 @@
/**
* Claude-style text shimmer: the text paints from a gradient with a light band
* that sweeps across the glyphs via background-clip. This is the single source
* of truth for the treatment — the ThinkingLoader label composes it. Under
* reduced motion the sweep is replaced by a gentle opacity pulse in solid ink:
* the shimmer conveys essential in-progress state, so it needs a vestibular-safe
* fallback rather than none. Consumers whose resting text is not body ink set
* `--shimmer-rest` to their resting color.
*/
.shimmer {
background-image: linear-gradient(90deg, #4a4a4a 40%, #b0b0b0 50%, #4a4a4a 60%);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer-sweep 2.2s linear infinite;
}
:global(.dark) .shimmer {
background-image: linear-gradient(90deg, #b9b9b9 40%, #f8f8f8 50%, #b9b9b9 60%);
}
@keyframes shimmer-sweep {
to {
background-position: 200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.shimmer,
:global(.dark) .shimmer {
background-image: none;
-webkit-background-clip: initial;
background-clip: initial;
color: var(--shimmer-rest, var(--text-body));
animation: shimmer-pulse 2.2s ease-in-out infinite;
}
}
@keyframes shimmer-pulse {
50% {
opacity: 0.55;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { cn } from '@sim/emcn'
import styles from '@/components/ui/shimmer-text.module.css'
interface ShimmerTextProps {
children: React.ReactNode
className?: string
}
/**
* Sweeping-highlight shimmer over a text phrase — the same treatment as the
* ThinkingLoader's "Thinking…" label, reusable on any active/streaming row.
* Size and weight come from the consumer's className; the gradient replaces
* the text color, so color classes are ignored while shimmering.
*/
export function ShimmerText({ children, className }: ShimmerTextProps) {
return <span className={cn(styles.shimmer, className)}>{children}</span>
}
@@ -92,23 +92,14 @@
white-space: nowrap;
}
/* Claude-style shimmer: the text paints from a gradient with a light band
that sweeps across the glyphs via background-clip. */
/* The sweeping-band treatment itself (gradient, timing, dark mode, reduced
motion) is owned by the shared shimmer-text module; this class only adds the
loader-scaled font sizing. Canonical normal weight per emcn rules: body text
is 400, never medium. */
.label {
composes: shimmer from "./shimmer-text.module.css";
font-size: var(--tl-label-size, 14px);
/* Canonical normal weight (per emcn rules: body text is 400, never medium).
Reads light and clean under the shimmer gradient on light surfaces. */
font-weight: 400;
background-image: linear-gradient(90deg, #4a4a4a 40%, #b0b0b0 50%, #4a4a4a 60%);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: label-shimmer 2.2s linear infinite;
}
:global(.dark) .label {
background-image: linear-gradient(90deg, #b9b9b9 40%, #f8f8f8 50%, #b9b9b9 60%);
}
/* Static label (shimmer off): the phrase in solid body ink, no gradient sweep. */
@@ -118,12 +109,6 @@
color: var(--text-body);
}
@keyframes label-shimmer {
to {
background-position: 200% 0;
}
}
/* Phrase crossfade — the incoming phrase rises and fades in while the outgoing
one rises and fades out (stacked over it), so phrases rotate smoothly. */
.labelStack {
@@ -366,7 +351,6 @@
@media (prefers-reduced-motion: reduce) {
.frame *,
.label,
.labelIn {
animation: none;
}