Skip to content

Commit 2594d9b

Browse files
committed
feat(AvatarAssistant): implement sleep state management with adaptive durations and localStorage persistence
1 parent 6b47466 commit 2594d9b

File tree

1 file changed

+178
-2
lines changed

1 file changed

+178
-2
lines changed

frontend/src/components/ui/AvatarAssistant.tsx

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,78 @@ const MESSAGE_TIMINGS = {
101101
DEMO_ANIMATION_STEP: 500, // Time between demo animation steps
102102
} as const;
103103

104+
// Sleep persistence configuration
105+
const SLEEP_CONFIG = {
106+
STORAGE_KEY: 'thinkred_assistant_sleep_state',
107+
DEFAULT_SLEEP_DURATION: 10 * 60 * 1000, // 10 minutes in milliseconds
108+
MIN_SLEEP_DURATION: 5 * 60 * 1000, // 5 minutes minimum
109+
MAX_SLEEP_DURATION: 30 * 60 * 1000, // 30 minutes maximum
110+
QUICK_SLEEP_DURATION: 5 * 60 * 1000, // 5 minutes for quick sleep
111+
LONG_SLEEP_DURATION: 20 * 60 * 1000, // 20 minutes for long sleep
112+
} as const;
113+
114+
// Sleep state interface
115+
interface SleepState {
116+
isAsleep: boolean;
117+
sleepStartTime: number;
118+
sleepDuration: number;
119+
reason: 'manual' | 'automatic';
120+
}
121+
122+
// Sleep storage utilities
123+
const sleepStorage = {
124+
set: (state: SleepState): void => {
125+
try {
126+
localStorage.setItem(SLEEP_CONFIG.STORAGE_KEY, JSON.stringify(state));
127+
} catch {
128+
// Silently fail if localStorage is not available
129+
}
130+
},
131+
132+
get: (): SleepState | null => {
133+
try {
134+
const storedState = localStorage.getItem(SLEEP_CONFIG.STORAGE_KEY);
135+
if (!storedState) return null;
136+
137+
const state = JSON.parse(storedState) as SleepState;
138+
139+
// Validate the stored state structure
140+
if (
141+
typeof state.isAsleep !== 'boolean' ||
142+
typeof state.sleepStartTime !== 'number' ||
143+
typeof state.sleepDuration !== 'number'
144+
) {
145+
sleepStorage.clear();
146+
return null;
147+
}
148+
149+
return state;
150+
} catch {
151+
sleepStorage.clear();
152+
return null;
153+
}
154+
},
155+
156+
clear: (): void => {
157+
try {
158+
localStorage.removeItem(SLEEP_CONFIG.STORAGE_KEY);
159+
} catch {
160+
// Silently fail if localStorage is not available
161+
}
162+
},
163+
164+
isExpired: (state: SleepState): boolean => {
165+
return Date.now() > state.sleepStartTime + state.sleepDuration;
166+
},
167+
};
168+
104169
const AvatarAssistant = () => {
105170
const navigate = useNavigate();
106171
const location = useLocation();
107172
const [isVisible, setIsVisible] = useState(true);
108173
const [isSleeping, setIsSleeping] = useState(false);
109174
const [isManualSleep, setIsManualSleep] = useState(false);
175+
const [persistentSleep, setPersistentSleep] = useState<SleepState | null>(null);
110176
const [isGoingToSleep, setIsGoingToSleep] = useState(false);
111177
const [isWakingUp, setIsWakingUp] = useState(false);
112178
const [justWokeUp, setJustWokeUp] = useState(false);
@@ -164,6 +230,7 @@ const AvatarAssistant = () => {
164230
const messageRef = useRef<HTMLDivElement>(null);
165231
const hasBeenRenderedRef = useRef(false);
166232
const isUnmountedRef = useRef(false); // Track if component is unmounted
233+
const [sleepTimeoutId, setSleepTimeoutId] = useState<NodeJS.Timeout | null>(null);
167234

168235
// Enhanced cleanup function
169236
const cleanupTimers = useCallback(() => {
@@ -186,6 +253,48 @@ const AvatarAssistant = () => {
186253
};
187254
}, [cleanupTimers]);
188255

256+
// Initialize persistent sleep state on component mount
257+
useEffect(() => {
258+
const storedSleepState = sleepStorage.get();
259+
260+
if (storedSleepState && storedSleepState.isAsleep) {
261+
if (sleepStorage.isExpired(storedSleepState)) {
262+
// Sleep duration has expired, clear storage and wake up
263+
sleepStorage.clear();
264+
setPersistentSleep(null);
265+
} else {
266+
// Still within sleep duration, restore sleep state
267+
setPersistentSleep(storedSleepState);
268+
setIsSleeping(true);
269+
setIsManualSleep(storedSleepState.reason === 'manual');
270+
setIsVisible(false);
271+
272+
// Set timeout for remaining sleep duration
273+
const remainingTime = storedSleepState.sleepStartTime + storedSleepState.sleepDuration - Date.now();
274+
const timeoutId = setTimeout(() => {
275+
// Auto wake up when sleep duration expires
276+
sleepStorage.clear();
277+
setPersistentSleep(null);
278+
// Wake up by resetting sleep states
279+
setIsSleeping(false);
280+
setIsManualSleep(false);
281+
setIsVisible(true);
282+
}, remainingTime);
283+
284+
setSleepTimeoutId(timeoutId);
285+
}
286+
}
287+
}, []);
288+
289+
// Clean up sleep timeout on unmount
290+
useEffect(() => {
291+
return () => {
292+
if (sleepTimeoutId) {
293+
clearTimeout(sleepTimeoutId);
294+
}
295+
};
296+
}, [sleepTimeoutId]);
297+
189298
// Function to change message with shrink-then-grow animation - Enhanced with error handling
190299
const changeMessageWithAnimation = useCallback(
191300
(newMessage: string) => {
@@ -689,7 +798,7 @@ const AvatarAssistant = () => {
689798
}, 150);
690799
}, 100); // Small debounce to prevent rapid state changes
691800
} else if (scrollY <= sleepScrollDistance && (isSleeping || isGoingToSleep) && !isManualSleep && !isWakingUp) {
692-
// Debounce the wake transition
801+
// Debounce the wake up transition
693802
scrollTimeout = setTimeout(() => {
694803
if (isUnmountedRef.current) return;
695804
setIsGoingToSleep(false); // Reset transition state
@@ -834,6 +943,35 @@ const AvatarAssistant = () => {
834943
showPageWelcome();
835944
}, [location.pathname, isVisible, isSleeping, changeMessageWithAnimation]);
836945

946+
// Function to determine appropriate sleep duration based on user behavior
947+
const getAdaptiveSleepDuration = (): number => {
948+
// If user has been very active, use shorter sleep duration
949+
if (userInteractionCount > 10) {
950+
return SLEEP_CONFIG.QUICK_SLEEP_DURATION;
951+
}
952+
953+
// If user has been idle for a long time, use longer sleep duration
954+
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
955+
if (timeSinceLastInteraction > 5 * 60 * 1000) {
956+
// 5 minutes of inactivity
957+
return SLEEP_CONFIG.LONG_SLEEP_DURATION;
958+
}
959+
960+
// Default duration for most cases
961+
return SLEEP_CONFIG.DEFAULT_SLEEP_DURATION;
962+
};
963+
964+
// Function to get remaining sleep time for display
965+
const getRemainingSleepaTime = () => {
966+
if (!persistentSleep) return null;
967+
968+
const remaining = persistentSleep.sleepStartTime + persistentSleep.sleepDuration - Date.now();
969+
if (remaining <= 0) return null;
970+
971+
const minutes = Math.ceil(remaining / (60 * 1000));
972+
return minutes;
973+
};
974+
837975
// Unified function to get avatar animation classes - prevents conflicts
838976
const getAvatarAnimationClass = () => {
839977
// Priority order to prevent conflicts (highest to lowest priority):
@@ -1249,6 +1387,12 @@ const AvatarAssistant = () => {
12491387

12501388
// Put assistant to sleep (user-initiated hide)
12511389
const putAssistantToSleep = () => {
1390+
// Clear any existing sleep timeout
1391+
if (sleepTimeoutId) {
1392+
clearTimeout(sleepTimeoutId);
1393+
setSleepTimeoutId(null);
1394+
}
1395+
12521396
// Clear any pending timeouts that might interfere first
12531397
if (timeoutRef.current) {
12541398
clearTimeout(timeoutRef.current);
@@ -1257,6 +1401,28 @@ const AvatarAssistant = () => {
12571401
clearInterval(intervalRef.current);
12581402
}
12591403

1404+
// Create persistent sleep state with adaptive duration
1405+
const sleepState: SleepState = {
1406+
isAsleep: true,
1407+
sleepStartTime: Date.now(),
1408+
sleepDuration: getAdaptiveSleepDuration(),
1409+
reason: 'manual',
1410+
};
1411+
1412+
// Store sleep state in localStorage
1413+
sleepStorage.set(sleepState);
1414+
setPersistentSleep(sleepState);
1415+
1416+
// Set timeout to automatically wake up after sleep duration
1417+
const timeoutId = setTimeout(() => {
1418+
sleepStorage.clear();
1419+
setPersistentSleep(null);
1420+
// Auto wake up by calling the wake up function
1421+
wakeUpAssistant();
1422+
}, sleepState.sleepDuration);
1423+
1424+
setSleepTimeoutId(timeoutId);
1425+
12601426
// Step 1: Close message bubble and start transition simultaneously
12611427
setIsExpanded(false);
12621428
setShowContextualOptions(false);
@@ -1281,6 +1447,16 @@ const AvatarAssistant = () => {
12811447

12821448
// Wake up assistant (user-initiated show)
12831449
const wakeUpAssistant = () => {
1450+
// Clear any existing sleep timeout
1451+
if (sleepTimeoutId) {
1452+
clearTimeout(sleepTimeoutId);
1453+
setSleepTimeoutId(null);
1454+
}
1455+
1456+
// Clear persistent sleep state
1457+
sleepStorage.clear();
1458+
setPersistentSleep(null);
1459+
12841460
// Track user interaction
12851461
setUserInteractionCount(prev => prev + 1);
12861462
setLastInteractionTime(Date.now());
@@ -1371,7 +1547,7 @@ const AvatarAssistant = () => {
13711547
<div
13721548
className="sleeping-avatar-size cursor-pointer transition-opacity duration-300 ease-in-out pointer-events-auto"
13731549
onClick={wakeUpAssistant}
1374-
title="Click to wake up your assistant"
1550+
title={`Click to wake up your assistant${getRemainingSleepaTime() ? ` (${getRemainingSleepaTime()} min remaining)` : ''}`}
13751551
>
13761552
<img
13771553
src="/assets/avatars/assistant-red-sleeping.png"

0 commit comments

Comments
 (0)