import { LOADING_PHRASES, PHRASE_INTERVAL } from "./loading"; import { animate, AnimatePresence, m as motion, useMotionValue } from "motion/react"; import { useEffect, useState } from "react"; import { cn } from "../../lib/utils"; import { IconHash, IconLoader } from "../../icons"; /* ───────────────────────────────────────────────────────── * SESSION-CREATION LOADING SEQUENCE * * Phrase rotation is internal; settle timing is driven by the * parent via the `settled` prop (set true when the backend signals * the session is ready). * ───────────────────────────────────────────────────────── */ function shuffle(values: T[]) { let next = [...values]; for (let i = next.length - 1; i > 0; i--) { let j = Math.floor(Math.random() * (i + 1)); [next[i], next[j]] = [next[j], next[i]]; } return next; } function phrases() { let [first, ...rest] = LOADING_PHRASES; return [first, ...shuffle(rest)]; } /** Spinner physics */ const SPIN_TRANSITION = { type: "spring" as const, stiffness: 108, damping: 19, mass: 0.8, repeat: Infinity, }; /** Icon swap */ const ICON_SWAP = { initial: { opacity: 0, scale: 0.85, filter: "blur(4px)" }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, scale: 0.85, filter: "blur(4px)" }, transition: { duration: 0.4, ease: "easeOut" as const }, }; const SPINNER_SIZE = { 14: "size-3.5", 18: "size-4.5", } as const; /** Plain opacity crossfade between text values. */ function CrossfadeText({ shimmer, text }: { shimmer?: boolean; text: string }) { return ( {text} ); } /** * Continuously rotating loading icon. Drives `rotate` imperatively so the * animation is decoupled from AnimatePresence's initial-suppression logic * (which would otherwise skip the spin's `initial={rotate: 0}` on first mount). */ export function LoadingSpinner( { runId = 0, size }: { runId?: number; size?: keyof typeof SPINNER_SIZE }, ) { let rotate = useMotionValue(0); useEffect(() => { rotate.set(0); let controls = animate(rotate, 360, SPIN_TRANSITION); return () => controls.stop(); }, [runId, rotate]); return ( ); } /** * Session row in its loading state. Phrase rotation runs internally * while `settled` is false; flip `settled` to true (driven by the * parent's backend signal) to swap to the resolved session name and * hashtag icon. `runId` resets the phrase rotation for replay; * production callers can omit it. */ export function SessionItemLoading(props: LoadingProps) { return ; } type LoadingProps = { settled: boolean; runId?: number; sessionName?: string; selected?: boolean; }; function Loading( { settled, runId = 0, sessionName = "", selected = false }: LoadingProps, ) { let [phraseIdx, setPhraseIdx] = useState(0); let [sequence] = useState(phrases); useEffect(() => { if (settled) return; let interval = setInterval( () => setPhraseIdx((x) => x + 1), PHRASE_INTERVAL, ); return () => clearInterval(interval); }, [settled, runId]); let text = settled ? sessionName : sequence[phraseIdx % sequence.length]; let iconNode = settled ? : ; return (
{iconNode}
); }