mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+17
-20
@@ -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 && (
|
||||
|
||||
+31
-5
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user