-
Notifications
You must be signed in to change notification settings - Fork 70
Expand file tree
/
Copy pathloading-indicator.tsx
More file actions
176 lines (151 loc) · 5.44 KB
/
loading-indicator.tsx
File metadata and controls
176 lines (151 loc) · 5.44 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import { useReducer, useEffect } from "react";
// ============================================================================
// Types
// ============================================================================
type Phase = "green" | "orange" | "red";
interface LoadingIndicatorProps {
text?: string;
estimatedSeconds?: number;
errorMessage?: string;
}
// ============================================================================
// Constants
// ============================================================================
const PHASE_STYLES = {
green: {
background: "linear-gradient(90deg, #35aa6b 0%, #7cc89f 100%)",
},
orange: {
background: "linear-gradient(90deg, #f97316 0%, #fb923c 100%)",
},
red: {
background: "linear-gradient(90deg, #ef4444 0%, #f87171 100%)",
},
} as const;
// ============================================================================
// Component
// ============================================================================
type IndicatorState = {
progress: number;
phase: Phase;
isTimeout: boolean;
};
type IndicatorAction =
| { type: "SET_PROGRESS"; progress: number }
| { type: "ADVANCE_PHASE"; nextPhase: Phase }
| { type: "SET_TIMEOUT" };
function indicatorReducer(state: IndicatorState, action: IndicatorAction): IndicatorState {
switch (action.type) {
case "SET_PROGRESS":
return { ...state, progress: action.progress };
case "ADVANCE_PHASE":
return { ...state, phase: action.nextPhase, progress: 0 };
case "SET_TIMEOUT":
return { ...state, isTimeout: true };
default:
return state;
}
}
export const LoadingIndicator = ({ text = "Thinking", estimatedSeconds = 0, errorMessage }: LoadingIndicatorProps) => {
// State
const [{ progress, phase, isTimeout }, dispatch] = useReducer(indicatorReducer, {
progress: 0,
phase: "green",
isTimeout: false,
});
// Handle progress animation
useEffect(() => {
if (estimatedSeconds <= 0) return;
let animationFrameId: number;
let lastUpdateTime = Date.now();
let currentProgress = 0;
// If we want it to go faster, we multiply the increment.
// 1x = standard duration. 2x = half duration.
const getSpeedMultiplier = (currentPhase: Phase) => {
switch (currentPhase) {
case "green":
return 1; // Takes full duration
case "orange":
return 2; // Takes half duration (50%)
case "red":
return 2; // Takes half duration (50%)
default:
return 1;
}
};
const updateProgress = () => {
const now = Date.now();
const deltaTime = now - lastUpdateTime;
// Add random delay to make animation more natural
if (deltaTime < Math.random() * 300) {
animationFrameId = requestAnimationFrame(updateProgress);
return;
}
lastUpdateTime = now;
// Calculate progress with natural fluctuation
const speedMultiplier = getSpeedMultiplier(phase);
// the math
const baseIncrement = (deltaTime / (estimatedSeconds * 1000)) * 100 * speedMultiplier;
const fluctuation = baseIncrement * (Math.random() - 0.5);
const increment = Math.max(0, baseIncrement + fluctuation);
currentProgress = Math.max(currentProgress, currentProgress + increment);
// Handle phase transitions
if (currentProgress >= 100) {
// we spend 100% of estimatedDuration in green,
// 50% in orange, and 50% in red before warning.
if (phase === "green") {
dispatch({ type: "ADVANCE_PHASE", nextPhase: "orange" });
currentProgress = 0;
} else if (phase === "orange") {
dispatch({ type: "ADVANCE_PHASE", nextPhase: "red" });
currentProgress = 0;
} else if (phase === "red") {
dispatch({ type: "SET_TIMEOUT" });
return;
}
}
dispatch({ type: "SET_PROGRESS", progress: currentProgress });
if (!isTimeout) {
animationFrameId = requestAnimationFrame(updateProgress);
}
};
animationFrameId = requestAnimationFrame(updateProgress);
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
}, [estimatedSeconds, phase, isTimeout]);
// Get status message based on phase
const getStatusMessage = () => {
if (isTimeout)
return "Sorry! This request took too long to complete. We're working on improving reliability. You can try waiting a bit longer or refreshing the page. Thank you for your patience.";
if (phase === "orange") return "Synthesizing...";
if (phase === "red") return "Just a moment...";
if (errorMessage && errorMessage.length > 0) return errorMessage;
return text;
};
return (
<div className="indicator">
{/* Status Text */}
<div
className={`flex space-x-1 text-xs ${!isTimeout && !errorMessage ? "shimmer" : ""}`}
>
<span className={isTimeout || errorMessage ? "text-rose-400" : ""}>{getStatusMessage()}</span>
</div>
{/* Progress Bar */}
{estimatedSeconds > 0 && !isTimeout && !errorMessage && (
<div className="w-full h-1 bg-gray-200 dark:!bg-default-300 rounded-full overflow-hidden">
<div
className="h-full transition-all duration-300 ease-out"
style={{
width: `${progress}%`,
...PHASE_STYLES[phase],
transition: "width 1s ease-out",
}}
/>
</div>
)}
</div>
);
};