Files
simstudioai--sim/apps/sim/components/ui/thinking-loader.tsx
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

449 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { type CSSProperties, type ReactNode, useEffect, useId, useState } from 'react'
import { cn } from '@sim/emcn'
import styles from '@/components/ui/thinking-loader.module.css'
const VARIANTS = [
'metaballs',
'relay',
'corners',
'burst',
'compass',
'squeeze',
'thinking',
'orb',
] as const
export type ThinkingLoaderVariant = (typeof VARIANTS)[number]
/**
* Shapes used in the random morph cycle. `orb` (the solid terminal circle) is
* excluded — it is only reachable by pinning `variant='orb'` or via `settle`,
* so the cycle never lands on it mid-stream.
*/
const CYCLE_VARIANTS = VARIANTS.filter((v) => v !== 'orb')
/**
* Deterministic integer hash — turns a step index into a spread-out pseudo-
* random number. Used to pick both the shape and its hold time per step, so the
* order and pacing look unpredictable (any shape can follow any other, some
* linger, some pass quickly) while staying a pure function of the clock — every
* loader instance lands on the same shape at the same time.
*/
function hashStep(n: number): number {
let x = n | 0
x = Math.imul((x >>> 16) ^ x, 0x45d9f3b)
x = Math.imul((x >>> 16) ^ x, 0x45d9f3b)
return ((x >>> 16) ^ x) >>> 0
}
/**
* Common multiple of every shape animation period (800/1000/1200/2000ms,
* alternates doubled) — the wall-clock modulus for the shared negative
* animation-delay that phase-locks instances mounted at different times.
*/
const SYNC_PERIOD_MS = 12_000
/**
* A super-cycle of steps, each holding a pseudo-random shape for a pseudo-random
* duration (≈1.12.6s), so the morph pacing feels natural — some shapes linger,
* some pass quickly — instead of a metronome. Deterministic, so instances stay
* in lockstep; long enough not to read as a loop.
*/
const CYCLE_STEPS = 16
const STEP_MIN_MS = 1100
const STEP_RANGE_MS = 1500
const STEP_DURATIONS = Array.from(
{ length: CYCLE_STEPS },
(_, i) => STEP_MIN_MS + (hashStep(i + 1000) % STEP_RANGE_MS)
)
const SUPER_PERIOD_MS = STEP_DURATIONS.reduce((sum, d) => sum + d, 0)
/** The shape for a given step — pseudo-random, never an immediate repeat. */
function variantForStep(i: number): ThinkingLoaderVariant {
const idx = hashStep(i) % CYCLE_VARIANTS.length
const prevIdx = hashStep((i - 1 + CYCLE_STEPS) % CYCLE_STEPS) % CYCLE_VARIANTS.length
return CYCLE_VARIANTS[idx === prevIdx ? (idx + 1) % CYCLE_VARIANTS.length : idx]
}
/**
* The shape the shared timeline is on right now, and how long until the next.
* Walks the super-cycle's varied step durations — a pure function of the wall
* clock, so instances stay in lockstep.
*/
function variantAtNow(): { variant: ThinkingLoaderVariant; msUntilNext: number } {
let t = Date.now() % SUPER_PERIOD_MS
for (let i = 0; i < CYCLE_STEPS; i++) {
if (t < STEP_DURATIONS[i]) {
return { variant: variantForStep(i), msUntilNext: STEP_DURATIONS[i] - t }
}
t -= STEP_DURATIONS[i]
}
return { variant: variantForStep(0), msUntilNext: STEP_DURATIONS[0] }
}
/**
* Ink shapes per variant, authored in the shared 100x100 viewBox.
* Geometry mirrors the intrinsic CSS loaders these were adapted from,
* contain-fit to the canvas. Animations live in the CSS module.
*/
const VARIANT_SHAPES: Record<ThinkingLoaderVariant, ReactNode> = {
metaballs: (
<>
<circle className={styles.metaballsA} cx='22' cy='50' r='16' />
<circle className={styles.metaballsB} cx='78' cy='50' r='16' />
</>
),
relay: (
<>
<rect x='13' y='28' width='16' height='44' />
<rect x='71' y='28' width='16' height='44' />
<circle className={styles.relayBall} cx='21' cy='50' r='14' />
</>
),
corners: (
<>
<rect x='27' y='27' width='46' height='46' />
<circle className={styles.cornersA} cx='27' cy='27' r='14' />
<circle className={styles.cornersB} cx='73' cy='27' r='14' />
<circle className={styles.cornersC} cx='73' cy='73' r='14' />
<circle className={styles.cornersD} cx='27' cy='73' r='14' />
</>
),
// burst (Thinking) — four dots fling out of a center cross and are swallowed
// at the window edge, then fire again: the Core churning.
burst: (
<>
<rect x='12.5' y='43.75' width='75' height='12.5' />
<rect x='43.75' y='12.5' width='12.5' height='75' />
<circle cx='50' cy='50' r='12.5' />
<circle className={styles.burstUp} cx='50' cy='50' r='12.5' />
<circle className={styles.burstDown} cx='50' cy='50' r='12.5' />
<circle className={styles.burstLeft} cx='50' cy='50' r='12.5' />
<circle className={styles.burstRight} cx='50' cy='50' r='12.5' />
</>
),
compass: (
<>
<circle cx='50' cy='23' r='14' />
<circle cx='23' cy='50' r='14' />
<circle cx='77' cy='50' r='14' />
<circle cx='50' cy='77' r='14' />
<circle className={styles.compassMover} cx='50' cy='23' r='14' />
</>
),
squeeze: (
<>
{/* Arcs are stroked, not filled — they inherit the group's gradient stroke
(so the O is shaded identically to every other shape); the group pins
stroke-width to 0, so each arc re-asserts its own 12.5. */}
<path d='M 21.36 37.5 A 31.25 31.25 0 0 1 78.64 37.5' fill='none' strokeWidth='12.5' />
<path d='M 21.36 62.5 A 31.25 31.25 0 0 0 78.64 62.5' fill='none' strokeWidth='12.5' />
<rect className={styles.squeezeBarL} x='15' y='37.5' width='12.5' height='25' />
<rect className={styles.squeezeBarR} x='72.5' y='37.5' width='12.5' height='25' />
</>
),
// thinking — a core with small blobs drifting out and back on offset timings:
// a restless thought-cloud that never quite resolves. The Core deliberating.
thinking: (
<>
<circle cx='50' cy='50' r='15' />
<circle className={styles.thinkA} cx='50' cy='50' r='12' />
<circle className={styles.thinkB} cx='50' cy='50' r='12' />
<circle className={styles.thinkC} cx='50' cy='50' r='11' />
</>
),
// orb — a single solid disc that nearly fills the frame. Not part of the
// random cycle; the loader settles here so it can hand off to a real circular
// button (e.g. a send button) without a visible shape pop.
orb: <circle cx='50' cy='50' r='42' />,
}
/**
* World-aligned status phrase per shape (used when `phase` is set). Each phrase
* names what the shape represents in the Sim world, so the words on screen always
* match the loader. Keys are the shape names; the comment is the world concept.
*/
const VARIANT_PHRASE: Record<ThinkingLoaderVariant, string> = {
corners: 'Orchestrating…', // Mothership — the Core directing the work
squeeze: 'Working…', // Pod — one agent on a task
compass: 'In formation…', // Formation — many Pods in parallel
metaballs: 'Dispatching…', // Dispatch — sending work out
relay: 'Returning…', // Return — work coming back, consolidated
burst: 'Thinking…', // Thinking — the Core deliberating
thinking: 'Standing by…', // Waiting · Sentinel — holding for a condition
orb: 'Ready', // Orb — settled, ready to act
}
export interface ThinkingLoaderProps {
/** Pin one pattern. When omitted, the loader morphs between all patterns at random. */
variant?: ThinkingLoaderVariant
/**
* When cycling, open on this pattern (held one beat) before joining the
* shared morph timeline. Use `'corners'` (the Mothership shape) to always
* begin on the Core. Ignored when `variant` pins a single pattern.
*/
startVariant?: ThinkingLoaderVariant
/**
* Stop cycling and morph to the solid `orb` disc — the loader's terminal
* resting shape. The goo filter melts the current shape into the orb (no hard
* swap), so it can hand off to a real circular element underneath it. Ignored
* when `variant` pins a single pattern.
*/
settle?: boolean
/** Rendered square size in px. Defaults to 20. */
size?: number
/** Optional status text (e.g. "Thinking…") rendered beside the goo with a shimmer sweep. */
label?: string
/**
* Show a world-aligned status phrase that matches the current shape — e.g.
* "Dispatching…" under the Dispatch shape — updating as the loader morphs.
* Overrides `label`.
*/
phase?: boolean
/**
* Phrase/label font size as a fraction of `size`. Defaults to `0.7`. Lower it
* when the loader is shown scaled up (e.g. a zoomed hero shot) so the phrase
* doesn't read oversized next to the glyph.
*/
labelRatio?: number
/**
* Paint the label with the sweeping Claude-style shimmer (default). Set
* `false` for a static label in solid `--text-body` ink — no gradient, no
* sweep — while the phrase crossfade still applies.
*/
shimmer?: boolean
/** Layout-only classes (margins, alignment). The loader owns its chrome. */
className?: string
/**
* Escape hatch for the ink material: merged onto the SVG, so a caller can
* override the gradient/glow CSS vars (`--tl-grad-inner`, `--tl-grad-outer`,
* `--tl-glow`) to match a surface it hands off to. Inline, so it beats the
* module defaults. Use sparingly — the loader owns its look by default.
*/
style?: CSSProperties
}
/**
* Gooey mono thinking indicator shown wherever chat is working.
* Ink is near-black on light surfaces and off-white on dark surfaces.
*
* The goo filter operates on the alpha channel of transparent SVG shapes,
* so the loader needs no backdrop and composites on any background. All
* patterns share one filtered group, so the default cycling mode melts one
* pattern into the next instead of hard-swapping.
*
* @example
* ```tsx
* <ThinkingLoader label='Thinking…' />
* ```
*/
export function ThinkingLoader({
variant,
startVariant,
settle = false,
size = 20,
label,
phase,
labelRatio = 0.7,
shimmer = true,
className,
style,
}: ThinkingLoaderProps) {
// useId emits colons, which break url(#...) filter references — strip them.
const id = useId().replace(/[^a-zA-Z0-9-]/g, '')
const filterId = `tl-goo-${id}`
const clipId = `tl-clip-${id}`
const windowClipId = `tl-window-${id}`
const gradientId = `tl-grad-${id}`
const [cycleVariant, setCycleVariant] = useState<ThinkingLoaderVariant>(
startVariant ?? 'metaballs'
)
const cycling = variant === undefined
useEffect(() => {
if (!cycling) return
// Settle: stop the cycle and melt to the terminal orb (goo handles the morph).
if (settle) {
setCycleVariant('orb')
return
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
// When a startVariant is given, hold it for one min-step so the cycle always
// opens on that shape, then join the shared wall-clock morph timeline.
let opened = startVariant === undefined
let timeout: ReturnType<typeof setTimeout>
const tick = () => {
if (!opened) {
opened = true
setCycleVariant(startVariant as ThinkingLoaderVariant)
timeout = setTimeout(tick, STEP_MIN_MS)
return
}
const { variant: next, msUntilNext } = variantAtNow()
setCycleVariant(next)
timeout = setTimeout(tick, msUntilNext)
}
tick()
return () => clearTimeout(timeout)
}, [cycling, startVariant, settle])
// Phase-lock the CSS animations to the wall clock (set after mount so
// server and client markup agree). All instances share the same negative
// delay modulus, so their keyframes line up regardless of mount time.
const [syncDelay, setSyncDelay] = useState<string | undefined>(undefined)
useEffect(() => {
setSyncDelay(`-${Date.now() % SYNC_PERIOD_MS}ms`)
}, [])
const shown = variant ?? cycleVariant
// `phase` shows the world phrase for the shape on screen; otherwise the
// caller's static label (if any). Cycling, it updates as the shape morphs.
const displayLabel = phase ? VARIANT_PHRASE[shown] : label
// Crossfade the phrase when it changes: the outgoing phrase rises and fades
// out while the incoming one rises and fades in, so they rotate smoothly
// instead of snapping. The shimmer keeps running on the text underneath.
const [shownLabel, setShownLabel] = useState(displayLabel)
const [exitingLabel, setExitingLabel] = useState<string | undefined>(undefined)
useEffect(() => {
if (displayLabel === shownLabel) return
setExitingLabel(shownLabel)
setShownLabel(displayLabel)
}, [displayLabel, shownLabel])
useEffect(() => {
if (!exitingLabel) return
const timeout = setTimeout(() => setExitingLabel(undefined), 420)
return () => clearTimeout(timeout)
}, [exitingLabel])
const stages = cycling ? VARIANTS : [shown]
const loader = (
<svg
role={displayLabel ? undefined : 'status'}
aria-label={displayLabel ? undefined : 'Thinking'}
aria-hidden={displayLabel ? true : undefined}
viewBox='0 0 100 100'
width={size}
height={size}
className={cn(styles.frame, !displayLabel && className)}
style={{
...(syncDelay ? ({ '--tl-sync': syncDelay } as CSSProperties) : {}),
...style,
}}
>
<defs>
{/* sRGB so the radial gradient fill keeps its authored midtones
through the blur (linearRGB would wash the gradient out). The goo
crush runs first; the inner glow then rides the merged silhouette's
edge — a soft white inset, per the Figma loader spec. */}
<filter
id={filterId}
x='-30%'
y='-30%'
width='160%'
height='160%'
colorInterpolationFilters='sRGB'
>
<feGaussianBlur in='SourceGraphic' stdDeviation='5' result='blur' />
<feColorMatrix
in='blur'
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9'
result='goo'
/>
{/* Inner shadow from the goo silhouette's alpha (Figma technique:
blur the alpha, subtract it from itself to leave an inner ring,
tint white). stdDeviation 4.86 = the spec's 3.5 scaled 72→100. */}
<feColorMatrix
in='goo'
type='matrix'
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
result='gooAlpha'
/>
<feGaussianBlur in='gooAlpha' stdDeviation='4.86' result='innerBlur' />
<feComposite
in='innerBlur'
in2='gooAlpha'
operator='arithmetic'
k2='-1'
k3='1'
result='innerMask'
/>
{/* Glow color + per-theme opacity ride a CSS var (light 0.6 / dark
0.9) so one filter serves both themes. */}
<feFlood className={styles.glow} result='glowColor' />
<feComposite in='glowColor' in2='innerMask' operator='in' result='glow' />
<feMerge>
<feMergeNode in='goo' />
<feMergeNode in='glow' />
</feMerge>
</filter>
{/* Radial gradient (center → edge), theme stops via CSS vars. Matches
the Figma loader: light #4F4F4F→#6F6F6F, dark #A7A7A7→#D6D6D6.
objectBoundingBox (default units) so EACH blob is shaded on its own
box — Figma fits the gradient per shape, so small/offset dots read
glossy (center dark, edge light) instead of flat. */}
<radialGradient id={gradientId} cx='0.5' cy='0.5' r='0.5'>
<stop className={styles.gradInner} />
<stop offset='1' className={styles.gradOuter} />
</radialGradient>
{/* Shapes clip BEFORE the goo filter, so anything exiting the frame
melts into the edge instead of getting a hard post-filter cut. */}
<clipPath id={clipId}>
<rect width='100' height='100' />
</clipPath>
{/* The original burst loader hid its flying dots under a 10px border,
swallowing them at the inner window — this clip reproduces it. */}
<clipPath id={windowClipId}>
<rect x='12.5' y='12.5' width='75' height='75' />
</clipPath>
</defs>
<g
filter={`url(#${filterId})`}
fill={`url(#${gradientId})`}
stroke={`url(#${gradientId})`}
strokeWidth={0}
>
{stages.map((v) => (
<g
key={v}
clipPath={`url(#${v === 'burst' ? windowClipId : clipId})`}
className={cn(styles.stage, v === shown && styles.stageActive)}
>
{VARIANT_SHAPES[v]}
</g>
))}
</g>
</svg>
)
if (!displayLabel) return loader
return (
<output
className={cn(styles.labelRow, className)}
style={
{
'--tl-label-size': `${size * labelRatio}px`,
'--tl-label-gap': `${size * 0.4}px`,
} as CSSProperties
}
>
{loader}
<span className={styles.labelStack}>
{exitingLabel ? (
<span key={exitingLabel} className={cn(styles.labelLayer, styles.labelOut)}>
<span className={shimmer ? styles.label : styles.labelStatic}>{exitingLabel}</span>
</span>
) : null}
<span key={shownLabel} className={cn(styles.labelLayer, styles.labelIn)}>
<span className={shimmer ? styles.label : styles.labelStatic}>{shownLabel}</span>
</span>
</span>
</output>
)
}