From 09a04e8b9b6a9bf058427a7ebcff22626a08776f Mon Sep 17 00:00:00 2001 From: Zhiyuan Date: Sun, 5 Jul 2026 11:41:15 -0700 Subject: [PATCH] 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 --- .../components/architecture/message-flow.tsx | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/web/src/components/architecture/message-flow.tsx b/web/src/components/architecture/message-flow.tsx index ff25fd55e..5a34c9a8f 100644 --- a/web/src/components/architecture/message-flow.tsx +++ b/web/src/components/architecture/message-flow.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion } from "framer-motion"; const FLOW_STEPS = [ { role: "user", label: "user", color: "bg-blue-500" }, @@ -16,20 +16,22 @@ const FLOW_STEPS = [ export function MessageFlow() { const [count, setCount] = useState(0); - const intervalRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); useEffect(() => { - intervalRef.current = setInterval(() => { - setCount((prev) => { - if (prev >= FLOW_STEPS.length) { - setTimeout(() => setCount(0), 1500); - return prev; - } - return prev + 1; - }); - }, 800); + // Single self-scheduling timer: only ever one timeout is pending, so a + // completed cycle can't queue multiple resets that snap the count back. + const step = (current: number) => { + const next = current >= FLOW_STEPS.length ? 0 : current + 1; + setCount(next); + // Hold on the full array before restarting the cycle. + const delay = next >= FLOW_STEPS.length ? 1500 : 800; + timeoutRef.current = setTimeout(() => step(next), delay); + }; + + timeoutRef.current = setTimeout(() => step(0), 800); return () => { - if (intervalRef.current) clearInterval(intervalRef.current); + if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); @@ -43,22 +45,21 @@ export function MessageFlow() { len={count} -
- - {FLOW_STEPS.slice(0, count).map((step, i) => ( - - - {step.label} - - - ))} - +
+ {FLOW_STEPS.slice(0, count).map((step, i) => ( + + + {step.label} + + + ))} {count === 0 && (
[]