Skip to content

Commit 9974073

Browse files
andresdjassoclaude
andcommitted
fix(thinking-loader): phase-lock all instances to a shared wall-clock timeline
The switcher chip and the in-chat indicator each picked random next patterns on their own timers and started their CSS animations at their own mount time, so concurrent loaders drifted visibly apart. The pattern shown is now a pure function of Date.now() over a fixed cycle sequence, and every shape animation gets a shared negative delay (now mod the 12s common period) via --tl-sync, so any two loaders — whenever mounted — show the same pattern at the same frame. The doubled-class CSS rule out-specifies the animation shorthand's implicit 0s delay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ef31105 commit 9974073

2 files changed

Lines changed: 62 additions & 8 deletions

File tree

apps/sim/components/emcn/components/thinking-loader/thinking-loader.module.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
flex: none;
2121
}
2222

23+
/* Wall-clock phase lock: the component sets --tl-sync to a shared negative
24+
delay (now mod the common animation period), so every instance's keyframes
25+
line up no matter when it mounted. The doubled class out-specifies the
26+
shape classes' animation shorthand (implicit 0s delay) regardless of
27+
source order. */
28+
.frame.frame * {
29+
animation-delay: var(--tl-sync, 0s);
30+
}
31+
2332
:global(.dark) .frame {
2433
color: #d6d6d6;
2534
}

apps/sim/components/emcn/components/thinking-loader/thinking-loader.tsx

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type ReactNode, useEffect, useId, useState } from 'react'
3+
import { type CSSProperties, type ReactNode, useEffect, useId, useState } from 'react'
44
import styles from '@/components/emcn/components/thinking-loader/thinking-loader.module.css'
55
import { cn } from '@/lib/core/utils/cn'
66

@@ -33,6 +33,41 @@ const VARIANT_LOOP_MS: Record<ThinkingLoaderVariant, number> = {
3333
maze: 2000,
3434
}
3535

36+
/**
37+
* Fixed shuffle of the cycle so every instance walks the same pattern order.
38+
* Which pattern shows is a pure function of the wall clock, so loaders in
39+
* the chat switcher and the message stream stay in lockstep.
40+
*/
41+
const CYCLE_SEQUENCE: readonly ThinkingLoaderVariant[] = [
42+
'metaballs',
43+
'relay',
44+
'compass',
45+
'corners',
46+
'maze',
47+
'burst',
48+
'orbit',
49+
'squeeze',
50+
]
51+
const CYCLE_TOTAL_MS = CYCLE_SEQUENCE.reduce((sum, v) => sum + VARIANT_LOOP_MS[v], 0)
52+
53+
/**
54+
* Common multiple of every shape animation period (800/1000/1200/2000ms,
55+
* alternates doubled) — the wall-clock modulus for the shared negative
56+
* animation-delay that phase-locks instances mounted at different times.
57+
*/
58+
const SYNC_PERIOD_MS = 12_000
59+
60+
/** The pattern the shared timeline is on right now, and how long it holds. */
61+
function variantAtNow(): { variant: ThinkingLoaderVariant; msUntilNext: number } {
62+
let t = Date.now() % CYCLE_TOTAL_MS
63+
for (const v of CYCLE_SEQUENCE) {
64+
const hold = VARIANT_LOOP_MS[v]
65+
if (t < hold) return { variant: v, msUntilNext: hold - t }
66+
t -= hold
67+
}
68+
return { variant: CYCLE_SEQUENCE[0], msUntilNext: VARIANT_LOOP_MS[CYCLE_SEQUENCE[0]] }
69+
}
70+
3671
/**
3772
* Ink shapes per variant, authored in the shared 100x100 viewBox.
3873
* Geometry mirrors the intrinsic CSS loaders these were adapted from,
@@ -152,14 +187,23 @@ export function ThinkingLoader({ variant, size = 20, label, className }: Thinkin
152187
if (!cycling) return
153188
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
154189

155-
const timeout = setTimeout(() => {
156-
setCycleVariant((prev) => {
157-
const others = VARIANTS.filter((v) => v !== prev)
158-
return others[Math.floor(Math.random() * others.length)]
159-
})
160-
}, VARIANT_LOOP_MS[cycleVariant])
190+
let timeout: ReturnType<typeof setTimeout>
191+
const tick = () => {
192+
const { variant: next, msUntilNext } = variantAtNow()
193+
setCycleVariant(next)
194+
timeout = setTimeout(tick, msUntilNext)
195+
}
196+
tick()
161197
return () => clearTimeout(timeout)
162-
}, [cycling, cycleVariant])
198+
}, [cycling])
199+
200+
// Phase-lock the CSS animations to the wall clock (set after mount so
201+
// server and client markup agree). All instances share the same negative
202+
// delay modulus, so their keyframes line up regardless of mount time.
203+
const [syncDelay, setSyncDelay] = useState<string | undefined>(undefined)
204+
useEffect(() => {
205+
setSyncDelay(`-${Date.now() % SYNC_PERIOD_MS}ms`)
206+
}, [])
163207

164208
const shown = variant ?? cycleVariant
165209
const stages = cycling ? VARIANTS : [shown]
@@ -173,6 +217,7 @@ export function ThinkingLoader({ variant, size = 20, label, className }: Thinkin
173217
width={size}
174218
height={size}
175219
className={cn(styles.frame, !label && className)}
220+
style={syncDelay ? ({ '--tl-sync': syncDelay } as CSSProperties) : undefined}
176221
>
177222
<defs>
178223
<filter id={filterId} x='-30%' y='-30%' width='160%' height='160%'>

0 commit comments

Comments
 (0)