From 968fe674e2ba8e0dce4c57643623ed94140cf067 Mon Sep 17 00:00:00 2001 From: Anna Nguyen Date: Fri, 4 Sep 2026 09:49:08 -0400 Subject: [PATCH 1/7] Add a whimsical loading icon for waiting subagents. Gives pending subagent rows and workbench loading text a Cursor-like animated mark that can be iterated on in design. Co-authored-by: Cursor --- .../multithread/MultiThreadViewerMessage.tsx | 16 +++- .../src/components/utils/AILoadingText.tsx | 14 +-- .../src/components/AgentLoadingIcon.tsx | 88 +++++++++++++++++++ js/design-system/src/index.ts | 1 + .../src/stories/AgentLoadingIcon.stories.tsx | 28 ++++++ 5 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 js/design-system/src/components/AgentLoadingIcon.tsx create mode 100644 js/design-system/src/stories/AgentLoadingIcon.stories.tsx diff --git a/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx b/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx index b4273d827f..5beea6bec6 100644 --- a/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx +++ b/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx @@ -1,6 +1,7 @@ import { Accordion, AccordionItem, + AgentLoadingIcon, Code, Flex, getLastStringChild, @@ -92,7 +93,11 @@ export function SimpleToolCall({ const toolName = attributes?.tool?.name ?? '' const args = attributes?.tool?.arguments const kind = resolveToolCallKind(toolName, args) - const title = customTitle ?? toolCallDisplayTitle(kind, toolName, args) + const title = + customTitle ?? + (isPending && kind === 'subagent' + ? 'Waiting for subagent' + : toolCallDisplayTitle(kind, toolName, args)) const subtitle = toolCallDisplaySubtitle(kind, toolName, args, content) const label = customLabel ?? ( + ) : undefined) + } /> ) const accordionProps = { @@ -474,7 +484,7 @@ const AccordionLabelSC = styled.span(({ theme }) => ({ const ToolCallLineSC = styled.span(({ theme }) => ({ display: 'flex', - alignItems: 'baseline', + alignItems: 'center', gap: theme.spacing.xsmall, minWidth: 0, maxWidth: '100%', diff --git a/js/console/src/components/utils/AILoadingText.tsx b/js/console/src/components/utils/AILoadingText.tsx index ab3744b9e2..3f17c74a11 100644 --- a/js/console/src/components/utils/AILoadingText.tsx +++ b/js/console/src/components/utils/AILoadingText.tsx @@ -1,4 +1,4 @@ -import { Flex, FlexProps, LoaderIcon } from '@pluralsh/design-system' +import { AgentLoadingIcon, Flex, FlexProps } from '@pluralsh/design-system' import { Body2P } from 'components/utils/typography/Text' import { useWorkbenchJobActivityWhimseyTextQuery, @@ -42,17 +42,9 @@ export function AILoadingText({ gap="xsmall" {...props} > - ) { + const { colors } = useTheme() + const fill = (color ? colors[color] : undefined) ?? colors['icon-xlight'] + + return ( + + + + + + ) +} diff --git a/js/design-system/src/index.ts b/js/design-system/src/index.ts index 7c44badb08..d747728869 100644 --- a/js/design-system/src/index.ts +++ b/js/design-system/src/index.ts @@ -16,6 +16,7 @@ export { type AccordionProps, type AccordionCaret, } from './components/Accordion' +export { AgentLoadingIcon } from './components/AgentLoadingIcon' export { AnimatedDiv } from './components/AnimatedDiv' export { default as AppIcon } from './components/AppIcon' export { default as ArrowScroll } from './components/ArrowScroll' diff --git a/js/design-system/src/stories/AgentLoadingIcon.stories.tsx b/js/design-system/src/stories/AgentLoadingIcon.stories.tsx new file mode 100644 index 0000000000..ddbc43035e --- /dev/null +++ b/js/design-system/src/stories/AgentLoadingIcon.stories.tsx @@ -0,0 +1,28 @@ +import { Flex } from 'honorable' +import type { Meta, StoryObj } from '@storybook/react' + +import { AgentLoadingIcon } from '../components/AgentLoadingIcon' + +const meta = { + title: 'AgentLoadingIcon', + component: AgentLoadingIcon, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ( + + + Waiting for subagent + + ), + args: { + size: 16, + }, +} From 0a02c4d6c4ca8aa3034aecf74cd768e8af82a1d5 Mon Sep 17 00:00:00 2001 From: Anna Nguyen Date: Tue, 8 Sep 2026 10:02:36 -0400 Subject: [PATCH 2/7] Iterate subagent loading marks and chat titles. Adds Storybook variants (cursor, paper short/long, whimsy, aaron) so design can pick a mark, and labels pending tool rows as "{role} subagent" to match the workbench chat. Co-authored-by: Cursor --- .../multithread/MultiThreadViewerMessage.tsx | 5 +- .../ai/chatbot/toolCallDisplay.test.ts | 7 +- .../components/ai/chatbot/toolCallDisplay.tsx | 11 +- .../src/components/AgentLoadingIcon.tsx | 918 ++++++++++++++++-- js/design-system/src/index.ts | 6 +- .../src/stories/AgentLoadingIcon.stories.tsx | 322 +++++- 6 files changed, 1190 insertions(+), 79 deletions(-) diff --git a/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx b/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx index 5beea6bec6..6d228ce7fd 100644 --- a/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx +++ b/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx @@ -94,10 +94,7 @@ export function SimpleToolCall({ const args = attributes?.tool?.arguments const kind = resolveToolCallKind(toolName, args) const title = - customTitle ?? - (isPending && kind === 'subagent' - ? 'Waiting for subagent' - : toolCallDisplayTitle(kind, toolName, args)) + customTitle ?? toolCallDisplayTitle(kind, toolName, args) const subtitle = toolCallDisplaySubtitle(kind, toolName, args, content) const label = customLabel ?? ( { expect(toolCallDisplayTitle('subagent', 'workbench_subagent')).toBe( 'Subagent' ) + expect( + toolCallDisplayTitle('subagent', 'workbench_subagent', { + subagent: 'coding', + }) + ).toBe('Coding subagent') expect(toolCallDisplayTitle('read', 'Read')).toBe('Read') expect(toolCallDisplayTitle('python_sandbox', 'python_sandbox')).toBe( 'Python sandbox' @@ -55,7 +60,7 @@ describe('toolCallDisplaySubtitle', () => { subagent: 'infrastructure', prompt: 'Find CrashLoopBackOff pods in production', }) - ).toBe('Infrastructure · Find CrashLoopBackOff pods in production') + ).toBe('Find CrashLoopBackOff pods in production') }) it('prefers path and query over the raw tool name', () => { diff --git a/js/console/src/components/ai/chatbot/toolCallDisplay.tsx b/js/console/src/components/ai/chatbot/toolCallDisplay.tsx index 80f5eb4819..193b589165 100644 --- a/js/console/src/components/ai/chatbot/toolCallDisplay.tsx +++ b/js/console/src/components/ai/chatbot/toolCallDisplay.tsx @@ -211,7 +211,7 @@ export function toolCallDisplayTitle( case 'grep': return 'Grep' case 'subagent': - return 'Subagent' + return formatSubagentTitle(args) case 'subagent_result': return 'Result' case 'enable_tools': @@ -292,10 +292,13 @@ export function getSubagentPrompt(args?: ToolArguments): string { return typeof args.prompt === 'string' ? args.prompt : '' } -function formatSubagentSubtitle(args?: ToolArguments): string { +export function formatSubagentTitle(args?: ToolArguments): string { const role = startCase(getSubagentRole(args).replace(/[_-]+/g, ' ')) - const prompt = getSubagentPrompt(args) - return [role, prompt].filter(Boolean).join(' · ') + return role ? `${role} subagent` : 'Subagent' +} + +function formatSubagentSubtitle(args?: ToolArguments): string { + return getSubagentPrompt(args) } export function getCommand(toolName: string, args?: ToolArguments): string { diff --git a/js/design-system/src/components/AgentLoadingIcon.tsx b/js/design-system/src/components/AgentLoadingIcon.tsx index 7aab5555a8..9b8d393f53 100644 --- a/js/design-system/src/components/AgentLoadingIcon.tsx +++ b/js/design-system/src/components/AgentLoadingIcon.tsx @@ -1,88 +1,888 @@ -import { type ComponentPropsWithRef } from 'react' -import styled, { keyframes, useTheme } from 'styled-components' +import { + type ComponentPropsWithRef, + useCallback, + useEffect, + useRef, +} from 'react' +import styled, { css, keyframes, useTheme } from 'styled-components' import { SemanticColorKey } from '../theme/colors' -const spin = keyframes` - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +export type AgentLoadingVariant = + | 'cursor' + | 'slide' + | 'wave' + | 'pulse' + | 'whimsy' + | 'paper' + | 'paperLong' + | 'aaron' +export type AgentLoadingState = + | 'working' + | 'waiting' + | 'idle' + | 'success' + | 'error' + +const DOTS = 9 + +const CURSOR_FRAMES: number[][] = [ + [1, 2, 4], + [1, 2, 3, 4, 5], + [3, 6], + [2, 3, 5, 6, 8, 9], + [5, 7, 8, 9], + [1, 4, 5, 6, 7, 8, 9], + [1, 4, 7], + [1, 2, 4, 7, 8], +] + +function cursorDotFrames(cell: number) { + const lit = CURSOR_FRAMES.map((frame) => frame.includes(cell)) + const step = (on: boolean) => + `opacity: ${on ? 'var(--on)' : 'var(--off)'}; transform: scale(${on ? 1 : 0.42});` + + return keyframes` + 0%, 8% { ${step(lit[0])} } + 12.5%, 20.5% { ${step(lit[1])} } + 25%, 33% { ${step(lit[2])} } + 37.5%, 45.5% { ${step(lit[3])} } + 50%, 58% { ${step(lit[4])} } + 62.5%, 70.5% { ${step(lit[5])} } + 75%, 83% { ${step(lit[6])} } + 87.5%, 95.5% { ${step(lit[7])} } + 100% { ${step(lit[0])} } + ` +} + +const cursorCell = Array.from({ length: DOTS }, (_, i) => cursorDotFrames(i + 1)) + +const slideRow = keyframes` + 0%, 12% { transform: translate(0, 0); } + 28%, 46% { transform: translate(var(--span), 0); } + 58%, 100% { transform: translate(0, 0); } ` -const twinkle = keyframes` - 0%, - 80%, - 100% { - opacity: 0.22; - } - 40% { - opacity: 1; - } +const slideRowReverse = keyframes` + 0%, 12% { transform: translate(0, 0); } + 28%, 46% { transform: translate(calc(-1 * var(--span)), 0); } + 58%, 100% { transform: translate(0, 0); } ` -const AgentLoadingIconSC = styled.svg` - flex-shrink: 0; - display: block; - animation: ${spin} 3.2s linear infinite; +const wave = keyframes` + 0%, 72%, 100% { opacity: var(--off); transform: scale(0.7); } + 18% { opacity: var(--on); transform: scale(1); } +` - .dot { - animation: ${twinkle} 1.05s ease-in-out infinite; - } +const pulse = keyframes` + 0%, 100% { opacity: 0.28; transform: scale(0.78); } + 50% { opacity: var(--on); transform: scale(1); } +` - .dot:nth-child(2) { - animation-delay: 0.18s; - } +const AgentLoadingIconSC = styled.span<{ + $size: number + $dot: number + $span: number + $color: string + $duration: string + $variant: AgentLoadingVariant + $state: AgentLoadingState +}>(({ $size, $dot, $span, $color, $duration, $variant, $state }) => { + const paused = $state === 'idle' || $state === 'success' || $state === 'error' + const allOn = paused + + return css` + --on: 1; + --off: 0.16; + --span: ${$span}px; + display: inline-grid; + flex-shrink: 0; + width: ${$size}px; + height: ${$size}px; + grid-template-columns: repeat(3, ${$dot}px); + grid-template-rows: repeat(3, ${$dot}px); + justify-content: space-between; + align-content: space-between; + color: ${$color}; + + i { + display: block; + width: ${$dot}px; + height: ${$dot}px; + border-radius: 50%; + background: currentColor; + animation-duration: ${$duration}; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + animation-iteration-count: infinite; + animation-play-state: ${paused ? 'paused' : 'running'}; + } - .dot:nth-child(3) { - animation-delay: 0.36s; + ${$variant === 'cursor' && + cursorCell.map( + (frame, i) => css` + i:nth-child(${i + 1}) { + animation-name: ${allOn ? 'none' : frame}; + animation-timing-function: cubic-bezier(0.45, 0.05, 0.2, 1); + } + ` + )} + + ${$variant === 'slide' && + css` + i { + animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1); + opacity: var(--on); + } + i:nth-child(-n + 3) { + animation-name: ${allOn ? 'none' : slideRow}; + } + i:nth-child(n + 4):nth-child(-n + 6) { + animation-name: ${allOn ? 'none' : slideRowReverse}; + } + i:nth-child(n + 7) { + animation-name: ${allOn ? 'none' : slideRow}; + } + `} + + ${$variant === 'wave' && + css` + i { + animation-name: ${allOn ? 'none' : wave}; + } + ${Array.from( + { length: DOTS }, + (_, i) => css` + i:nth-child(${i + 1}) { + animation-delay: ${i * 0.07}s; + } + ` + )} + `} + + ${$variant === 'pulse' && + css` + i { + animation-name: ${allOn ? 'none' : pulse}; + } + i:nth-child(5) { + animation-delay: 0.18s; + } + `} + + ${$state === 'idle' && + css` + i { + opacity: 0.38; + } + `} + + ${$state === 'success' && + css` + i { + opacity: 1; + } + `} + + ${$state === 'error' && + css` + i { + opacity: 0.14; + } + i:nth-child(1), + i:nth-child(3), + i:nth-child(5), + i:nth-child(7), + i:nth-child(9) { + opacity: 1; + } + `} + ` +}) + +function stateColor( + state: AgentLoadingState, + colors: ReturnType['colors'], + color?: SemanticColorKey, + variant?: AgentLoadingVariant +): string { + if (state === 'success') return colors['icon-success'] + if (state === 'error') return colors['icon-danger'] + if (color) return colors[color] + if (variant === 'paper' || variant === 'paperLong') return colors['icon-primary'] + return colors['icon-xlight'] +} + +function stateDuration(state: AgentLoadingState, variant: AgentLoadingVariant) { + if (state === 'waiting') { + if (variant === 'cursor') return '8s' + if (variant === 'whimsy') return '1.8s' + if (variant === 'aaron') return '1.5s' + if (variant === 'paper' || variant === 'paperLong') return '1.6s' + return '2s' } -` + if (variant === 'cursor') return '5.8s' + if (variant === 'whimsy') return '1.05s' + if (variant === 'aaron') return '0.9s' + if (variant === 'paper' || variant === 'paperLong') return '1s' + if (variant === 'slide') return '1.8s' + if (variant === 'wave') return '1.35s' + if (variant === 'pulse') return '1.35s' + return '1.2s' +} export function AgentLoadingIcon({ size = 12, color, + variant = 'cursor', + state = 'working', + columns, + rows, ...props }: { size?: number color?: SemanticColorKey -} & ComponentPropsWithRef<'svg'>) { + variant?: AgentLoadingVariant + state?: AgentLoadingState + columns?: number + rows?: number +} & ComponentPropsWithRef<'span'>) { const { colors } = useTheme() - const fill = (color ? colors[color] : undefined) ?? colors['icon-xlight'] + const fill = stateColor(state, colors, color, variant) + const duration = stateDuration(state, variant) + const dot = Math.max(2, Math.round(size * 0.22)) + const span = (size - dot) / 2 + + if (variant === 'paper' || variant === 'paperLong') { + return ( + + ) + } + + if (variant === 'aaron') { + return ( + + ) + } + + if (variant === 'whimsy') { + return ( + + ) + } return ( - - - + {Array.from({ length: DOTS }, (_, i) => ( + + ))} ) } + +function cssColorToRgb(color: string): string { + const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i) + if (hex) { + let h = hex[1] + if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + const n = parseInt(h, 16) + return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}` + } + const rgb = color.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i + ) + if (rgb) return `${rgb[1]}, ${rgb[2]}, ${rgb[3]}` + return '52, 133, 249' +} + +type ColumnState = { + pos: Float32Array + speed: Float32Array + wavePhase: Float32Array +} + +function createColumns(count: number, wrap: number): ColumnState { + const pos = new Float32Array(count) + const speed = new Float32Array(count) + const wavePhase = new Float32Array(count) + for (let i = 0; i < count; i++) { + pos[i] = Math.random() * wrap + speed[i] = 0.85 + 0.3 * Math.random() + wavePhase[i] = Math.random() * Math.PI * 2 + } + return { pos, speed, wavePhase } +} + +const PaperRainSC = styled.span<{ $width: number; $height: number }>( + ({ $width, $height }) => css` + display: inline-flex; + flex-shrink: 0; + width: ${$width}px; + height: ${$height}px; + + canvas { + display: block; + width: 100%; + height: 100%; + } + ` +) + +function PaperRain({ + size, + color, + state, + columns = 3, + rows = 3, + ...props +}: { + size: number + color: string + state: AgentLoadingState + columns?: number + rows?: number +} & ComponentPropsWithRef<'span'>) { + const canvasRef = useRef(null) + const colsRef = useRef(null) + const timeRef = useRef(0) + const lastRef = useRef(-1) + const paused = state === 'idle' || state === 'success' || state === 'error' + const cellSize = Math.max(2, size / rows) + const wrap = rows + 4 + const width = columns * cellSize + const height = rows * cellSize + const speed = state === 'waiting' ? 0.75 : 1.2 + const rgb = cssColorToRgb(color) + + const draw = useCallback( + (now: number) => { + const canvas = canvasRef.current + if (!canvas) return + if (!colsRef.current || colsRef.current.pos.length !== columns) { + colsRef.current = createColumns(columns, wrap) + } + const cols = colsRef.current + const ctx = canvas.getContext('2d') + if (!ctx) return + + const dpr = window.devicePixelRatio || 1 + if (canvas.width !== width * dpr || canvas.height !== height * dpr) { + canvas.width = width * dpr + canvas.height = height * dpr + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + + const last = lastRef.current + lastRef.current = now + if (last < 0) return + const dt = (now - last) / (1000 / 60) + if (!paused) timeRef.current += dt + const t = timeRef.current + const radius = 0.35 * cellSize + const cx = 0.5 * cellSize + + for (let col = 0; col < columns; col++) { + if (!paused) { + cols.pos[col] += 0.12 * speed * cols.speed[col] * dt + cols.pos[col] %= wrap + } + const y = cols.pos[col] + if (!paused) { + cols.speed[col] = + 0.85 + 0.15 * Math.sin(-cols.wavePhase[col] + 0.02 * t) + } + const trail = 1.5 + 2.5 * (1 - (cols.speed[col] - 0.85) / 0.3) + const x = col * cellSize + cx + const idleAlpha = state === 'idle' ? 0.38 : 1 + + if (state === 'success') { + for (let row = 0; row < rows; row++) { + ctx.fillStyle = `rgba(${rgb}, 1)` + ctx.beginPath() + ctx.arc(x, row * cellSize + cx, radius, 0, Math.PI * 2) + ctx.fill() + } + continue + } + + if (state === 'error') { + for (let row = 0; row < rows; row++) { + if (row !== col && row !== rows - 1 - col) continue + ctx.fillStyle = `rgba(${rgb}, 1)` + ctx.beginPath() + ctx.arc(x, row * cellSize + cx, radius, 0, Math.PI * 2) + ctx.fill() + } + continue + } + + for (let row = 0; row < rows; row++) { + const dist = y - row + if (dist < 0 || dist >= trail) continue + const alpha = (1 - dist / trail) * idleAlpha + ctx.fillStyle = `rgba(${rgb}, ${alpha})` + ctx.beginPath() + ctx.arc(x, row * cellSize + cx, radius, 0, Math.PI * 2) + ctx.fill() + } + } + }, + [cellSize, columns, height, paused, rgb, rows, speed, state, width, wrap] + ) + + useEffect(() => { + let live = true + const tick = (now: number) => { + if (!live) return + draw(now) + frame = requestAnimationFrame(tick) + } + let frame = requestAnimationFrame(tick) + return () => { + live = false + cancelAnimationFrame(frame) + } + }, [draw]) + + return ( + + + + ) +} + +const BIRD_A: Array<[number, number]> = [ + [6, 1], + [7, 1], + [8, 1], + [5, 2], + [6, 2], + [8, 2], + [9, 2], + [5, 3], + [6, 3], + [7, 3], + [8, 3], + [9, 3], + [10, 3], + [1, 4], + [2, 4], + [4, 4], + [5, 4], + [6, 4], + [7, 4], + [8, 4], + [2, 5], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [8, 5], + [1, 6], + [2, 6], + [3, 6], + [4, 6], + [5, 6], + [6, 6], + [7, 6], + [2, 7], + [5, 7], + [7, 7], + [5, 8], + [7, 8], + [4, 9], + [8, 9], +] + +const BIRD_B: Array<[number, number]> = [ + [6, 1], + [7, 1], + [8, 1], + [2, 2], + [5, 2], + [6, 2], + [8, 2], + [9, 2], + [1, 3], + [2, 3], + [5, 3], + [6, 3], + [7, 3], + [8, 3], + [9, 3], + [10, 3], + [2, 4], + [3, 4], + [4, 4], + [5, 4], + [6, 4], + [7, 4], + [8, 4], + [3, 5], + [4, 5], + [5, 5], + [6, 5], + [7, 5], + [8, 5], + [1, 4], + [4, 6], + [5, 6], + [6, 6], + [7, 6], + [5, 7], + [7, 7], + [6, 8], + [7, 8], + [6, 9], + [8, 9], +] + +const danceHop = keyframes` + 0%, 12% { transform: translateX(-22%) scaleX(1); } + 13%, 37% { transform: translateX(22%) scaleX(1); } + 38%, 62% { transform: translateX(22%) scaleX(-1); } + 63%, 87% { transform: translateX(-22%) scaleX(-1); } + 88%, 100% { transform: translateX(-22%) scaleX(1); } +` + +const dancePose = keyframes` + 0%, 24% { opacity: 1; } + 25%, 49% { opacity: 0; } + 50%, 74% { opacity: 1; } + 75%, 100% { opacity: 0; } +` + +const dancePoseAlt = keyframes` + 0%, 24% { opacity: 0; } + 25%, 49% { opacity: 1; } + 50%, 74% { opacity: 0; } + 75%, 100% { opacity: 1; } +` + +const WhimsyBirdSC = styled.span<{ + $size: number + $color: string + $duration: string + $state: AgentLoadingState +}>(({ $size, $color, $duration, $state }) => { + const paused = $state === 'idle' || $state === 'success' || $state === 'error' + + return css` + display: inline-flex; + flex-shrink: 0; + width: ${$size}px; + height: ${$size}px; + color: ${$color}; + overflow: visible; + + svg { + width: 100%; + height: 100%; + overflow: visible; + animation-name: ${paused ? 'none' : danceHop}; + animation-duration: ${$duration}; + animation-timing-function: steps(1, end); + animation-iteration-count: infinite; + transform-origin: center; + } + + .pose-a { + animation-name: ${paused ? 'none' : dancePose}; + animation-duration: ${$duration}; + animation-timing-function: steps(1, end); + animation-iteration-count: infinite; + } + + .pose-b { + animation-name: ${paused ? 'none' : dancePoseAlt}; + animation-duration: ${$duration}; + animation-timing-function: steps(1, end); + animation-iteration-count: infinite; + } + + ${$state === 'idle' && + css` + .pose-b { + opacity: 0; + } + `} + + ${$state === 'success' && + css` + .pose-a { + opacity: 0; + } + .pose-b { + opacity: 1; + } + `} + + ${$state === 'error' && + css` + svg { + transform: rotate(90deg) translateY(8%); + } + .pose-b { + opacity: 0; + } + `} + ` +}) + +function PixelSprite({ pixels }: { pixels: Array<[number, number]> }) { + return ( + <> + {pixels.map(([x, y]) => ( + + ))} + + ) +} + +function WhimsyBird({ + size, + color, + duration, + state, + ...props +}: { + size: number + color: string + duration: string + state: AgentLoadingState +} & ComponentPropsWithRef<'span'>) { + return ( + + + + + + + + + + + ) +} + +// 6-7 dance: one hand up / other down, then the mirror. +const AARON_SIX: Array<[number, number]> = [ + [6, 1], + [7, 1], + [6, 2], + [7, 2], + [7, 3], + [7, 4], + [9, 4], + [10, 4], + [5, 5], + [7, 5], + [4, 6], + [7, 6], + [7, 7], + [8, 7], + [3, 8], + [4, 8], + [8, 8], + [8, 9], +] + +const AARON_SEVEN: Array<[number, number]> = [ + [4, 1], + [5, 1], + [4, 2], + [5, 2], + [4, 3], + [1, 4], + [2, 4], + [4, 4], + [4, 5], + [6, 5], + [4, 6], + [7, 6], + [3, 7], + [4, 7], + [3, 8], + [7, 8], + [8, 8], + [3, 9], +] + +const aaronBounce = keyframes` + 0%, 49% { transform: translate(-16%, 4%); } + 50%, 100% { transform: translate(16%, 4%); } +` + +const aaronPoseSix = keyframes` + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +` + +const aaronPoseSeven = keyframes` + 0%, 49% { opacity: 0; } + 50%, 100% { opacity: 1; } +` + +const AaronManSC = styled.span<{ + $size: number + $color: string + $duration: string + $state: AgentLoadingState +}>(({ $size, $color, $duration, $state }) => { + const paused = $state === 'idle' || $state === 'success' || $state === 'error' + + return css` + display: inline-flex; + flex-shrink: 0; + width: ${$size}px; + height: ${$size}px; + color: ${$color}; + overflow: visible; + + svg { + width: 100%; + height: 100%; + overflow: visible; + animation-name: ${paused ? 'none' : aaronBounce}; + animation-duration: ${$duration}; + animation-timing-function: steps(1, end); + animation-iteration-count: infinite; + transform-origin: center bottom; + } + + .pose-six, + .pose-seven { + animation-duration: ${$duration}; + animation-timing-function: steps(1, end); + animation-iteration-count: infinite; + } + + .pose-six { + animation-name: ${paused ? 'none' : aaronPoseSix}; + } + .pose-seven { + animation-name: ${paused ? 'none' : aaronPoseSeven}; + opacity: 0; + } + + ${$state === 'idle' && + css` + .pose-seven { + opacity: 0; + } + `} + + ${$state === 'success' && + css` + .pose-six { + opacity: 0; + } + .pose-seven { + opacity: 1; + } + `} + + ${$state === 'error' && + css` + svg { + transform: rotate(90deg) translateY(12%); + } + .pose-seven { + opacity: 0; + } + `} + ` +}) + +function AaronMan({ + size, + color, + duration, + state, + ...props +}: { + size: number + color: string + duration: string + state: AgentLoadingState +} & ComponentPropsWithRef<'span'>) { + return ( + + + + + + + + + + + ) +} + + + diff --git a/js/design-system/src/index.ts b/js/design-system/src/index.ts index d747728869..253c7da998 100644 --- a/js/design-system/src/index.ts +++ b/js/design-system/src/index.ts @@ -16,7 +16,11 @@ export { type AccordionProps, type AccordionCaret, } from './components/Accordion' -export { AgentLoadingIcon } from './components/AgentLoadingIcon' +export { + AgentLoadingIcon, + type AgentLoadingState, + type AgentLoadingVariant, +} from './components/AgentLoadingIcon' export { AnimatedDiv } from './components/AnimatedDiv' export { default as AppIcon } from './components/AppIcon' export { default as ArrowScroll } from './components/ArrowScroll' diff --git a/js/design-system/src/stories/AgentLoadingIcon.stories.tsx b/js/design-system/src/stories/AgentLoadingIcon.stories.tsx index ddbc43035e..35141803b1 100644 --- a/js/design-system/src/stories/AgentLoadingIcon.stories.tsx +++ b/js/design-system/src/stories/AgentLoadingIcon.stories.tsx @@ -1,28 +1,330 @@ -import { Flex } from 'honorable' +import { Div } from 'honorable' import type { Meta, StoryObj } from '@storybook/react' +import { type ReactNode } from 'react' +import styled, { css, keyframes, useTheme } from 'styled-components' -import { AgentLoadingIcon } from '../components/AgentLoadingIcon' +import { + AgentLoadingIcon, + type AgentLoadingState, + type AgentLoadingVariant, +} from '../components/AgentLoadingIcon' + +const VARIANTS: AgentLoadingVariant[] = [ + 'cursor', + 'slide', + 'wave', + 'pulse', + 'whimsy', + 'aaron', + 'paper', + 'paperLong', +] +const STATES: AgentLoadingState[] = [ + 'working', + 'waiting', + 'idle', + 'success', + 'error', +] const meta = { title: 'AgentLoadingIcon', component: AgentLoadingIcon, + argTypes: { + variant: { control: 'select', options: VARIANTS }, + state: { control: 'select', options: STATES }, + size: { control: { type: 'range', min: 8, max: 48, step: 1 } }, + columns: { control: { type: 'range', min: 2, max: 16, step: 1 } }, + rows: { control: { type: 'range', min: 2, max: 8, step: 1 } }, + }, } satisfies Meta export default meta type Story = StoryObj +const BoardSC = styled.div(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', + gap: theme.spacing.medium, + width: '100%', +})) + +const CellSC = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing.small, + padding: `${theme.spacing.small}px ${theme.spacing.medium}px`, + borderRadius: theme.borderRadiuses.medium, + background: theme.colors['fill-two'], + border: theme.borders['fill-two'], + color: theme.colors.text, + ...theme.partials.text.body2, +})) + +const LabelSC = styled.span(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: 2, + minWidth: 0, + '.name': { + color: theme.colors.text, + }, + '.meta': { + color: theme.colors['text-xlight'], + ...theme.partials.text.caption, + }, +})) + +type SubagentId = 'coding' | 'integration' | 'canvas' + +const WORKBENCH_SUBAGENTS: Array<{ + id: SubagentId + name: string + lines: string[] +}> = [ + { + id: 'coding', + name: 'Coding', + lines: [ + 'Created GitOps PR https://github.com/pluralsh/plrl-up-demos/pull/28…', + ], + }, + { + id: 'integration', + name: 'Integration', + lines: [ + 'Posted the requested follow-up comment on pluralsh/console#4104.', + 'Created the single-file GitOps deployment PR…', + 'Reported the GitOps deployment PR back to the source PR…', + ], + }, + { + id: 'canvas', + name: 'Canvas', + lines: [ + 'Created a minimal, status-focused demo deployment handoff canvas…', + ], + }, +] + +const shimmer = keyframes` + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +` + +const ChatSC = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing.medium, + maxWidth: 560, + padding: theme.spacing.medium, + background: theme.colors['fill-zero'], +})) + +const SubagentBlockSC = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: 4, +})) + +const TitleRowSC = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing.xsmall, +})) + +const TitleSC = styled.span<{ $pending?: boolean }>( + ({ theme, $pending }) => css` + ${theme.partials.text.body2Bold} + color: ${theme.colors['text-light']}; + + .kind { + font-weight: 400; + color: ${theme.colors['text-xlight']}; + } + + ${$pending && + css` + background: linear-gradient( + 90deg, + ${theme.colors['text-xlight']} 40%, + ${theme.colors.text} 50%, + ${theme.colors['text-xlight']} 60% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: ${shimmer} 2.4s linear infinite; + `} + ` +) + +const LineSC = styled.span<{ $pending?: boolean }>( + ({ theme, $pending }) => css` + ${theme.partials.text.body2} + color: ${theme.colors['text-xlight']}; + + ${$pending && + css` + color: ${theme.colors['text-disabled']}; + `} + ` +) + +function WorkbenchChatMock({ + renderIcon, + pending, +}: { + renderIcon: (agent: SubagentId) => ReactNode + pending: SubagentId[] +}) { + return ( + + {WORKBENCH_SUBAGENTS.map((agent) => { + const isPending = pending.includes(agent.id) + + return ( + + + {isPending && renderIcon(agent.id)} + + {agent.name} subagent + + + {agent.lines.map((line) => ( + + {line} + + ))} + + ) + })} + + ) +} + export const Default: Story = { + args: { + size: 12, + variant: 'cursor', + state: 'working', + }, render: (args) => ( - - - Waiting for subagent - + } + pending={['coding', 'integration', 'canvas']} + /> ), +} + +export const Whimsy: Story = { args: { size: 16, + variant: 'whimsy', + state: 'working', + }, + render: (args) => ( + } + pending={['coding']} + /> + ), +} + +export const Paper: Story = { + args: { + size: 12, + variant: 'paper', + state: 'working', + }, + render: (args) => ( + ( + + )} + pending={['coding', 'integration', 'canvas']} + /> + ), +} + +export const PaperLong: Story = { + args: { + size: 12, + variant: 'paperLong', + state: 'working', + }, + render: (args) => ( + ( + + )} + pending={['coding', 'integration', 'canvas']} + /> + ), +} + +export const Aaron: Story = { + args: { + size: 18, + variant: 'aaron', + state: 'working', + }, + render: (args) => ( + } + pending={['coding']} + /> + ), +} + +export const Gallery: Story = { + args: { + size: 16, + }, + + render: (args) => { + const theme = useTheme() + const size = args.size ?? 16 + + return ( +
+ + {VARIANTS.flatMap((variant) => { + const states = + variant === 'paper' || variant === 'paperLong' + ? (['working', 'waiting'] satisfies AgentLoadingState[]) + : STATES + + return states.map((state) => ( + + + + {variant} + {state} + + + )) + })} + +
+ ) }, } From 6d39d6943c07a12f8337b0372b606ec91d5fb8c3 Mon Sep 17 00:00:00 2001 From: Anna Nguyen Date: Thu, 10 Sep 2026 16:52:45 -0400 Subject: [PATCH 3/7] Iterate loading marks with equalizer, wave, and status bullets. Adds cursor wave and equalizer variants, and uses a single bullet for success and error so Storybook can compare completed and failed subagent rows. --- .../src/components/AgentLoadingIcon.tsx | 152 ++++++++++++++--- .../src/stories/AgentLoadingIcon.stories.tsx | 153 ++++++++++++------ 2 files changed, 239 insertions(+), 66 deletions(-) diff --git a/js/design-system/src/components/AgentLoadingIcon.tsx b/js/design-system/src/components/AgentLoadingIcon.tsx index 9b8d393f53..33803a3c5c 100644 --- a/js/design-system/src/components/AgentLoadingIcon.tsx +++ b/js/design-system/src/components/AgentLoadingIcon.tsx @@ -10,6 +10,8 @@ import { SemanticColorKey } from '../theme/colors' export type AgentLoadingVariant = | 'cursor' + | 'cursorWave' + | 'cursorEq' | 'slide' | 'wave' | 'pulse' @@ -74,11 +76,38 @@ const wave = keyframes` 18% { opacity: var(--on); transform: scale(1); } ` +const cursorWave = keyframes` + 0%, 100% { opacity: var(--off); transform: scale(0.42); } + 28% { opacity: var(--on); transform: scale(1); } + 48% { opacity: 0.42; transform: scale(0.7); } +` + const pulse = keyframes` 0%, 100% { opacity: 0.28; transform: scale(0.78); } 50% { opacity: var(--on); transform: scale(1); } ` +const eqLow = keyframes` + 0%, 100% { opacity: var(--on); transform: scale(1); } + 52% { opacity: 0.55; transform: scale(0.82); } +` + +const eqMid = keyframes` + 0%, 14% { opacity: var(--off); transform: scale(0.42); } + 24%, 62% { opacity: var(--on); transform: scale(1); } + 76%, 100% { opacity: var(--off); transform: scale(0.42); } +` + +const eqHigh = keyframes` + 0%, 32% { opacity: var(--off); transform: scale(0.42); } + 38%, 48% { opacity: var(--on); transform: scale(1); } + 58%, 100% { opacity: var(--off); transform: scale(0.42); } +` + +const EQ_COL_SPEED = [0.82, 1.18, 0.64] +const EQ_COL_DELAY = [0, 0.1, 0.22] +const EQ_ROW_FRAME = [eqHigh, eqMid, eqLow] + const AgentLoadingIconSC = styled.span<{ $size: number $dot: number @@ -127,6 +156,43 @@ const AgentLoadingIconSC = styled.span<{ ` )} + ${$variant === 'cursorWave' && + css` + i { + animation-name: ${allOn ? 'none' : cursorWave}; + animation-timing-function: cubic-bezier(0.45, 0.05, 0.2, 1); + } + ${Array.from({ length: DOTS }, (_, i) => { + const col = i % 3 + const row = Math.floor(i / 3) + + return css` + i:nth-child(${i + 1}) { + animation-delay: ${col * 0.14 + row * 0.06}s; + } + ` + })} + `} + + ${$variant === 'cursorEq' && + css` + i { + animation-timing-function: cubic-bezier(0.22, 0.7, 0.28, 1); + } + ${Array.from({ length: DOTS }, (_, i) => { + const col = i % 3 + const row = Math.floor(i / 3) + + return css` + i:nth-child(${i + 1}) { + animation-name: ${allOn ? 'none' : EQ_ROW_FRAME[row]}; + animation-duration: calc(${$duration} * ${EQ_COL_SPEED[col]}); + animation-delay: ${EQ_COL_DELAY[col]}s; + } + ` + })} + `} + ${$variant === 'slide' && css` i { @@ -176,36 +242,60 @@ const AgentLoadingIconSC = styled.span<{ } `} - ${$state === 'success' && - css` - i { - opacity: 1; - } - `} - - ${$state === 'error' && - css` - i { - opacity: 0.14; - } - i:nth-child(1), - i:nth-child(3), - i:nth-child(5), - i:nth-child(7), - i:nth-child(9) { - opacity: 1; - } - `} ` }) +const StatusBulletSC = styled.span<{ + $size: number + $dot: number + $color: string +}>(({ $size, $dot, $color }) => css` + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: ${$size}px; + height: ${$size}px; + color: ${$color}; + + i { + display: block; + width: ${$dot}px; + height: ${$dot}px; + border-radius: 50%; + background: currentColor; + } +`) + +function StatusBullet({ + size, + color, + ...props +}: { + size: number + color: string +} & ComponentPropsWithRef<'span'>) { + const dot = Math.max(3, Math.round(size * 0.32)) + + return ( + + + + ) +} + function stateColor( state: AgentLoadingState, colors: ReturnType['colors'], color?: SemanticColorKey, variant?: AgentLoadingVariant ): string { - if (state === 'success') return colors['icon-success'] if (state === 'error') return colors['icon-danger'] if (color) return colors[color] if (variant === 'paper' || variant === 'paperLong') return colors['icon-primary'] @@ -215,12 +305,16 @@ function stateColor( function stateDuration(state: AgentLoadingState, variant: AgentLoadingVariant) { if (state === 'waiting') { if (variant === 'cursor') return '8s' + if (variant === 'cursorWave') return '2.4s' + if (variant === 'cursorEq') return '2.6s' if (variant === 'whimsy') return '1.8s' if (variant === 'aaron') return '1.5s' if (variant === 'paper' || variant === 'paperLong') return '1.6s' return '2s' } if (variant === 'cursor') return '5.8s' + if (variant === 'cursorWave') return '1.5s' + if (variant === 'cursorEq') return '1.55s' if (variant === 'whimsy') return '1.05s' if (variant === 'aaron') return '0.9s' if (variant === 'paper' || variant === 'paperLong') return '1s' @@ -252,6 +346,22 @@ export function AgentLoadingIcon({ const dot = Math.max(2, Math.round(size * 0.22)) const span = (size - dot) / 2 + if (state === 'success' || state === 'error') { + return ( + + ) + } + if (variant === 'paper' || variant === 'paperLong') { return ( = [ { id: 'coding', @@ -104,6 +107,7 @@ const WORKBENCH_SUBAGENTS: Array<{ lines: [ 'Created a minimal, status-focused demo deployment handoff canvas…', ], + error: 'Failed to publish the canvas. Retry the subagent to continue.', }, ] @@ -143,20 +147,22 @@ const TitleSC = styled.span<{ $pending?: boolean }>( color: ${theme.colors['text-xlight']}; } - ${$pending && - css` - background: linear-gradient( - 90deg, - ${theme.colors['text-xlight']} 40%, - ${theme.colors.text} 50%, - ${theme.colors['text-xlight']} 60% - ); - background-size: 200% 100%; - background-clip: text; - -webkit-background-clip: text; - color: transparent; - animation: ${shimmer} 2.4s linear infinite; - `} + ${ + $pending && + css` + background: linear-gradient( + 90deg, + ${theme.colors['text-xlight']} 40%, + ${theme.colors.text} 50%, + ${theme.colors['text-xlight']} 60% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: ${shimmer} 2.4s linear infinite; + ` + } ` ) @@ -165,41 +171,59 @@ const LineSC = styled.span<{ $pending?: boolean }>( ${theme.partials.text.body2} color: ${theme.colors['text-xlight']}; - ${$pending && - css` - color: ${theme.colors['text-disabled']}; - `} + ${ + $pending && + css` + color: ${theme.colors['text-disabled']}; + ` + } ` ) +const ErrorSC = styled.span(({ theme }) => ({ + ...theme.partials.text.body2, + color: theme.colors['text-danger'], +})) + +function isLiveState(state?: AgentLoadingState) { + return state === 'working' || state === 'waiting' +} + function WorkbenchChatMock({ renderIcon, pending, + states, }: { renderIcon: (agent: SubagentId) => ReactNode - pending: SubagentId[] + pending?: SubagentId[] + states?: Partial> }) { return ( {WORKBENCH_SUBAGENTS.map((agent) => { - const isPending = pending.includes(agent.id) + const state = states?.[agent.id] + const showIcon = state != null || pending?.includes(agent.id) + const live = state ? isLiveState(state) : pending?.includes(agent.id) return ( - {isPending && renderIcon(agent.id)} - + {showIcon && renderIcon(agent.id)} + {agent.name} subagent {agent.lines.map((line) => ( {line} ))} + {state === 'error' && agent.error && ( + {agent.error} + )} ) })} @@ -207,6 +231,24 @@ function WorkbenchChatMock({ ) } +const PREVIEW_STATES: Record = { + coding: 'working', + integration: 'success', + canvas: 'error', +} + +function previewIcon( + args: ComponentProps, + states: Record = PREVIEW_STATES +) { + return (agent: SubagentId) => ( + + ) +} + export const Default: Story = { args: { size: 12, @@ -215,8 +257,39 @@ export const Default: Story = { }, render: (args) => ( } - pending={['coding', 'integration', 'canvas']} + renderIcon={previewIcon(args)} + states={PREVIEW_STATES} + /> + ), +} + +export const CursorWave: Story = { + args: { + size: 12, + variant: 'cursorWave', + state: 'waiting', + }, + render: (args) => ( + + ), +} + +export const CursorEq: Story = { + args: { + size: 12, + variant: 'cursorEq', + state: 'working', + }, + render: (args) => ( + ), } @@ -229,27 +302,22 @@ export const Whimsy: Story = { }, render: (args) => ( } - pending={['coding']} + renderIcon={previewIcon(args)} + states={PREVIEW_STATES} /> ), } export const Paper: Story = { args: { - size: 12, + size: 8, variant: 'paper', state: 'working', }, render: (args) => ( ( - - )} - pending={['coding', 'integration', 'canvas']} + renderIcon={previewIcon({ ...args, variant: 'paper' })} + states={PREVIEW_STATES} /> ), } @@ -262,13 +330,8 @@ export const PaperLong: Story = { }, render: (args) => ( ( - - )} - pending={['coding', 'integration', 'canvas']} + renderIcon={previewIcon({ ...args, variant: 'paperLong' })} + states={PREVIEW_STATES} /> ), } @@ -281,8 +344,8 @@ export const Aaron: Story = { }, render: (args) => ( } - pending={['coding']} + renderIcon={previewIcon(args)} + states={PREVIEW_STATES} /> ), } From 33e853fdfb935df52e5cdca4d6933f13645edd88 Mon Sep 17 00:00:00 2001 From: Anna Nguyen Date: Thu, 10 Sep 2026 17:04:07 -0400 Subject: [PATCH 4/7] Limit role-based subagent titles to pending calls. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completed rows keep the original “subagent” title; “Coding subagent” only shows while the call is waiting. --- .../chatbot/multithread/MultiThreadViewerMessage.tsx | 2 +- .../src/components/ai/chatbot/toolCallDisplay.test.ts | 10 +++++++++- .../src/components/ai/chatbot/toolCallDisplay.tsx | 5 +++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx b/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx index 6d228ce7fd..40c09c3b3d 100644 --- a/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx +++ b/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx @@ -94,7 +94,7 @@ export function SimpleToolCall({ const args = attributes?.tool?.arguments const kind = resolveToolCallKind(toolName, args) const title = - customTitle ?? toolCallDisplayTitle(kind, toolName, args) + customTitle ?? toolCallDisplayTitle(kind, toolName, args, isPending) const subtitle = toolCallDisplaySubtitle(kind, toolName, args, content) const label = customLabel ?? ( { describe('toolCallDisplayTitle', () => { it('uses Cursor-style verbs for common tools', () => { expect(toolCallDisplayTitle('subagent', 'workbench_subagent')).toBe( - 'Subagent' + 'subagent' ) expect( toolCallDisplayTitle('subagent', 'workbench_subagent', { subagent: 'coding', }) + ).toBe('subagent') + expect( + toolCallDisplayTitle( + 'subagent', + 'workbench_subagent', + { subagent: 'coding' }, + true + ) ).toBe('Coding subagent') expect(toolCallDisplayTitle('read', 'Read')).toBe('read') expect(toolCallDisplayTitle('python_sandbox', 'python_sandbox')).toBe( diff --git a/js/console/src/components/ai/chatbot/toolCallDisplay.tsx b/js/console/src/components/ai/chatbot/toolCallDisplay.tsx index f6493de46b..0af43162a4 100644 --- a/js/console/src/components/ai/chatbot/toolCallDisplay.tsx +++ b/js/console/src/components/ai/chatbot/toolCallDisplay.tsx @@ -191,7 +191,8 @@ export function toolCallGroupHeader( export function toolCallDisplayTitle( kind: ToolCallKind, toolName: string, - args?: ToolArguments + args?: ToolArguments, + isPending?: boolean ): string { switch (kind) { case 'command_execution': @@ -211,7 +212,7 @@ export function toolCallDisplayTitle( case 'grep': return 'grep' case 'subagent': - return formatSubagentTitle(args) + return isPending ? formatSubagentTitle(args) : 'subagent' case 'subagent_result': return 'result' case 'enable_tools': From cb2211d2a2285a9071365cf0548ac94acaf6de88 Mon Sep 17 00:00:00 2001 From: Anna Nguyen Date: Thu, 10 Sep 2026 17:07:36 -0400 Subject: [PATCH 5/7] Honor reduced motion on agent loading marks. Stops looping CSS and canvas motion when the user prefers reduced motion, and keeps a static first-frame mark instead. --- .../src/components/AgentLoadingIcon.tsx | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/js/design-system/src/components/AgentLoadingIcon.tsx b/js/design-system/src/components/AgentLoadingIcon.tsx index 33803a3c5c..5acc07373f 100644 --- a/js/design-system/src/components/AgentLoadingIcon.tsx +++ b/js/design-system/src/components/AgentLoadingIcon.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, + useState, } from 'react' import styled, { css, keyframes, useTheme } from 'styled-components' @@ -108,6 +109,28 @@ const EQ_COL_SPEED = [0.82, 1.18, 0.64] const EQ_COL_DELAY = [0, 0.1, 0.22] const EQ_ROW_FRAME = [eqHigh, eqMid, eqLow] +const stopMotion = css` + @media (prefers-reduced-motion: reduce) { + animation-name: none !important; + animation-play-state: paused !important; + } +` + +function usePrefersReducedMotion() { + const [reduced, setReduced] = useState(false) + + useEffect(() => { + const media = window.matchMedia('(prefers-reduced-motion: reduce)') + const onChange = () => setReduced(media.matches) + + onChange() + media.addEventListener('change', onChange) + return () => media.removeEventListener('change', onChange) + }, []) + + return reduced +} + const AgentLoadingIconSC = styled.span<{ $size: number $dot: number @@ -144,6 +167,20 @@ const AgentLoadingIconSC = styled.span<{ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); animation-iteration-count: infinite; animation-play-state: ${paused ? 'paused' : 'running'}; + ${stopMotion} + } + + @media (prefers-reduced-motion: reduce) { + i { + opacity: var(--off); + transform: scale(0.42); + } + i:nth-child(1), + i:nth-child(2), + i:nth-child(4) { + opacity: var(--on); + transform: scale(1); + } } ${$variant === 'cursor' && @@ -484,7 +521,12 @@ function PaperRain({ const colsRef = useRef(null) const timeRef = useRef(0) const lastRef = useRef(-1) - const paused = state === 'idle' || state === 'success' || state === 'error' + const reduceMotion = usePrefersReducedMotion() + const paused = + reduceMotion || + state === 'idle' || + state === 'success' || + state === 'error' const cellSize = Math.max(2, size / rows) const wrap = rows + 4 const width = columns * cellSize @@ -729,6 +771,7 @@ const WhimsyBirdSC = styled.span<{ animation-timing-function: steps(1, end); animation-iteration-count: infinite; transform-origin: center; + ${stopMotion} } .pose-a { @@ -736,6 +779,7 @@ const WhimsyBirdSC = styled.span<{ animation-duration: ${$duration}; animation-timing-function: steps(1, end); animation-iteration-count: infinite; + ${stopMotion} } .pose-b { @@ -743,6 +787,13 @@ const WhimsyBirdSC = styled.span<{ animation-duration: ${$duration}; animation-timing-function: steps(1, end); animation-iteration-count: infinite; + ${stopMotion} + } + + @media (prefers-reduced-motion: reduce) { + .pose-b { + opacity: 0; + } } ${$state === 'idle' && @@ -911,6 +962,7 @@ const AaronManSC = styled.span<{ animation-timing-function: steps(1, end); animation-iteration-count: infinite; transform-origin: center bottom; + ${stopMotion} } .pose-six, @@ -918,6 +970,13 @@ const AaronManSC = styled.span<{ animation-duration: ${$duration}; animation-timing-function: steps(1, end); animation-iteration-count: infinite; + ${stopMotion} + } + + @media (prefers-reduced-motion: reduce) { + .pose-seven { + opacity: 0; + } } .pose-six { From 208fb97c00383fce997d37ada7f186e79b05d798 Mon Sep 17 00:00:00 2001 From: Anna Nguyen Date: Thu, 10 Sep 2026 17:10:30 -0400 Subject: [PATCH 6/7] Slow loading marks and title shimmer under reduced motion. Keeps a quiet loop so waiting rows still look alive instead of frozen next to a shimmering header. --- .../src/components/utils/typography/Text.tsx | 3 ++ .../src/components/AgentLoadingIcon.tsx | 53 +++++-------------- .../src/stories/AgentLoadingIcon.stories.tsx | 4 ++ 3 files changed, 20 insertions(+), 40 deletions(-) diff --git a/js/console/src/components/utils/typography/Text.tsx b/js/console/src/components/utils/typography/Text.tsx index 557a324efd..023dc021a9 100644 --- a/js/console/src/components/utils/typography/Text.tsx +++ b/js/console/src/components/utils/typography/Text.tsx @@ -116,5 +116,8 @@ const sharedTextStyles = ({ backgroundClip: 'text', color: 'transparent', animation: `shimmer-text ${SHIMMER_DURATION} linear infinite`, + '@media (prefers-reduced-motion: reduce)': { + animationDuration: '6s', + }, }), }) diff --git a/js/design-system/src/components/AgentLoadingIcon.tsx b/js/design-system/src/components/AgentLoadingIcon.tsx index 5acc07373f..10027a93da 100644 --- a/js/design-system/src/components/AgentLoadingIcon.tsx +++ b/js/design-system/src/components/AgentLoadingIcon.tsx @@ -109,10 +109,9 @@ const EQ_COL_SPEED = [0.82, 1.18, 0.64] const EQ_COL_DELAY = [0, 0.1, 0.22] const EQ_ROW_FRAME = [eqHigh, eqMid, eqLow] -const stopMotion = css` +const easeMotion = css` @media (prefers-reduced-motion: reduce) { - animation-name: none !important; - animation-play-state: paused !important; + animation-duration: calc(var(--motion, 1s) * 3) !important; } ` @@ -147,6 +146,7 @@ const AgentLoadingIconSC = styled.span<{ --on: 1; --off: 0.16; --span: ${$span}px; + --motion: ${$duration}; display: inline-grid; flex-shrink: 0; width: ${$size}px; @@ -167,20 +167,7 @@ const AgentLoadingIconSC = styled.span<{ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); animation-iteration-count: infinite; animation-play-state: ${paused ? 'paused' : 'running'}; - ${stopMotion} - } - - @media (prefers-reduced-motion: reduce) { - i { - opacity: var(--off); - transform: scale(0.42); - } - i:nth-child(1), - i:nth-child(2), - i:nth-child(4) { - opacity: var(--on); - transform: scale(1); - } + ${easeMotion} } ${$variant === 'cursor' && @@ -522,16 +509,12 @@ function PaperRain({ const timeRef = useRef(0) const lastRef = useRef(-1) const reduceMotion = usePrefersReducedMotion() - const paused = - reduceMotion || - state === 'idle' || - state === 'success' || - state === 'error' + const paused = state === 'idle' || state === 'success' || state === 'error' const cellSize = Math.max(2, size / rows) const wrap = rows + 4 const width = columns * cellSize const height = rows * cellSize - const speed = state === 'waiting' ? 0.75 : 1.2 + const speed = (state === 'waiting' ? 0.75 : 1.2) * (reduceMotion ? 0.3 : 1) const rgb = cssColorToRgb(color) const draw = useCallback( @@ -759,6 +742,7 @@ const WhimsyBirdSC = styled.span<{ flex-shrink: 0; width: ${$size}px; height: ${$size}px; + --motion: ${$duration}; color: ${$color}; overflow: visible; @@ -771,7 +755,7 @@ const WhimsyBirdSC = styled.span<{ animation-timing-function: steps(1, end); animation-iteration-count: infinite; transform-origin: center; - ${stopMotion} + ${easeMotion} } .pose-a { @@ -779,7 +763,7 @@ const WhimsyBirdSC = styled.span<{ animation-duration: ${$duration}; animation-timing-function: steps(1, end); animation-iteration-count: infinite; - ${stopMotion} + ${easeMotion} } .pose-b { @@ -787,13 +771,7 @@ const WhimsyBirdSC = styled.span<{ animation-duration: ${$duration}; animation-timing-function: steps(1, end); animation-iteration-count: infinite; - ${stopMotion} - } - - @media (prefers-reduced-motion: reduce) { - .pose-b { - opacity: 0; - } + ${easeMotion} } ${$state === 'idle' && @@ -950,6 +928,7 @@ const AaronManSC = styled.span<{ flex-shrink: 0; width: ${$size}px; height: ${$size}px; + --motion: ${$duration}; color: ${$color}; overflow: visible; @@ -962,7 +941,7 @@ const AaronManSC = styled.span<{ animation-timing-function: steps(1, end); animation-iteration-count: infinite; transform-origin: center bottom; - ${stopMotion} + ${easeMotion} } .pose-six, @@ -970,13 +949,7 @@ const AaronManSC = styled.span<{ animation-duration: ${$duration}; animation-timing-function: steps(1, end); animation-iteration-count: infinite; - ${stopMotion} - } - - @media (prefers-reduced-motion: reduce) { - .pose-seven { - opacity: 0; - } + ${easeMotion} } .pose-six { diff --git a/js/design-system/src/stories/AgentLoadingIcon.stories.tsx b/js/design-system/src/stories/AgentLoadingIcon.stories.tsx index faa6940f2b..6775495f35 100644 --- a/js/design-system/src/stories/AgentLoadingIcon.stories.tsx +++ b/js/design-system/src/stories/AgentLoadingIcon.stories.tsx @@ -161,6 +161,10 @@ const TitleSC = styled.span<{ $pending?: boolean }>( -webkit-background-clip: text; color: transparent; animation: ${shimmer} 2.4s linear infinite; + + @media (prefers-reduced-motion: reduce) { + animation-duration: 7s; + } ` } ` From 68dfadc3686433a6b03ea5e707ec1a75fc203eb4 Mon Sep 17 00:00:00 2001 From: Anna Nguyen Date: Thu, 10 Sep 2026 17:22:35 -0400 Subject: [PATCH 7/7] Fix Prettier formatting in AgentLoadingIcon. Unblocks CI JavaScript lint, which failed on design-system lint:format for this file. --- .../src/components/AgentLoadingIcon.tsx | 351 ++++++++++-------- 1 file changed, 186 insertions(+), 165 deletions(-) diff --git a/js/design-system/src/components/AgentLoadingIcon.tsx b/js/design-system/src/components/AgentLoadingIcon.tsx index 10027a93da..57594665d4 100644 --- a/js/design-system/src/components/AgentLoadingIcon.tsx +++ b/js/design-system/src/components/AgentLoadingIcon.tsx @@ -21,11 +21,7 @@ export type AgentLoadingVariant = | 'paperLong' | 'aaron' export type AgentLoadingState = - | 'working' - | 'waiting' - | 'idle' - | 'success' - | 'error' + 'working' | 'waiting' | 'idle' | 'success' | 'error' const DOTS = 9 @@ -58,7 +54,9 @@ function cursorDotFrames(cell: number) { ` } -const cursorCell = Array.from({ length: DOTS }, (_, i) => cursorDotFrames(i + 1)) +const cursorCell = Array.from({ length: DOTS }, (_, i) => + cursorDotFrames(i + 1) +) const slideRow = keyframes` 0%, 12% { transform: translate(0, 0); } @@ -170,102 +168,115 @@ const AgentLoadingIconSC = styled.span<{ ${easeMotion} } - ${$variant === 'cursor' && - cursorCell.map( - (frame, i) => css` - i:nth-child(${i + 1}) { - animation-name: ${allOn ? 'none' : frame}; - animation-timing-function: cubic-bezier(0.45, 0.05, 0.2, 1); - } - ` - )} - - ${$variant === 'cursorWave' && - css` - i { - animation-name: ${allOn ? 'none' : cursorWave}; - animation-timing-function: cubic-bezier(0.45, 0.05, 0.2, 1); - } - ${Array.from({ length: DOTS }, (_, i) => { - const col = i % 3 - const row = Math.floor(i / 3) - - return css` + ${ + $variant === 'cursor' && + cursorCell.map( + (frame, i) => css` i:nth-child(${i + 1}) { - animation-delay: ${col * 0.14 + row * 0.06}s; + animation-name: ${allOn ? 'none' : frame}; + animation-timing-function: cubic-bezier(0.45, 0.05, 0.2, 1); } ` - })} - `} + ) + } - ${$variant === 'cursorEq' && - css` - i { - animation-timing-function: cubic-bezier(0.22, 0.7, 0.28, 1); - } - ${Array.from({ length: DOTS }, (_, i) => { - const col = i % 3 - const row = Math.floor(i / 3) + ${ + $variant === 'cursorWave' && + css` + i { + animation-name: ${allOn ? 'none' : cursorWave}; + animation-timing-function: cubic-bezier(0.45, 0.05, 0.2, 1); + } + ${Array.from({ length: DOTS }, (_, i) => { + const col = i % 3 + const row = Math.floor(i / 3) + + return css` + i:nth-child(${i + 1}) { + animation-delay: ${col * 0.14 + row * 0.06}s; + } + ` + })} + ` + } - return css` - i:nth-child(${i + 1}) { - animation-name: ${allOn ? 'none' : EQ_ROW_FRAME[row]}; - animation-duration: calc(${$duration} * ${EQ_COL_SPEED[col]}); - animation-delay: ${EQ_COL_DELAY[col]}s; - } - ` - })} - `} - - ${$variant === 'slide' && - css` - i { - animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1); - opacity: var(--on); - } - i:nth-child(-n + 3) { - animation-name: ${allOn ? 'none' : slideRow}; - } - i:nth-child(n + 4):nth-child(-n + 6) { - animation-name: ${allOn ? 'none' : slideRowReverse}; - } - i:nth-child(n + 7) { - animation-name: ${allOn ? 'none' : slideRow}; - } - `} + ${ + $variant === 'cursorEq' && + css` + i { + animation-timing-function: cubic-bezier(0.22, 0.7, 0.28, 1); + } + ${Array.from({ length: DOTS }, (_, i) => { + const col = i % 3 + const row = Math.floor(i / 3) + + return css` + i:nth-child(${i + 1}) { + animation-name: ${allOn ? 'none' : EQ_ROW_FRAME[row]}; + animation-duration: calc(${$duration} * ${EQ_COL_SPEED[col]}); + animation-delay: ${EQ_COL_DELAY[col]}s; + } + ` + })} + ` + } - ${$variant === 'wave' && - css` - i { - animation-name: ${allOn ? 'none' : wave}; - } - ${Array.from( - { length: DOTS }, - (_, i) => css` - i:nth-child(${i + 1}) { - animation-delay: ${i * 0.07}s; - } - ` - )} - `} + ${ + $variant === 'slide' && + css` + i { + animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1); + opacity: var(--on); + } + i:nth-child(-n + 3) { + animation-name: ${allOn ? 'none' : slideRow}; + } + i:nth-child(n + 4):nth-child(-n + 6) { + animation-name: ${allOn ? 'none' : slideRowReverse}; + } + i:nth-child(n + 7) { + animation-name: ${allOn ? 'none' : slideRow}; + } + ` + } - ${$variant === 'pulse' && - css` - i { - animation-name: ${allOn ? 'none' : pulse}; - } - i:nth-child(5) { - animation-delay: 0.18s; - } - `} + ${ + $variant === 'wave' && + css` + i { + animation-name: ${allOn ? 'none' : wave}; + } + ${Array.from( + { length: DOTS }, + (_, i) => css` + i:nth-child(${i + 1}) { + animation-delay: ${i * 0.07}s; + } + ` + )} + ` + } - ${$state === 'idle' && - css` - i { - opacity: 0.38; - } - `} + ${ + $variant === 'pulse' && + css` + i { + animation-name: ${allOn ? 'none' : pulse}; + } + i:nth-child(5) { + animation-delay: 0.18s; + } + ` + } + ${ + $state === 'idle' && + css` + i { + opacity: 0.38; + } + ` + } ` }) @@ -273,23 +284,25 @@ const StatusBulletSC = styled.span<{ $size: number $dot: number $color: string -}>(({ $size, $dot, $color }) => css` - display: inline-flex; - flex-shrink: 0; - align-items: center; - justify-content: center; - width: ${$size}px; - height: ${$size}px; - color: ${$color}; - - i { - display: block; - width: ${$dot}px; - height: ${$dot}px; - border-radius: 50%; - background: currentColor; - } -`) +}>( + ({ $size, $dot, $color }) => css` + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: ${$size}px; + height: ${$size}px; + color: ${$color}; + + i { + display: block; + width: ${$dot}px; + height: ${$dot}px; + border-radius: 50%; + background: currentColor; + } + ` +) function StatusBullet({ size, @@ -322,7 +335,8 @@ function stateColor( ): string { if (state === 'error') return colors['icon-danger'] if (color) return colors[color] - if (variant === 'paper' || variant === 'paperLong') return colors['icon-primary'] + if (variant === 'paper' || variant === 'paperLong') + return colors['icon-primary'] return colors['icon-xlight'] } @@ -450,9 +464,7 @@ function cssColorToRgb(color: string): string { const n = parseInt(h, 16) return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}` } - const rgb = color.match( - /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i - ) + const rgb = color.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i) if (rgb) return `${rgb[1]}, ${rgb[2]}, ${rgb[3]}` return '52, 133, 249' } @@ -774,32 +786,38 @@ const WhimsyBirdSC = styled.span<{ ${easeMotion} } - ${$state === 'idle' && - css` - .pose-b { - opacity: 0; - } - `} + ${ + $state === 'idle' && + css` + .pose-b { + opacity: 0; + } + ` + } - ${$state === 'success' && - css` - .pose-a { - opacity: 0; - } - .pose-b { - opacity: 1; - } - `} + ${ + $state === 'success' && + css` + .pose-a { + opacity: 0; + } + .pose-b { + opacity: 1; + } + ` + } - ${$state === 'error' && - css` - svg { - transform: rotate(90deg) translateY(8%); - } - .pose-b { - opacity: 0; - } - `} + ${ + $state === 'error' && + css` + svg { + transform: rotate(90deg) translateY(8%); + } + .pose-b { + opacity: 0; + } + ` + } ` }) @@ -960,32 +978,38 @@ const AaronManSC = styled.span<{ opacity: 0; } - ${$state === 'idle' && - css` - .pose-seven { - opacity: 0; - } - `} + ${ + $state === 'idle' && + css` + .pose-seven { + opacity: 0; + } + ` + } - ${$state === 'success' && - css` - .pose-six { - opacity: 0; - } - .pose-seven { - opacity: 1; - } - `} + ${ + $state === 'success' && + css` + .pose-six { + opacity: 0; + } + .pose-seven { + opacity: 1; + } + ` + } - ${$state === 'error' && - css` - svg { - transform: rotate(90deg) translateY(12%); - } - .pose-seven { - opacity: 0; - } - `} + ${ + $state === 'error' && + css` + svg { + transform: rotate(90deg) translateY(12%); + } + .pose-seven { + opacity: 0; + } + ` + } ` }) @@ -1025,6 +1049,3 @@ function AaronMan({ ) } - - -