add progress bar completion

This commit is contained in:
Swifty
2026-02-05 11:44:58 +01:00
parent d919bd5f54
commit 88ebef601a
3 changed files with 136 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
@@ -31,6 +32,29 @@ export function MessageList({
isStreaming,
});
const [showThinkingMessage, setShowThinkingMessage] = useState(false);
const [thinkingComplete, setThinkingComplete] = useState(false);
// Manage thinking message visibility and completion state
useEffect(() => {
if (isStreaming && streamingChunks.length === 0) {
// Start showing thinking message
setShowThinkingMessage(true);
setThinkingComplete(false);
} else if (streamingChunks.length > 0 && showThinkingMessage) {
// Chunks arrived - trigger completion animation
setThinkingComplete(true);
} else if (!isStreaming) {
// Streaming ended completely - reset state
setShowThinkingMessage(false);
setThinkingComplete(false);
}
}, [isStreaming, streamingChunks.length, showThinkingMessage]);
function handleThinkingAnimationComplete() {
setShowThinkingMessage(false);
}
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Top fade shadow */}
@@ -92,10 +116,15 @@ export function MessageList({
})()}
{/* Render thinking message when streaming but no chunks yet */}
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
{showThinkingMessage && (
<ThinkingMessage
isComplete={thinkingComplete}
onAnimationComplete={handleThinkingAnimationComplete}
/>
)}
{/* Render streaming message if active */}
{isStreaming && streamingChunks.length > 0 && (
{/* Render streaming message if active (wait for thinking animation to complete) */}
{isStreaming && streamingChunks.length > 0 && !showThinkingMessage && (
<StreamingMessage
chunks={streamingChunks}
onComplete={onStreamComplete}

View File

@@ -6,26 +6,36 @@ import { useAsymptoticProgress } from "../ToolCallMessage/useAsymptoticProgress"
export interface ThinkingMessageProps {
className?: string;
isComplete?: boolean;
onAnimationComplete?: () => void;
}
export function ThinkingMessage({ className }: ThinkingMessageProps) {
export function ThinkingMessage({
className,
isComplete = false,
onAnimationComplete,
}: ThinkingMessageProps) {
const [showSlowLoader, setShowSlowLoader] = useState(false);
const [showCoffeeMessage, setShowCoffeeMessage] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const coffeeTimerRef = useRef<NodeJS.Timeout | null>(null);
const progress = useAsymptoticProgress(showCoffeeMessage);
const delayTimerRef = useRef<NodeJS.Timeout | null>(null);
const { progress, isAnimationDone } = useAsymptoticProgress(
showCoffeeMessage,
isComplete,
);
useEffect(() => {
if (timerRef.current === null) {
timerRef.current = setTimeout(() => {
setShowSlowLoader(true);
}, 8000);
}, 3000);
}
if (coffeeTimerRef.current === null) {
coffeeTimerRef.current = setTimeout(() => {
setShowCoffeeMessage(true);
}, 10000);
}, 8000);
}
return () => {
@@ -40,6 +50,22 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
};
}, []);
// Handle completion animation delay before unmounting
useEffect(() => {
if (isAnimationDone && onAnimationComplete) {
delayTimerRef.current = setTimeout(() => {
onAnimationComplete();
}, 200); // 200ms delay after animation completes
}
return () => {
if (delayTimerRef.current) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
};
}, [isAnimationDone, onAnimationComplete]);
return (
<div
className={cn(

View File

@@ -1,5 +1,18 @@
import { useEffect, useRef, useState } from "react";
/**
* Cubic Ease Out easing function: 1 - (1 - t)^3
* Starts fast and decelerates smoothly to a stop.
*/
function cubicEaseOut(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
export interface AsymptoticProgressResult {
progress: number;
isAnimationDone: boolean;
}
/**
* Hook that returns a progress value that starts fast and slows down,
* asymptotically approaching but never reaching the max value.
@@ -11,25 +24,38 @@ import { useEffect, useRef, useState } from "react";
* - 87.5% is reached at 3 * halfLifeSeconds
* - and so on...
*
* When isComplete is set to true, animates from current progress to 100%
* using Cubic Ease Out over 300ms.
*
* @param isActive - Whether the progress should be animating
* @param isComplete - Whether to animate to 100% (completion animation)
* @param halfLifeSeconds - Time in seconds to reach 50% progress (default: 30)
* @param maxProgress - Maximum progress value to approach (default: 100)
* @param intervalMs - Update interval in milliseconds (default: 100)
* @returns Current progress value (0-maxProgress)
* @returns Object with current progress value and whether completion animation is done
*/
export function useAsymptoticProgress(
isActive: boolean,
isComplete = false,
halfLifeSeconds = 30,
maxProgress = 100,
intervalMs = 100,
) {
): AsymptoticProgressResult {
const [progress, setProgress] = useState(0);
const [isAnimationDone, setIsAnimationDone] = useState(false);
const elapsedTimeRef = useRef(0);
const completionStartProgressRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
// Handle asymptotic progress when active but not complete
useEffect(() => {
if (!isActive) {
setProgress(0);
elapsedTimeRef.current = 0;
if (!isActive || isComplete) {
if (!isComplete) {
setProgress(0);
elapsedTimeRef.current = 0;
setIsAnimationDone(false);
completionStartProgressRef.current = null;
}
return;
}
@@ -44,7 +70,48 @@ export function useAsymptoticProgress(
}, intervalMs);
return () => clearInterval(interval);
}, [isActive, halfLifeSeconds, maxProgress, intervalMs]);
}, [isActive, isComplete, halfLifeSeconds, maxProgress, intervalMs]);
return progress;
// Handle completion animation
useEffect(() => {
if (!isComplete) {
return;
}
// Capture the starting progress when completion begins
if (completionStartProgressRef.current === null) {
completionStartProgressRef.current = progress;
}
const startProgress = completionStartProgressRef.current;
const animationDuration = 300; // 300ms
const startTime = performance.now();
function animate(currentTime: number) {
const elapsed = currentTime - startTime;
const t = Math.min(elapsed / animationDuration, 1);
// Cubic Ease Out from current progress to maxProgress
const easedProgress =
startProgress + (maxProgress - startProgress) * cubicEaseOut(t);
setProgress(easedProgress);
if (t < 1) {
animationFrameRef.current = requestAnimationFrame(animate);
} else {
setProgress(maxProgress);
setIsAnimationDone(true);
}
}
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isComplete, maxProgress]);
return { progress, isAnimationDone };
}