fix(web): stabilize Message Growth animation and fit chips on one line

The "Message Growth" visualization (message-flow.tsx) had three issues:

1. Flicker at len=0/1. The setInterval kept firing while the array was
   full, queuing multiple setCount(0) resets that snapped the counter
   back mid-regrowth; and index-keyed chips inside AnimatePresence
   collided with still-exiting nodes on reset, leaving stuck invisible
   chips (len=1 rendered 8 chips at opacity 0). Replaced with a single
   self-scheduling setTimeout chain and removed AnimatePresence/exit so
   chips unmount cleanly on reset.

2. Horizontal scrollbar at len=8. Switched overflow-x-auto to flex-wrap
   and tightened chip padding/gap (px-2.5->px-2, gap-1.5->gap-1) so all
   8 chips fit on one line within the existing column width.

3. Inconsistent entrance animation. Dropped the width:0->auto slide in
   favor of a unified scale+opacity zoom with layout for smooth reflow.

Refs #450
This commit is contained in:
Zhiyuan
2026-07-05 11:41:15 -07:00
parent a9cafe953a
commit 09a04e8b9b

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
const FLOW_STEPS = [ const FLOW_STEPS = [
{ role: "user", label: "user", color: "bg-blue-500" }, { role: "user", label: "user", color: "bg-blue-500" },
@@ -16,20 +16,22 @@ const FLOW_STEPS = [
export function MessageFlow() { export function MessageFlow() {
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
intervalRef.current = setInterval(() => { // Single self-scheduling timer: only ever one timeout is pending, so a
setCount((prev) => { // completed cycle can't queue multiple resets that snap the count back.
if (prev >= FLOW_STEPS.length) { const step = (current: number) => {
setTimeout(() => setCount(0), 1500); const next = current >= FLOW_STEPS.length ? 0 : current + 1;
return prev; setCount(next);
} // Hold on the full array before restarting the cycle.
return prev + 1; const delay = next >= FLOW_STEPS.length ? 1500 : 800;
}); timeoutRef.current = setTimeout(() => step(next), delay);
}, 800); };
timeoutRef.current = setTimeout(() => step(0), 800);
return () => { return () => {
if (intervalRef.current) clearInterval(intervalRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
}; };
}, []); }, []);
@@ -43,22 +45,21 @@ export function MessageFlow() {
len={count} len={count}
</span> </span>
</div> </div>
<div className="flex gap-1.5 overflow-x-auto pb-1"> <div className="flex flex-wrap gap-1 pb-1">
<AnimatePresence> {FLOW_STEPS.slice(0, count).map((step, i) => (
{FLOW_STEPS.slice(0, count).map((step, i) => ( <motion.div
<motion.div key={i}
key={i} layout
initial={{ opacity: 0, scale: 0.7, width: 0 }} initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1, width: "auto" }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
className={`flex shrink-0 items-center rounded-md px-2.5 py-1.5 ${step.color}`} className={`flex shrink-0 items-center rounded-md px-2 py-1.5 ${step.color}`}
> >
<span className="whitespace-nowrap font-mono text-[10px] font-medium text-white"> <span className="whitespace-nowrap font-mono text-[10px] font-medium text-white">
{step.label} {step.label}
</span> </span>
</motion.div> </motion.div>
))} ))}
</AnimatePresence>
{count === 0 && ( {count === 0 && (
<div className="flex h-7 items-center text-xs text-[var(--color-text-secondary)]"> <div className="flex h-7 items-center text-xs text-[var(--color-text-secondary)]">
[] []