diff --git a/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx b/js/console/src/components/ai/chatbot/multithread/MultiThreadViewerMessage.tsx
index b4273d827f..40c09c3b3d 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,8 @@ 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 ?? toolCallDisplayTitle(kind, toolName, args, isPending)
const subtitle = toolCallDisplaySubtitle(kind, toolName, args, content)
const label = customLabel ?? (
+ ) : undefined)
+ }
/>
)
const accordionProps = {
@@ -474,7 +481,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/ai/chatbot/toolCallDisplay.test.ts b/js/console/src/components/ai/chatbot/toolCallDisplay.test.ts
index f21e5ab88a..25976ef2e2 100644
--- a/js/console/src/components/ai/chatbot/toolCallDisplay.test.ts
+++ b/js/console/src/components/ai/chatbot/toolCallDisplay.test.ts
@@ -34,6 +34,19 @@ describe('toolCallDisplayTitle', () => {
expect(toolCallDisplayTitle('subagent', 'workbench_subagent')).toBe(
'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(
'python sandbox'
@@ -55,7 +68,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 4ba9a3b867..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 'subagent'
+ return isPending ? formatSubagentTitle(args) : 'subagent'
case 'subagent_result':
return 'result'
case 'enable_tools':
@@ -292,10 +293,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/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}
>
-
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 slideRowReverse = keyframes`
+ 0%, 12% { transform: translate(0, 0); }
+ 28%, 46% { transform: translate(calc(-1 * var(--span)), 0); }
+ 58%, 100% { transform: translate(0, 0); }
+`
+
+const wave = keyframes`
+ 0%, 72%, 100% { opacity: var(--off); transform: scale(0.7); }
+ 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 easeMotion = css`
+ @media (prefers-reduced-motion: reduce) {
+ animation-duration: calc(var(--motion, 1s) * 3) !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
+ $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;
+ --motion: ${$duration};
+ 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'};
+ ${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`
+ 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 {
+ 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;
+ }
+ `
+ }
+ `
+})
+
+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 === '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 === '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'
+ 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
+ variant?: AgentLoadingVariant
+ state?: AgentLoadingState
+ columns?: number
+ rows?: number
+} & ComponentPropsWithRef<'span'>) {
+ const { colors } = useTheme()
+ 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 (state === 'success' || state === 'error') {
+ return (
+
+ )
+ }
+
+ 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 reduceMotion = usePrefersReducedMotion()
+ 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) * (reduceMotion ? 0.3 : 1)
+ 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;
+ --motion: ${$duration};
+ 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;
+ ${easeMotion}
+ }
+
+ .pose-a {
+ animation-name: ${paused ? 'none' : dancePose};
+ animation-duration: ${$duration};
+ animation-timing-function: steps(1, end);
+ animation-iteration-count: infinite;
+ ${easeMotion}
+ }
+
+ .pose-b {
+ animation-name: ${paused ? 'none' : dancePoseAlt};
+ animation-duration: ${$duration};
+ animation-timing-function: steps(1, end);
+ animation-iteration-count: infinite;
+ ${easeMotion}
+ }
+
+ ${
+ $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;
+ --motion: ${$duration};
+ 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;
+ ${easeMotion}
+ }
+
+ .pose-six,
+ .pose-seven {
+ animation-duration: ${$duration};
+ animation-timing-function: steps(1, end);
+ animation-iteration-count: infinite;
+ ${easeMotion}
+ }
+
+ .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 a19ed47b08..63f1db45be 100644
--- a/js/design-system/src/index.ts
+++ b/js/design-system/src/index.ts
@@ -16,6 +16,11 @@ export {
type AccordionProps,
type AccordionCaret,
} from './components/Accordion'
+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
new file mode 100644
index 0000000000..6775495f35
--- /dev/null
+++ b/js/design-system/src/stories/AgentLoadingIcon.stories.tsx
@@ -0,0 +1,397 @@
+import { Div } from 'honorable'
+import type { Meta, StoryObj } from '@storybook/react'
+import { type ComponentProps, type ReactNode } from 'react'
+import styled, { css, keyframes, useTheme } from 'styled-components'
+
+import {
+ AgentLoadingIcon,
+ type AgentLoadingState,
+ type AgentLoadingVariant,
+} from '../components/AgentLoadingIcon'
+
+const VARIANTS: AgentLoadingVariant[] = [
+ 'cursor',
+ 'cursorWave',
+ 'cursorEq',
+ '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[]
+ error?: 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…',
+ ],
+ error: 'Failed to publish the canvas. Retry the subagent to continue.',
+ },
+]
+
+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;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation-duration: 7s;
+ }
+ `
+ }
+ `
+)
+
+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']};
+ `
+ }
+ `
+)
+
+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[]
+ states?: Partial>
+}) {
+ return (
+
+ {WORKBENCH_SUBAGENTS.map((agent) => {
+ const state = states?.[agent.id]
+ const showIcon = state != null || pending?.includes(agent.id)
+ const live = state ? isLiveState(state) : pending?.includes(agent.id)
+
+ return (
+
+
+ {showIcon && renderIcon(agent.id)}
+
+ {agent.name} subagent
+
+
+ {agent.lines.map((line) => (
+
+ {line}
+
+ ))}
+ {state === 'error' && agent.error && (
+ {agent.error}
+ )}
+
+ )
+ })}
+
+ )
+}
+
+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,
+ variant: 'cursor',
+ state: 'working',
+ },
+ render: (args) => (
+
+ ),
+}
+
+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) => (
+
+ ),
+}
+
+export const Whimsy: Story = {
+ args: {
+ size: 16,
+ variant: 'whimsy',
+ state: 'working',
+ },
+ render: (args) => (
+
+ ),
+}
+
+export const Paper: Story = {
+ args: {
+ size: 8,
+ variant: 'paper',
+ state: 'working',
+ },
+ render: (args) => (
+
+ ),
+}
+
+export const PaperLong: Story = {
+ args: {
+ size: 12,
+ variant: 'paperLong',
+ state: 'working',
+ },
+ render: (args) => (
+
+ ),
+}
+
+export const Aaron: Story = {
+ args: {
+ size: 18,
+ variant: 'aaron',
+ state: 'working',
+ },
+ render: (args) => (
+
+ ),
+}
+
+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}
+
+
+ ))
+ })}
+
+
+ )
+ },
+}