Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -89,33 +86,33 @@ 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(
Comment thread
waleedlatif1 marked this conversation as resolved.
'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'
)}
/>
</button>
) : (
<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 && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)]' />
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
}
if (display === 'cancelled') {
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
Expand Down Expand Up @@ -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 change: 1 addition & 0 deletions apps/sim/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export {
SelectTrigger,
SelectValue,
} from './select'
export { ShimmerText } from './shimmer-text'
export { ThinkingLoader, type ThinkingLoaderVariant } from './thinking-loader'
44 changes: 44 additions & 0 deletions apps/sim/components/ui/shimmer-text.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.

@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;
}
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
}

@keyframes shimmer-pulse {
50% {
opacity: 0.55;
}
Comment thread
waleedlatif1 marked this conversation as resolved.
}
17 changes: 17 additions & 0 deletions apps/sim/components/ui/shimmer-text.tsx
Original file line number Diff line number Diff line change
@@ -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>
}
26 changes: 5 additions & 21 deletions apps/sim/components/ui/thinking-loader.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
Expand Down Expand Up @@ -366,7 +351,6 @@

@media (prefers-reduced-motion: reduce) {
.frame *,
.label,
.labelIn {
animation: none;
}
Expand Down
Loading