d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
449 lines
17 KiB
TypeScript
449 lines
17 KiB
TypeScript
'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.1–2.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>
|
||
)
|
||
}
|