diff --git a/src/components/agents/shared/AgentChat.tsx b/src/components/agents/shared/AgentChat.tsx index e52fcc2c..d815c9fc 100644 --- a/src/components/agents/shared/AgentChat.tsx +++ b/src/components/agents/shared/AgentChat.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Plus, X, PanelLeftClose, Search, Trash2, Clock, MessageSquare, Activity, ChevronDown, Cloud, Check, Loader2, AlertCircle } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import type { AgentConfig } from '../../../config/activities'; @@ -115,10 +116,15 @@ export function AgentChat({ const currentAgentId = useRef(agent.id); const currentNametag = useRef(null); const lastSavedMessagesRef = useRef(''); + const isMountedRef = useRef(true); // Get nametag from wallet for user identification const { nametag } = useWallet(); + // URL-based session persistence (survives responsive re-mounts) + const [searchParams, setSearchParams] = useSearchParams(); + const urlSessionId = searchParams.get('session'); + // Chat history hook - bound to nametag so each user has their own history const { sessions, @@ -127,9 +133,11 @@ export function AgentChat({ deleteSession, clearAllHistory, resetCurrentSession, + showDeleteSuccess, saveCurrentMessages, searchSessions, syncState, + syncImmediately, justDeleted, } = useChatHistory({ agentId: agent.id, @@ -183,6 +191,14 @@ export function AgentChat({ } }; + // Track component mount state for async operations + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + // Reset state when agent changes useEffect(() => { if (currentAgentId.current !== agent.id) { @@ -211,6 +227,43 @@ export function AgentChat({ } }, [nametag, setMessages, useMockMode]); + // Restore session from URL on mount (survives responsive re-mounts) + useEffect(() => { + if (urlSessionId && sessions.length > 0 && !currentSession && !hasGreeted.current) { + const sessionExists = sessions.some(s => s.id === urlSessionId); + if (sessionExists) { + const sessionMessages = loadSession(urlSessionId); + if (sessionMessages.length > 0) { + const agentMessages: AgentMessage[] = sessionMessages.map(m => ({ + id: m.id, + role: m.role, + content: m.content, + timestamp: m.timestamp, + thinking: m.thinking, + })); + setExtendedMessages(agentMessages); + if (!useMockMode) { + setMessages(sessionMessages); + } + hasGreeted.current = true; + lastSavedMessagesRef.current = JSON.stringify(sessionMessages.map(m => ({ id: m.id, content: m.content }))); + } + } + } + }, [urlSessionId, sessions, currentSession, loadSession, setMessages, useMockMode]); + + // Update URL when session changes (but don't clear URL until sessions are loaded) + useEffect(() => { + const currentUrlSession = searchParams.get('session'); + if (currentSession?.id && currentSession.id !== currentUrlSession) { + setSearchParams({ session: currentSession.id }, { replace: true }); + } else if (!currentSession && currentUrlSession && sessions.length > 0) { + // Only clear URL after sessions are loaded (to allow restore attempt first) + searchParams.delete('session'); + setSearchParams(searchParams, { replace: true }); + } + }, [currentSession, searchParams, setSearchParams, sessions.length]); + // Sync messages from useAgentChat hook (for non-mock mode) useEffect(() => { if (useMockMode) return; @@ -262,9 +315,9 @@ export function AgentChat({ return () => clearTimeout(timeoutId); }, [extendedMessages, isTyping, saveCurrentMessages]); - // Greeting message + // Greeting message (skip if restoring session from URL) useEffect(() => { - if (!hasGreeted.current && extendedMessages.length === 0 && agent.greetingMessage) { + if (!hasGreeted.current && extendedMessages.length === 0 && agent.greetingMessage && !urlSessionId) { hasGreeted.current = true; if (useMockMode) { setExtendedMessages([{ @@ -282,7 +335,7 @@ export function AgentChat({ }]); } } - }, [agent.greetingMessage, extendedMessages.length, setMessages, useMockMode]); + }, [agent.greetingMessage, extendedMessages.length, setMessages, useMockMode, urlSessionId]); const scrollToBottom = useCallback((instant = false) => { const el = messagesContainerRef.current; @@ -418,20 +471,41 @@ export function AgentChat({ } }, [loadSession, setMessages, useMockMode]); - const handleDeleteSession = (sessionId: string) => { + const handleDeleteSession = async (sessionId: string) => { + const wasCurrentSession = currentSession?.id === sessionId; deleteSession(sessionId); setShowDeleteConfirm(null); // If we deleted the current session, start a new chat - if (currentSession?.id === sessionId) { + if (wasCurrentSession) { handleNewChat(); } + + // Wait for IPFS sync then show success + try { + await syncImmediately(); + if (isMountedRef.current) { + showDeleteSuccess(); + } + } catch (error) { + console.error('Failed to sync after deleting session:', error); + } }; - const handleClearAllHistory = () => { + const handleClearAllHistory = async () => { clearAllHistory(); setShowClearAllConfirm(false); handleNewChat(); + + // Sync in background, show success after completion + try { + await syncImmediately(); + if (isMountedRef.current) { + showDeleteSuccess(); + } + } catch (error) { + console.error('Failed to sync after clearing history:', error); + } }; const getActionLabel = (cardData: TCardData): string => { @@ -988,7 +1062,7 @@ export function AgentChat({ return ( <> {/* Layout with left sidebar for chat history */} -
+
{renderHistorySidebar()} diff --git a/src/components/agents/shared/ChatHistoryIpfsService.ts b/src/components/agents/shared/ChatHistoryIpfsService.ts index 5ddcaa54..5e8e2f43 100644 --- a/src/components/agents/shared/ChatHistoryIpfsService.ts +++ b/src/components/agents/shared/ChatHistoryIpfsService.ts @@ -75,6 +75,7 @@ export type SyncStep = export interface ChatSyncStatus { initialized: boolean; isSyncing: boolean; + hasPendingSync: boolean; // True when sync is scheduled but not yet started (debounce period) lastSync: ChatSyncResult | null; ipnsName: string | null; currentStep: SyncStep; @@ -88,6 +89,7 @@ export interface ChatSyncStatus { // Different HKDF info string creates a separate key for chat storage const HKDF_INFO_CHAT = "ipfs-chat-history-ed25519-v1"; const SYNC_DEBOUNCE_MS = 3000; +const TOMBSTONE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days // ========================================== // ChatHistoryIpfsService @@ -147,6 +149,9 @@ export class ChatHistoryIpfsService { return; } + // Clean up old tombstones on startup (once per session) + this.cleanupOldTombstones(); + this.boundSyncHandler = () => { this.hasPendingChanges = true; this.scheduleSync(); @@ -851,8 +856,12 @@ export class ChatHistoryIpfsService { } console.log(`💬 Scheduling sync in ${SYNC_DEBOUNCE_MS}ms`); this.syncTimer = setTimeout(() => { + this.syncTimer = null; this.syncNow().catch(console.error); }, SYNC_DEBOUNCE_MS); + + // Notify listeners that a sync is now pending + this.notifyStatusListeners(); } /** @@ -972,12 +981,18 @@ export class ChatHistoryIpfsService { } } + // Queue for pending sync requests (coalesces multiple requests into one) + private pendingSyncResolvers: Array<(result: ChatSyncResult) => void> = []; + async syncNow(): Promise { console.log(`💬 syncNow called`); + // If already syncing, queue this request and wait for next sync if (this.isSyncing) { - console.log(`💬 syncNow: already syncing, skipping`); - return { success: false, timestamp: Date.now(), error: "Sync in progress" }; + console.log(`💬 syncNow: already syncing, queuing request...`); + return new Promise((resolve) => { + this.pendingSyncResolvers.push(resolve); + }); } this.isSyncing = true; @@ -1033,7 +1048,27 @@ export class ChatHistoryIpfsService { } finally { this.isSyncing = false; - if (this.pendingSync) { + // Handle coalesced sync requests - run ONE more sync to resolve all queued promises + if (this.pendingSyncResolvers.length > 0) { + const resolvers = this.pendingSyncResolvers; + this.pendingSyncResolvers = []; + console.log(`💬 syncNow: resolving ${resolvers.length} queued request(s) with a final sync`); + + // Run one final sync that captures the latest localStorage state + // This single sync handles all the changes that were queued + this.syncNow().then((result) => { + resolvers.forEach(resolve => resolve(result)); + }).catch((error) => { + // On error, still resolve with an error result + const errorResult: ChatSyncResult = { + success: false, + timestamp: Date.now(), + error: String(error), + }; + resolvers.forEach(resolve => resolve(errorResult)); + }); + } else if (this.pendingSync) { + // Handle debounced sync requests (from scheduleSync) this.pendingSync = false; this.scheduleSync(); } else { @@ -1202,9 +1237,7 @@ export class ChatHistoryIpfsService { }; localStorage.setItem(STORAGE_KEYS.AGENT_CHAT_TOMBSTONES, JSON.stringify(tombstones)); - - // Trigger sync - this.scheduleSync(); + // Sync is triggered by ChatHistoryRepository.notifyUpdate() → TanStack hook } /** @@ -1226,9 +1259,39 @@ export class ChatHistoryIpfsService { } localStorage.setItem(STORAGE_KEYS.AGENT_CHAT_TOMBSTONES, JSON.stringify(tombstones)); + // Sync is triggered by ChatHistoryRepository.notifyUpdate() → TanStack hook + } + + /** + * Clean up tombstones older than TOMBSTONE_MAX_AGE_MS (30 days) + * Old tombstones are unlikely to be needed for sync conflict resolution + * and just waste storage space. + */ + cleanupOldTombstones(): number { + const tombstonesRaw = localStorage.getItem(STORAGE_KEYS.AGENT_CHAT_TOMBSTONES); + if (!tombstonesRaw) return 0; + + const tombstones: Record = JSON.parse(tombstonesRaw); + const now = Date.now(); + const cutoffTime = now - TOMBSTONE_MAX_AGE_MS; + + let removedCount = 0; + const remainingTombstones: Record = {}; + + for (const [sessionId, tombstone] of Object.entries(tombstones)) { + if (tombstone.deletedAt >= cutoffTime) { + remainingTombstones[sessionId] = tombstone; + } else { + removedCount++; + } + } + + if (removedCount > 0) { + localStorage.setItem(STORAGE_KEYS.AGENT_CHAT_TOMBSTONES, JSON.stringify(remainingTombstones)); + console.log(`💬 Cleaned up ${removedCount} old tombstone(s) (older than 30 days)`); + } - // Trigger sync - this.scheduleSync(); + return removedCount; } /** @@ -1290,6 +1353,7 @@ export class ChatHistoryIpfsService { return { initialized: this.helia !== null, isSyncing: this.isSyncing, + hasPendingSync: this.syncTimer !== null || this.hasPendingChanges, lastSync: this.lastSync, ipnsName: this.cachedIpnsName, currentStep: this.currentStep, diff --git a/src/components/agents/shared/ChatHistoryRepository.ts b/src/components/agents/shared/ChatHistoryRepository.ts index 9d528df4..dfb3761b 100644 --- a/src/components/agents/shared/ChatHistoryRepository.ts +++ b/src/components/agents/shared/ChatHistoryRepository.ts @@ -22,6 +22,8 @@ import { STORAGE_KEYS, STORAGE_KEY_GENERATORS } from '../../../config/storageKey const MAX_STORAGE_SIZE = 4 * 1024 * 1024; // Maximum number of sessions to keep per agent const MAX_SESSIONS_PER_AGENT = 50; +// Maximum messages per session in localStorage (full history on IPFS) +const MAX_MESSAGES_PER_SESSION = 100; export interface ChatSession { id: string; @@ -153,13 +155,15 @@ export class ChatHistoryRepository { if (initialMessage) { this.saveMessages(session.id, [initialMessage]); - } else { - // Trigger IPFS sync even without initial message - try { - getChatHistoryIpfsService().scheduleSync(); - } catch (e) { - console.warn('[ChatHistory] Failed to schedule IPFS sync:', e); - } + } + + // Immediately sync session creation to IPFS (critical operation) + try { + getChatHistoryIpfsService().syncImmediately().catch(e => { + console.warn('[ChatHistory] Failed to sync session creation:', e); + }); + } catch (e) { + console.warn('[ChatHistory] Failed to trigger IPFS sync:', e); } this.notifyUpdate(); @@ -190,9 +194,13 @@ export class ChatHistoryRepository { localStorage.removeItem(this.getMessagesKey(sessionId)); } - // Record tombstone for IPFS sync + // Record tombstone and immediately sync to IPFS (critical operation) try { - getChatHistoryIpfsService().recordSessionDeletion(sessionId); + const ipfsService = getChatHistoryIpfsService(); + ipfsService.recordSessionDeletion(sessionId); + ipfsService.syncImmediately().catch(e => { + console.warn('[ChatHistory] Failed to sync session deletion:', e); + }); } catch (e) { console.warn('[ChatHistory] Failed to record IPFS tombstone:', e); } @@ -216,10 +224,14 @@ export class ChatHistoryRepository { } }); - // Record tombstones for IPFS sync + // Record tombstones and immediately sync to IPFS (critical operation) if (agentSessions.length > 0) { try { - getChatHistoryIpfsService().recordBulkDeletion(agentSessions.map(s => s.id)); + const ipfsService = getChatHistoryIpfsService(); + ipfsService.recordBulkDeletion(agentSessions.map(s => s.id)); + ipfsService.syncImmediately().catch(e => { + console.warn('[ChatHistory] Failed to sync bulk deletion:', e); + }); } catch (e) { console.warn('[ChatHistory] Failed to record IPFS bulk tombstones:', e); } @@ -239,10 +251,14 @@ export class ChatHistoryRepository { localStorage.removeItem(this.getMessagesKey(s.id)); }); - // Record tombstones for IPFS sync + // Record tombstones and immediately sync to IPFS (critical operation) if (sessions.length > 0) { try { - getChatHistoryIpfsService().recordBulkDeletion(sessions.map(s => s.id)); + const ipfsService = getChatHistoryIpfsService(); + ipfsService.recordBulkDeletion(sessions.map(s => s.id)); + ipfsService.syncImmediately().catch(e => { + console.warn('[ChatHistory] Failed to sync clear all:', e); + }); } catch (e) { console.warn('[ChatHistory] Failed to record IPFS bulk tombstones:', e); } @@ -306,15 +322,25 @@ export class ChatHistoryRepository { // Get session info for logging const session = this.getSession(sessionId); - console.log(`💬 [Repository] saveMessages: sessionId=${sessionId.slice(0, 8)}..., agentId=${session?.agentId || 'unknown'}, messageCount=${messages.length}`); + const totalMessageCount = messages.length; + console.log(`💬 [Repository] saveMessages: sessionId=${sessionId.slice(0, 8)}..., agentId=${session?.agentId || 'unknown'}, messageCount=${totalMessageCount}`); // Check storage size before saving if (this.getStorageSize() > MAX_STORAGE_SIZE) { this.cleanupOldSessions(); } + // Trim to MAX_MESSAGES_PER_SESSION for localStorage (full history on IPFS) + const messagesToStore = messages.length > MAX_MESSAGES_PER_SESSION + ? messages.slice(-MAX_MESSAGES_PER_SESSION) + : messages; + + if (messages.length > MAX_MESSAGES_PER_SESSION) { + console.log(`💬 [Repository] Trimming ${messages.length} messages to ${MAX_MESSAGES_PER_SESSION} for localStorage`); + } + try { - localStorage.setItem(this.getMessagesKey(sessionId), JSON.stringify(messages)); + localStorage.setItem(this.getMessagesKey(sessionId), JSON.stringify(messagesToStore)); // Update session metadata const lastMessage = messages[messages.length - 1]; @@ -339,7 +365,7 @@ export class ChatHistoryRepository { this.cleanupOldSessions(); // Retry once try { - localStorage.setItem(this.getMessagesKey(sessionId), JSON.stringify(messages)); + localStorage.setItem(this.getMessagesKey(sessionId), JSON.stringify(messagesToStore)); } catch { console.error('[ChatHistory] Failed to save messages after cleanup'); } diff --git a/src/components/agents/shared/useChatHistory.ts b/src/components/agents/shared/useChatHistory.ts index abc0037c..c9d0a17d 100644 --- a/src/components/agents/shared/useChatHistory.ts +++ b/src/components/agents/shared/useChatHistory.ts @@ -30,6 +30,7 @@ interface UseChatHistoryReturn { deleteSession: (sessionId: string) => void; clearAllHistory: () => void; resetCurrentSession: () => void; + showDeleteSuccess: () => void; // Message management saveCurrentMessages: (messages: ChatMessage[]) => void; @@ -43,6 +44,7 @@ interface UseChatHistoryReturn { // IPFS sync status (TanStack Query based) syncState: SyncState; + syncImmediately: () => Promise; } export function useChatHistory({ @@ -59,12 +61,12 @@ export function useChatHistory({ const userIdRef = useRef(userId); // TanStack Query based IPFS sync - const { syncState } = useChatHistorySync({ + const { syncState, syncImmediately } = useChatHistorySync({ userId, enabled: enabled && !!userId, }); - // Keep refs in sync + // Keep refs in sync with current session useEffect(() => { currentSessionRef.current = currentSession; }, [currentSession]); @@ -84,15 +86,17 @@ export function useChatHistory({ const agentSessions = chatHistoryRepository.getSessionsForAgent(agentId, userId); setSessions(agentSessions); setIsLoading(false); + // Note: Active session is restored via URL param (?session=id) in AgentChat + setCurrentSession(null); }; - // Reset current session when user changes - setCurrentSession(null); loadSessions(); // Listen for updates from other tabs/components and IPFS sync const handleUpdate = () => { - loadSessions(); + // Reload sessions but preserve current session if still valid + const agentSessions = chatHistoryRepository.getSessionsForAgent(agentId, userId); + setSessions(agentSessions); }; window.addEventListener('agent-chat-history-updated', handleUpdate); @@ -123,6 +127,7 @@ export function useChatHistory({ }, []); // Delete a session + // Note: Does NOT show success message - caller should call showDeleteSuccess() after sync const deleteSession = useCallback((sessionId: string) => { chatHistoryRepository.deleteSession(sessionId); setSessions(prev => prev.filter(s => s.id !== sessionId)); @@ -131,22 +136,21 @@ export function useChatHistory({ if (currentSessionRef.current?.id === sessionId) { setCurrentSession(null); } - - // Show success message - setJustDeleted(true); - setTimeout(() => setJustDeleted(false), 2000); }, []); // Clear all history for this agent and user + // Note: Does NOT show success message - caller should call showDeleteSuccess() after sync const clearAllHistory = useCallback(() => { chatHistoryRepository.deleteAllSessionsForAgent(agentId, userIdRef.current); setSessions([]); setCurrentSession(null); + }, [agentId]); - // Show success message + // Show delete success message (call after sync completes) + const showDeleteSuccess = useCallback(() => { setJustDeleted(true); setTimeout(() => setJustDeleted(false), 2000); - }, [agentId]); + }, []); // Reset current session (for starting new chat) const resetCurrentSession = useCallback(() => { @@ -202,10 +206,12 @@ export function useChatHistory({ deleteSession, clearAllHistory, resetCurrentSession, + showDeleteSuccess, saveCurrentMessages, searchSessions, isLoading, justDeleted, syncState, + syncImmediately, }; } diff --git a/src/components/agents/shared/useChatHistorySync.ts b/src/components/agents/shared/useChatHistorySync.ts index c201a08d..2c33d7d9 100644 --- a/src/components/agents/shared/useChatHistorySync.ts +++ b/src/components/agents/shared/useChatHistorySync.ts @@ -10,13 +10,14 @@ */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useRef, useEffect, useState } from 'react'; +import { useCallback, useRef, useEffect } from 'react'; import { getChatHistoryIpfsService, type SyncStep } from './ChatHistoryIpfsService'; // Query keys export const chatHistorySyncKeys = { all: ['chat-history-sync'] as const, ipns: (userId: string) => [...chatHistorySyncKeys.all, 'ipns', userId] as const, + status: () => [...chatHistorySyncKeys.all, 'status'] as const, }; interface UseChatHistorySyncOptions { @@ -54,24 +55,35 @@ export function useChatHistorySync({ const syncTimerRef = useRef | null>(null); const pendingSyncRef = useRef(false); - // Track detailed step from IPFS service - const [currentStep, setCurrentStep] = useState('idle'); - const [stepProgress, setStepProgress] = useState(''); + // Global sync status via TanStack Query (shared across all components) + const { data: serviceStatus } = useQuery({ + queryKey: chatHistorySyncKeys.status(), + queryFn: () => { + const ipfsService = getChatHistoryIpfsService(); + return ipfsService.getStatus(); + }, + staleTime: Infinity, // Only update via setQueryData from subscription + gcTime: Infinity, // Never garbage collect + }); - // Subscribe to IPFS service status changes - useEffect(() => { - if (!enabled || !userId) return; + const currentStep = serviceStatus?.currentStep ?? 'idle'; + const stepProgress = serviceStatus?.stepProgress ?? ''; + // Subscribe to IPFS service status changes and update TanStack Query cache + useEffect(() => { const ipfsService = getChatHistoryIpfsService(); + // Update cache with current status on mount + queryClient.setQueryData(chatHistorySyncKeys.status(), ipfsService.getStatus()); + + // Subscribe to future changes and update cache const unsubscribe = ipfsService.onStatusChange(() => { const status = ipfsService.getStatus(); - setCurrentStep(status.currentStep); - setStepProgress(status.stepProgress || ''); + queryClient.setQueryData(chatHistorySyncKeys.status(), status); }); return unsubscribe; - }, [enabled, userId]); + }, [queryClient]); // Query: Initial load and periodic sync from IPNS const { @@ -183,16 +195,21 @@ export function useChatHistorySync({ }; }, []); + // Derive sync state from service's currentStep (global) rather than mutation state (local) + // This ensures consistent status across component remounts + const isServiceSyncing = currentStep !== 'idle' && currentStep !== 'complete' && currentStep !== 'error'; + const isServiceUploading = currentStep === 'building-data' || currentStep === 'uploading' || currentStep === 'publishing-ipns'; + // Computed state for UI const syncState: SyncState = { isInitialLoading, - isFetching, + isFetching: isFetching || isServiceSyncing, isRefetching, fetchError: fetchError as Error | null, - isUploading, + isUploading: isUploading || isServiceUploading, uploadError: uploadError as Error | null, - isSyncing: isFetching || isUploading, - isError: !!fetchError || !!uploadError, + isSyncing: isFetching || isUploading || isServiceSyncing, + isError: !!fetchError || !!uploadError || currentStep === 'error', lastSyncTime: syncResult?.timestamp || null, sessionCount: syncResult?.sessionCount ?? null, currentStep, diff --git a/src/components/wallet/L1/components/modals/DeleteConfirmationModal.tsx b/src/components/wallet/L1/components/modals/DeleteConfirmationModal.tsx index e11d39d4..7de52cab 100644 --- a/src/components/wallet/L1/components/modals/DeleteConfirmationModal.tsx +++ b/src/components/wallet/L1/components/modals/DeleteConfirmationModal.tsx @@ -1,5 +1,7 @@ -import { AlertTriangle, Download } from "lucide-react"; -import { motion } from "framer-motion"; +import { AlertTriangle, Download, Loader2, ShieldAlert, X, Trash2 } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useGlobalSyncStatus } from "../../../../../hooks/useGlobalSyncStatus"; +import { useState } from "react"; interface DeleteConfirmationModalProps { show: boolean; @@ -14,8 +16,37 @@ export function DeleteConfirmationModal({ onSaveFirst, onCancel, }: DeleteConfirmationModalProps) { + const { isAnySyncing, statusMessage } = useGlobalSyncStatus(); + const [showSyncWarning, setShowSyncWarning] = useState(false); + if (!show) return null; + const handleDeleteClick = () => { + if (isAnySyncing) { + // Show sync warning instead of deleting immediately + setShowSyncWarning(true); + } else { + onConfirmDelete(); + } + }; + + const handleForceDelete = () => { + // User acknowledged the risk + setShowSyncWarning(false); + onConfirmDelete(); + }; + + const handleCloseSyncWarning = () => { + setShowSyncWarning(false); + onCancel(); + }; + + // When sync completes while on sync warning screen, allow deletion + const handleSyncCompleteDelete = () => { + setShowSyncWarning(false); + onConfirmDelete(); + }; + return ( - e.stopPropagation()} - > - + + {showSyncWarning ? ( + // Sync Warning Modal e.stopPropagation()} > + {/* Close button */} + + - + + {isAnySyncing ? ( + + ) : ( + + )} + +

+ {isAnySyncing ? "Sync in Progress" : "Sync Complete"} +

+

+ {isAnySyncing + ? "Your data is being synchronized to IPFS." + : "All data has been synchronized."} +

+

+ {statusMessage} +

+ {isAnySyncing && ( +

+ Deleting now may result in data loss on other devices. +
+ Please wait for sync to complete. +

+ )}
-
-

- Delete Wallet? -

-

- Are you sure you want to delete this wallet?
- - This action cannot be undone. - -

-

- If you haven't saved your backup, your funds will be lost - forever. -

-
- - - - Save Backup First - + + + {isAnySyncing ? ( + <> + + Waiting for Sync... + + ) : ( + <> + + Delete Wallet + + )} + -
- + + I Understand the Risks - Delete Now + + + + ) : ( + // Original Delete Confirmation Modal + e.stopPropagation()} + > + - Cancel - - + + + + +

+ Delete Wallet? +

+

+ Are you sure you want to delete this wallet?
+ + This action cannot be undone. + +

+

+ If you haven't saved your backup, your funds will be lost + forever. +

+
+ + - Delete Anyway - -
-
-
+ + + Save Backup First + + +
+ + Cancel + + + Delete Anyway + +
+
+
+ )} + ); } diff --git a/src/hooks/useGlobalSyncStatus.ts b/src/hooks/useGlobalSyncStatus.ts new file mode 100644 index 00000000..c707100f --- /dev/null +++ b/src/hooks/useGlobalSyncStatus.ts @@ -0,0 +1,185 @@ +/** + * useGlobalSyncStatus - Aggregates IPFS sync status from all services + * + * Monitors sync status from: + * - ChatHistoryIpfsService (chat history) + * - IpfsStorageService (tokens) + * + * Used to prevent wallet deletion while sync is in progress. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { getChatHistoryIpfsService, type SyncStep } from '../components/agents/shared/ChatHistoryIpfsService'; +import { IpfsStorageService } from '../components/wallet/L3/services/IpfsStorageService'; +import { IdentityManager } from '../components/wallet/L3/services/IdentityManager'; + +// Session key (same as useWallet.ts) +const SESSION_KEY = 'user-pin-1234'; + +export interface GlobalSyncStatus { + // Individual service states + chatSyncing: boolean; + chatStep: SyncStep; + tokenSyncing: boolean; + + // Combined state + isAnySyncing: boolean; + + // Human-readable status + statusMessage: string; +} + +export function useGlobalSyncStatus(): GlobalSyncStatus { + const [chatSyncing, setChatSyncing] = useState(false); + const [chatStep, setChatStep] = useState('idle'); + const [tokenSyncing, setTokenSyncing] = useState(false); + + // Subscribe to chat history sync status + useEffect(() => { + const chatService = getChatHistoryIpfsService(); + + // Get initial status + const updateChatStatus = () => { + const status = chatService.getStatus(); + // Consider syncing if: actively syncing, has pending sync (debounce period), or in a sync step + const isSyncing = status.isSyncing || + status.hasPendingSync || + (status.currentStep !== 'idle' && status.currentStep !== 'complete' && status.currentStep !== 'error'); + setChatSyncing(isSyncing); + setChatStep(status.currentStep); + }; + + updateChatStatus(); + + // Subscribe to changes + const unsubscribe = chatService.onStatusChange(updateChatStatus); + + return unsubscribe; + }, []); + + // Subscribe to token storage sync status + useEffect(() => { + const identityManager = IdentityManager.getInstance(SESSION_KEY); + const tokenService = IpfsStorageService.getInstance(identityManager); + + // Get initial status + setTokenSyncing(tokenService.isCurrentlySyncing()); + + // Listen for sync state changes via custom event + const handleSyncEvent = (e: CustomEvent) => { + if (e.detail?.type === 'sync:state-changed' && e.detail.data?.isSyncing !== undefined) { + setTokenSyncing(e.detail.data.isSyncing); + } + }; + + window.addEventListener('ipfs-storage-event', handleSyncEvent as EventListener); + + // Poll for token sync status as backup (events may be missed during initialization) + const pollInterval = setInterval(() => { + const currentSyncing = tokenService.isCurrentlySyncing(); + setTokenSyncing(currentSyncing); + }, 500); + + return () => { + window.removeEventListener('ipfs-storage-event', handleSyncEvent as EventListener); + clearInterval(pollInterval); + }; + }, []); + + const isAnySyncing = chatSyncing || tokenSyncing; + + // Generate human-readable status message + const getStatusMessage = useCallback((): string => { + const parts: string[] = []; + + if (chatSyncing) { + switch (chatStep) { + case 'initializing': + parts.push('Initializing...'); + break; + case 'resolving-ipns': + parts.push('Resolving chat history...'); + break; + case 'fetching-content': + parts.push('Fetching chat history...'); + break; + case 'importing-data': + parts.push('Importing chat data...'); + break; + case 'building-data': + parts.push('Preparing chat data...'); + break; + case 'uploading': + parts.push('Uploading chat history...'); + break; + case 'publishing-ipns': + parts.push('Publishing chat to network...'); + break; + case 'idle': + case 'complete': + case 'error': + // If chatSyncing is true but step is idle/complete/error, + // it means we have a pending sync (debounce period) + parts.push('Preparing to sync chat...'); + break; + default: + parts.push('Syncing chat history...'); + } + } + + if (tokenSyncing) { + parts.push('Syncing tokens...'); + } + + if (parts.length === 0) { + return 'All data synced'; + } + + return parts.join(' '); + }, [chatSyncing, chatStep, tokenSyncing]); + + return { + chatSyncing, + chatStep, + tokenSyncing, + isAnySyncing, + statusMessage: getStatusMessage(), + }; +} + +/** + * Utility function to wait for all syncs to complete + * Returns a promise that resolves when no sync is in progress + */ +export async function waitForAllSyncsToComplete(timeoutMs: number = 60000): Promise { + const chatService = getChatHistoryIpfsService(); + const identityManager = IdentityManager.getInstance(SESSION_KEY); + const tokenService = IpfsStorageService.getInstance(identityManager); + + const startTime = Date.now(); + + return new Promise((resolve) => { + const checkSync = () => { + const chatStatus = chatService.getStatus(); + const tokenSyncing = tokenService.isCurrentlySyncing(); + + // Check both active sync and pending sync (debounce period) + const chatBusy = chatStatus.isSyncing || chatStatus.hasPendingSync; + + if (!chatBusy && !tokenSyncing) { + resolve(true); + return; + } + + if (Date.now() - startTime > timeoutMs) { + resolve(false); // Timeout + return; + } + + // Check again in 500ms + setTimeout(checkSync, 500); + }; + + checkSync(); + }); +} diff --git a/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts b/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts new file mode 100644 index 00000000..560ab17f --- /dev/null +++ b/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { STORAGE_KEYS } from "../../../../../src/config/storageKeys"; + +// ========================================== +// Mock Setup +// ========================================== + +// Mock IdentityManager +vi.mock("../../../../../src/components/wallet/L3/services/IdentityManager", () => ({ + IdentityManager: { + getInstance: vi.fn(() => ({ + getMasterSeed: vi.fn().mockReturnValue(new Uint8Array(32).fill(1)), + getWalletAddress: vi.fn().mockReturnValue("0x123"), + })), + }, +})); + +// Mock IPFS config +vi.mock("../../../../../src/config/ipfs.config", () => ({ + getBootstrapPeers: vi.fn(() => []), + getAllBackendGatewayUrls: vi.fn(() => []), + IPNS_RESOLUTION_CONFIG: { timeout: 1000 }, + IPFS_CONFIG: { timeout: 1000 }, +})); + +// Mock Helia and related +vi.mock("helia", () => ({ + createHelia: vi.fn(), +})); + +vi.mock("@helia/json", () => ({ + json: vi.fn(), +})); + +vi.mock("@libp2p/bootstrap", () => ({ + bootstrap: vi.fn(), +})); + +vi.mock("@libp2p/crypto/keys", () => ({ + generateKeyPairFromSeed: vi.fn(), +})); + +vi.mock("@libp2p/peer-id", () => ({ + peerIdFromPrivateKey: vi.fn(), +})); + +vi.mock("ipns", () => ({ + createIPNSRecord: vi.fn(), + marshalIPNSRecord: vi.fn(), + unmarshalIPNSRecord: vi.fn(), +})); + +// Mock ChatHistoryRepository +vi.mock("../../../../../src/components/agents/shared/ChatHistoryRepository", () => ({ + chatHistoryRepository: { + getAllSessions: vi.fn(() => []), + getMessages: vi.fn(() => []), + }, +})); + +// Import after mocking +import { + ChatHistoryIpfsService, + getChatHistoryIpfsService, +} from "../../../../../src/components/agents/shared/ChatHistoryIpfsService"; + +// ========================================== +// ChatHistoryIpfsService Tests +// ========================================== + +describe("ChatHistoryIpfsService", () => { + let localStorageMock: Record; + + beforeEach(() => { + localStorageMock = {}; + + // Mock localStorage + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => localStorageMock[key] || null), + setItem: vi.fn((key: string, value: string) => { + localStorageMock[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete localStorageMock[key]; + }), + clear: vi.fn(() => { + localStorageMock = {}; + }), + key: vi.fn((index: number) => Object.keys(localStorageMock)[index] || null), + get length() { + return Object.keys(localStorageMock).length; + }, + }); + + // Mock window + vi.stubGlobal("window", { + ...globalThis.window, + dispatchEvent: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + // ========================================== + // Singleton Tests + // ========================================== + + describe("getInstance", () => { + it("should return singleton instance", () => { + const instance1 = ChatHistoryIpfsService.getInstance(); + const instance2 = ChatHistoryIpfsService.getInstance(); + + expect(instance1).toBe(instance2); + }); + }); + + describe("getChatHistoryIpfsService", () => { + it("should return singleton via accessor function", () => { + const instance1 = getChatHistoryIpfsService(); + const instance2 = getChatHistoryIpfsService(); + + expect(instance1).toBe(instance2); + expect(instance1).toBe(ChatHistoryIpfsService.getInstance()); + }); + }); + + // ========================================== + // Tombstone Management Tests + // ========================================== + + describe("recordSessionDeletion", () => { + it("should record tombstone for deleted session", () => { + const service = getChatHistoryIpfsService(); + + service.recordSessionDeletion("session-1"); + + const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(tombstones["session-1"]).toBeDefined(); + expect(tombstones["session-1"].sessionId).toBe("session-1"); + expect(tombstones["session-1"].reason).toBe("user-deleted"); + }); + + it("should preserve existing tombstones", () => { + localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ + "existing-session": { + sessionId: "existing-session", + deletedAt: Date.now() - 1000, + reason: "user-deleted", + }, + }); + + const service = getChatHistoryIpfsService(); + service.recordSessionDeletion("new-session"); + + const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(tombstones["existing-session"]).toBeDefined(); + expect(tombstones["new-session"]).toBeDefined(); + }); + }); + + describe("recordBulkDeletion", () => { + it("should record tombstones for multiple sessions", () => { + const service = getChatHistoryIpfsService(); + + service.recordBulkDeletion(["session-1", "session-2", "session-3"]); + + const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(Object.keys(tombstones)).toHaveLength(3); + expect(tombstones["session-1"].reason).toBe("clear-all"); + expect(tombstones["session-2"].reason).toBe("clear-all"); + expect(tombstones["session-3"].reason).toBe("clear-all"); + }); + + it("should use same timestamp for all tombstones in bulk", () => { + const service = getChatHistoryIpfsService(); + + service.recordBulkDeletion(["session-1", "session-2"]); + + const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(tombstones["session-1"].deletedAt).toBe(tombstones["session-2"].deletedAt); + }); + }); + + describe("cleanupOldTombstones", () => { + it("should return 0 when no tombstones exist", () => { + const service = getChatHistoryIpfsService(); + + const removed = service.cleanupOldTombstones(); + + expect(removed).toBe(0); + }); + + it("should remove tombstones older than 30 days", () => { + const now = Date.now(); + const thirtyOneDaysAgo = now - 31 * 24 * 60 * 60 * 1000; + const twentyDaysAgo = now - 20 * 24 * 60 * 60 * 1000; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ + "old-session": { + sessionId: "old-session", + deletedAt: thirtyOneDaysAgo, + reason: "user-deleted", + }, + "recent-session": { + sessionId: "recent-session", + deletedAt: twentyDaysAgo, + reason: "user-deleted", + }, + }); + + const service = getChatHistoryIpfsService(); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const removed = service.cleanupOldTombstones(); + + expect(removed).toBe(1); + + const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(tombstones["old-session"]).toBeUndefined(); + expect(tombstones["recent-session"]).toBeDefined(); + + consoleSpy.mockRestore(); + }); + + it("should keep tombstones exactly at 30 days", () => { + const now = Date.now(); + const exactlyThirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ + "boundary-session": { + sessionId: "boundary-session", + deletedAt: exactlyThirtyDaysAgo, + reason: "user-deleted", + }, + }); + + const service = getChatHistoryIpfsService(); + + const removed = service.cleanupOldTombstones(); + + expect(removed).toBe(0); + + const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(tombstones["boundary-session"]).toBeDefined(); + }); + + it("should remove all tombstones when all are old", () => { + const thirtyOneDaysAgo = Date.now() - 31 * 24 * 60 * 60 * 1000; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ + "session-1": { + sessionId: "session-1", + deletedAt: thirtyOneDaysAgo - 1000, + reason: "user-deleted", + }, + "session-2": { + sessionId: "session-2", + deletedAt: thirtyOneDaysAgo - 2000, + reason: "clear-all", + }, + }); + + const service = getChatHistoryIpfsService(); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const removed = service.cleanupOldTombstones(); + + expect(removed).toBe(2); + + const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(Object.keys(tombstones)).toHaveLength(0); + + consoleSpy.mockRestore(); + }); + + it("should not modify storage when no tombstones removed", () => { + const recentTime = Date.now() - 1000; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ + "recent-session": { + sessionId: "recent-session", + deletedAt: recentTime, + reason: "user-deleted", + }, + }); + + const service = getChatHistoryIpfsService(); + const setItemSpy = vi.spyOn(localStorage, "setItem"); + + const removed = service.cleanupOldTombstones(); + + expect(removed).toBe(0); + // setItem should not be called when no tombstones removed + expect(setItemSpy).not.toHaveBeenCalledWith( + STORAGE_KEYS.AGENT_CHAT_TOMBSTONES, + expect.any(String) + ); + }); + }); + + // ========================================== + // Status Tests + // ========================================== + + describe("getStatus", () => { + it("should return initial status", () => { + const service = getChatHistoryIpfsService(); + + const status = service.getStatus(); + + expect(status.initialized).toBe(false); + expect(status.isSyncing).toBe(false); + expect(status.hasPendingSync).toBe(false); + expect(status.currentStep).toBe("idle"); + }); + }); + + describe("onStatusChange", () => { + it("should register and call status listener", () => { + const service = getChatHistoryIpfsService(); + const listener = vi.fn(); + + service.onStatusChange(listener); + + // Recording a tombstone triggers status update + service.recordSessionDeletion("test-session"); + + // Listener should be added (will be called on next status change) + expect(listener).not.toHaveBeenCalled(); // Not called immediately + }); + + it("should return unsubscribe function", () => { + const service = getChatHistoryIpfsService(); + const listener = vi.fn(); + + const unsubscribe = service.onStatusChange(listener); + + expect(typeof unsubscribe).toBe("function"); + + // Unsubscribe should work without error + expect(() => unsubscribe()).not.toThrow(); + }); + }); + + // ========================================== + // hasPendingSync Tests + // ========================================== + + describe("hasPendingSync tracking", () => { + it("should include hasPendingSync in status", () => { + const service = getChatHistoryIpfsService(); + + const status = service.getStatus(); + + expect(status).toHaveProperty("hasPendingSync"); + expect(typeof status.hasPendingSync).toBe("boolean"); + }); + }); + + // ========================================== + // clearLocalStateOnly Tests + // ========================================== + + describe("clearLocalStateOnly", () => { + it("should clear tombstones without IPFS sync", () => { + localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ + "session-1": { sessionId: "session-1", deletedAt: Date.now(), reason: "user-deleted" }, + }); + + const service = getChatHistoryIpfsService(); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + service.clearLocalStateOnly(); + + expect(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]).toBeUndefined(); + + consoleSpy.mockRestore(); + }); + }); +}); + +// ========================================== +// Integration-like Tests (using mock) +// ========================================== + +describe("ChatHistoryIpfsService tombstone lifecycle", () => { + let localStorageMock: Record; + + beforeEach(() => { + localStorageMock = {}; + + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => localStorageMock[key] || null), + setItem: vi.fn((key: string, value: string) => { + localStorageMock[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete localStorageMock[key]; + }), + clear: vi.fn(() => { + localStorageMock = {}; + }), + key: vi.fn((index: number) => Object.keys(localStorageMock)[index] || null), + get length() { + return Object.keys(localStorageMock).length; + }, + }); + + vi.stubGlobal("window", { + ...globalThis.window, + dispatchEvent: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("should handle full tombstone lifecycle: create, age, cleanup", () => { + const service = getChatHistoryIpfsService(); + + // Step 1: Record deletions + service.recordSessionDeletion("session-1"); + service.recordBulkDeletion(["session-2", "session-3"]); + + let tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(Object.keys(tombstones)).toHaveLength(3); + + // Step 2: Simulate aging (modify timestamps) + const oldTime = Date.now() - 35 * 24 * 60 * 60 * 1000; // 35 days ago + tombstones["session-1"].deletedAt = oldTime; + tombstones["session-2"].deletedAt = oldTime; + // session-3 stays recent + localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify(tombstones); + + // Step 3: Cleanup + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const removed = service.cleanupOldTombstones(); + + expect(removed).toBe(2); + + tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); + expect(Object.keys(tombstones)).toHaveLength(1); + expect(tombstones["session-3"]).toBeDefined(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/tests/unit/components/agents/shared/ChatHistoryRepository.test.ts b/tests/unit/components/agents/shared/ChatHistoryRepository.test.ts new file mode 100644 index 00000000..19cee733 --- /dev/null +++ b/tests/unit/components/agents/shared/ChatHistoryRepository.test.ts @@ -0,0 +1,629 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { STORAGE_KEYS, STORAGE_KEY_GENERATORS } from "../../../../../src/config/storageKeys"; + +// ========================================== +// Mock Setup +// ========================================== + +// Mock ChatHistoryIpfsService before importing ChatHistoryRepository +vi.mock("../../../../../src/components/agents/shared/ChatHistoryIpfsService", () => ({ + getChatHistoryIpfsService: vi.fn(() => ({ + syncImmediately: vi.fn().mockResolvedValue({ success: true }), + scheduleSync: vi.fn(), + recordSessionDeletion: vi.fn(), + recordBulkDeletion: vi.fn(), + })), +})); + +// Import after mocking +import { + ChatHistoryRepository, + chatHistoryRepository, + type ChatSession, +} from "../../../../../src/components/agents/shared/ChatHistoryRepository"; +import type { ChatMessage } from "../../../../../src/hooks/useAgentChat"; + +// ========================================== +// Test Fixtures +// ========================================== + +const createMockMessage = ( + id: string, + role: "user" | "assistant" = "user", + content: string = "Test message" +): ChatMessage => ({ + id, + role, + content, + timestamp: Date.now(), +}); + +const createMockSession = ( + id: string, + agentId: string = "agent-1", + userId: string = "user-1" +): ChatSession => ({ + id, + agentId, + userId, + title: "Test Session", + preview: "Test preview", + createdAt: Date.now(), + updatedAt: Date.now(), + messageCount: 0, +}); + +// ========================================== +// ChatHistoryRepository Tests +// ========================================== + +describe("ChatHistoryRepository", () => { + let localStorageMock: Record; + let repository: ChatHistoryRepository; + + beforeEach(() => { + localStorageMock = {}; + + // Mock localStorage + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => localStorageMock[key] || null), + setItem: vi.fn((key: string, value: string) => { + localStorageMock[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete localStorageMock[key]; + }), + clear: vi.fn(() => { + localStorageMock = {}; + }), + key: vi.fn((index: number) => Object.keys(localStorageMock)[index] || null), + get length() { + return Object.keys(localStorageMock).length; + }, + }); + + // Mock window.dispatchEvent + vi.stubGlobal("window", { + ...globalThis.window, + dispatchEvent: vi.fn(), + }); + + // Get fresh instance for each test + repository = ChatHistoryRepository.getInstance(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + // ========================================== + // Singleton Tests + // ========================================== + + describe("getInstance", () => { + it("should return singleton instance", () => { + const instance1 = ChatHistoryRepository.getInstance(); + const instance2 = ChatHistoryRepository.getInstance(); + + expect(instance1).toBe(instance2); + }); + + it("should export chatHistoryRepository as singleton", () => { + expect(chatHistoryRepository).toBe(ChatHistoryRepository.getInstance()); + }); + }); + + // ========================================== + // Session Management Tests + // ========================================== + + describe("getAllSessions", () => { + it("should return empty array when no sessions exist", () => { + const sessions = repository.getAllSessions(); + + expect(sessions).toEqual([]); + }); + + it("should return sessions sorted by updatedAt descending", () => { + const now = Date.now(); + const sessions: ChatSession[] = [ + { ...createMockSession("1"), updatedAt: now - 2000 }, + { ...createMockSession("2"), updatedAt: now }, + { ...createMockSession("3"), updatedAt: now - 1000 }, + ]; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.getAllSessions(); + + expect(result[0].id).toBe("2"); // Most recent + expect(result[1].id).toBe("3"); + expect(result[2].id).toBe("1"); // Oldest + }); + + it("should return empty array on parse error", () => { + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = "invalid json"; + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const sessions = repository.getAllSessions(); + + expect(sessions).toEqual([]); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe("getSessionsForAgent", () => { + it("should filter sessions by agentId", () => { + const sessions: ChatSession[] = [ + createMockSession("1", "agent-1", "user-1"), + createMockSession("2", "agent-2", "user-1"), + createMockSession("3", "agent-1", "user-2"), + ]; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.getSessionsForAgent("agent-1"); + + expect(result).toHaveLength(2); + expect(result.every(s => s.agentId === "agent-1")).toBe(true); + }); + + it("should filter by both agentId and userId when userId provided", () => { + const sessions: ChatSession[] = [ + createMockSession("1", "agent-1", "user-1"), + createMockSession("2", "agent-1", "user-2"), + createMockSession("3", "agent-2", "user-1"), + ]; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.getSessionsForAgent("agent-1", "user-1"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("1"); + }); + }); + + describe("getSession", () => { + it("should return session by id", () => { + const sessions: ChatSession[] = [ + createMockSession("session-1"), + createMockSession("session-2"), + ]; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.getSession("session-1"); + + expect(result).not.toBeNull(); + expect(result?.id).toBe("session-1"); + }); + + it("should return null for non-existent session", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.getSession("non-existent"); + + expect(result).toBeNull(); + }); + }); + + describe("createSession", () => { + it("should create new session with generated id", () => { + const session = repository.createSession("agent-1", "user-1"); + + expect(session.id).toBeDefined(); + expect(session.agentId).toBe("agent-1"); + expect(session.userId).toBe("user-1"); + expect(session.title).toBe("New conversation"); + }); + + it("should create session with initial message", () => { + const initialMessage = createMockMessage("msg-1", "user", "Hello!"); + + const session = repository.createSession("agent-1", "user-1", initialMessage); + + expect(session.title).toBe("Hello!"); + expect(session.preview).toBe("Hello!"); + expect(session.messageCount).toBe(1); + }); + + it("should truncate long titles", () => { + const longContent = "A".repeat(50); + const initialMessage = createMockMessage("msg-1", "user", longContent); + + const session = repository.createSession("agent-1", "user-1", initialMessage); + + expect(session.title.length).toBeLessThanOrEqual(40); + expect(session.title).toContain("..."); + }); + + it("should dispatch update event", () => { + repository.createSession("agent-1", "user-1"); + + expect(window.dispatchEvent).toHaveBeenCalled(); + }); + }); + + describe("updateSession", () => { + it("should update session properties", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + repository.updateSession("session-1", { + title: "Updated Title", + messageCount: 5, + }); + + const updated = repository.getSession("session-1"); + expect(updated?.title).toBe("Updated Title"); + expect(updated?.messageCount).toBe(5); + }); + + it("should update updatedAt timestamp", () => { + const oldTime = Date.now() - 10000; + const sessions: ChatSession[] = [ + { ...createMockSession("session-1"), updatedAt: oldTime }, + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + repository.updateSession("session-1", { title: "New Title" }); + + const updated = repository.getSession("session-1"); + expect(updated?.updatedAt).toBeGreaterThan(oldTime); + }); + + it("should not throw for non-existent session", () => { + expect(() => { + repository.updateSession("non-existent", { title: "Test" }); + }).not.toThrow(); + }); + }); + + describe("deleteSession", () => { + it("should remove session from storage", () => { + const sessions: ChatSession[] = [ + createMockSession("session-1"), + createMockSession("session-2"), + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + repository.deleteSession("session-1"); + + const remaining = repository.getAllSessions(); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe("session-2"); + }); + + it("should remove messages for deleted session", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] = "[]"; + + repository.deleteSession("session-1"); + + expect(localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")]).toBeUndefined(); + }); + }); + + describe("deleteAllSessionsForAgent", () => { + it("should delete all sessions for specific agent", () => { + const sessions: ChatSession[] = [ + createMockSession("1", "agent-1", "user-1"), + createMockSession("2", "agent-2", "user-1"), + createMockSession("3", "agent-1", "user-1"), + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + repository.deleteAllSessionsForAgent("agent-1"); + + const remaining = repository.getAllSessions(); + expect(remaining).toHaveLength(1); + expect(remaining[0].agentId).toBe("agent-2"); + }); + + it("should only delete sessions for specific user when userId provided", () => { + const sessions: ChatSession[] = [ + createMockSession("1", "agent-1", "user-1"), + createMockSession("2", "agent-1", "user-2"), + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + repository.deleteAllSessionsForAgent("agent-1", "user-1"); + + const remaining = repository.getAllSessions(); + expect(remaining).toHaveLength(1); + expect(remaining[0].userId).toBe("user-2"); + }); + }); + + describe("clearAllHistory", () => { + it("should remove all sessions and messages", () => { + const sessions: ChatSession[] = [ + createMockSession("session-1"), + createMockSession("session-2"), + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] = "[]"; + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-2")] = "[]"; + + repository.clearAllHistory(); + + expect(localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS]).toBeUndefined(); + expect(localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")]).toBeUndefined(); + expect(localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-2")]).toBeUndefined(); + }); + }); + + describe("clearAllLocalHistoryOnly", () => { + it("should clear sessions without IPFS sync", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({}); + + repository.clearAllLocalHistoryOnly(); + + expect(localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS]).toBeUndefined(); + expect(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]).toBeUndefined(); + }); + }); + + // ========================================== + // Message Management Tests + // ========================================== + + describe("getMessages", () => { + it("should return empty array when no messages exist", () => { + const messages = repository.getMessages("session-1"); + + expect(messages).toEqual([]); + }); + + it("should return messages for session", () => { + const mockMessages: ChatMessage[] = [ + createMockMessage("1", "user", "Hello"), + createMockMessage("2", "assistant", "Hi there"), + ]; + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] = + JSON.stringify(mockMessages); + + const messages = repository.getMessages("session-1"); + + expect(messages).toHaveLength(2); + expect(messages[0].content).toBe("Hello"); + }); + }); + + describe("saveMessages", () => { + it("should save messages to localStorage", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const messages: ChatMessage[] = [ + createMockMessage("1", "user", "Test"), + ]; + + repository.saveMessages("session-1", messages); + + const saved = JSON.parse( + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] + ); + expect(saved).toHaveLength(1); + }); + + it("should trim messages exceeding MAX_MESSAGES_PER_SESSION", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + // Create 150 messages (exceeds limit of 100) + const messages: ChatMessage[] = []; + for (let i = 0; i < 150; i++) { + messages.push(createMockMessage(`msg-${i}`, "user", `Message ${i}`)); + } + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + repository.saveMessages("session-1", messages); + + const saved = JSON.parse( + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] + ); + + // Should only store 100 most recent messages + expect(saved).toHaveLength(100); + // Should be the last 100 messages (msg-50 to msg-149) + expect(saved[0].id).toBe("msg-50"); + expect(saved[99].id).toBe("msg-149"); + + consoleSpy.mockRestore(); + }); + + it("should update session metadata with full message count", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + // Create 150 messages + const messages: ChatMessage[] = []; + for (let i = 0; i < 150; i++) { + messages.push(createMockMessage(`msg-${i}`, "user", `Message ${i}`)); + } + + repository.saveMessages("session-1", messages); + + const session = repository.getSession("session-1"); + // messageCount should be full count, not trimmed + expect(session?.messageCount).toBe(150); + }); + + it("should generate title from first user message", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const messages: ChatMessage[] = [ + createMockMessage("1", "user", "What is the weather?"), + createMockMessage("2", "assistant", "I can help with that"), + ]; + + repository.saveMessages("session-1", messages); + + const session = repository.getSession("session-1"); + expect(session?.title).toBe("What is the weather?"); + }); + }); + + describe("appendMessage", () => { + it("should append new message to session", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const existingMessages: ChatMessage[] = [ + createMockMessage("1", "user", "Hello"), + ]; + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] = + JSON.stringify(existingMessages); + + const newMessage = createMockMessage("2", "assistant", "Hi!"); + repository.appendMessage("session-1", newMessage); + + const messages = repository.getMessages("session-1"); + expect(messages).toHaveLength(2); + }); + + it("should update existing message by id", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const existingMessages: ChatMessage[] = [ + createMockMessage("msg-1", "assistant", "Original content"), + ]; + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] = + JSON.stringify(existingMessages); + + const updatedMessage = createMockMessage("msg-1", "assistant", "Updated content"); + repository.appendMessage("session-1", updatedMessage); + + const messages = repository.getMessages("session-1"); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe("Updated content"); + }); + }); + + // ========================================== + // Search Tests + // ========================================== + + describe("searchSessions", () => { + it("should return all sessions for empty query", () => { + const sessions: ChatSession[] = [ + createMockSession("1"), + createMockSession("2"), + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.searchSessions(""); + + expect(result).toHaveLength(2); + }); + + it("should filter by query in title", () => { + const sessions: ChatSession[] = [ + { ...createMockSession("1"), title: "Weather question" }, + { ...createMockSession("2"), title: "Coding help" }, + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.searchSessions("weather"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("1"); + }); + + it("should filter by query in preview", () => { + const sessions: ChatSession[] = [ + { ...createMockSession("1"), title: "Chat", preview: "Tell me about weather" }, + { ...createMockSession("2"), title: "Chat", preview: "Help with code" }, + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.searchSessions("weather"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("1"); + }); + + it("should search in message content", () => { + const sessions: ChatSession[] = [ + { ...createMockSession("session-1"), title: "Chat", preview: "..." }, + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const messages: ChatMessage[] = [ + createMockMessage("1", "user", "What is Python programming?"), + ]; + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] = + JSON.stringify(messages); + + const result = repository.searchSessions("python"); + + expect(result).toHaveLength(1); + }); + + it("should be case-insensitive", () => { + const sessions: ChatSession[] = [ + { ...createMockSession("1"), title: "WEATHER Question" }, + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.searchSessions("weather"); + + expect(result).toHaveLength(1); + }); + + it("should filter by agentId when provided", () => { + const sessions: ChatSession[] = [ + { ...createMockSession("1", "agent-1"), title: "Weather" }, + { ...createMockSession("2", "agent-2"), title: "Weather" }, + ]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const result = repository.searchSessions("weather", "agent-1"); + + expect(result).toHaveLength(1); + expect(result[0].agentId).toBe("agent-1"); + }); + }); + + // ========================================== + // Full Session Data Tests + // ========================================== + + describe("getSessionWithMessages", () => { + it("should return session with messages", () => { + const sessions: ChatSession[] = [createMockSession("session-1")]; + localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); + + const messages: ChatMessage[] = [ + createMockMessage("1", "user", "Hello"), + ]; + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] = + JSON.stringify(messages); + + const result = repository.getSessionWithMessages("session-1"); + + expect(result).not.toBeNull(); + expect(result?.id).toBe("session-1"); + expect(result?.messages).toHaveLength(1); + }); + + it("should return null for non-existent session", () => { + const result = repository.getSessionWithMessages("non-existent"); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/hooks/useGlobalSyncStatus.test.ts b/tests/unit/hooks/useGlobalSyncStatus.test.ts new file mode 100644 index 00000000..9ece5cea --- /dev/null +++ b/tests/unit/hooks/useGlobalSyncStatus.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { SyncStep } from "../../../src/components/agents/shared/ChatHistoryIpfsService"; + +// ========================================== +// Mock Setup +// ========================================== + +// Mock ChatHistoryIpfsService +const mockGetStatus = vi.fn(() => ({ + initialized: false, + isSyncing: false, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, +})); + +const mockOnStatusChange = vi.fn(() => { + // Return unsubscribe function + return () => {}; +}); + +vi.mock("../../../src/components/agents/shared/ChatHistoryIpfsService", () => ({ + getChatHistoryIpfsService: vi.fn(() => ({ + getStatus: mockGetStatus, + onStatusChange: mockOnStatusChange, + })), +})); + +// Mock IpfsStorageService +const mockIsCurrentlySyncing = vi.fn(() => false); + +vi.mock("../../../src/components/wallet/L3/services/IpfsStorageService", () => ({ + IpfsStorageService: { + getInstance: vi.fn(() => ({ + isCurrentlySyncing: mockIsCurrentlySyncing, + })), + }, +})); + +// Mock IdentityManager +vi.mock("../../../src/components/wallet/L3/services/IdentityManager", () => ({ + IdentityManager: { + getInstance: vi.fn(() => ({})), + }, +})); + +// Import after mocking +import { + useGlobalSyncStatus, + waitForAllSyncsToComplete, +} from "../../../src/hooks/useGlobalSyncStatus"; + +// ========================================== +// useGlobalSyncStatus Tests +// ========================================== + +describe("useGlobalSyncStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.stubGlobal("window", { + ...globalThis.window, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + + // Reset mock return values + mockGetStatus.mockReturnValue({ + initialized: false, + isSyncing: false, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + mockIsCurrentlySyncing.mockReturnValue(false); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // ========================================== + // Basic State Tests + // ========================================== + + describe("initial state", () => { + it("should return initial sync status", () => { + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.chatSyncing).toBe(false); + expect(result.current.tokenSyncing).toBe(false); + expect(result.current.isAnySyncing).toBe(false); + }); + + it("should return idle chat step", () => { + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.chatStep).toBe("idle"); + }); + + it("should return 'All data synced' message when not syncing", () => { + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.statusMessage).toBe("All data synced"); + }); + }); + + // ========================================== + // Chat Syncing Tests + // ========================================== + + describe("chat syncing", () => { + it("should detect active chat sync", () => { + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: true, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "uploading" as SyncStep, + }); + + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.chatSyncing).toBe(true); + expect(result.current.isAnySyncing).toBe(true); + }); + + it("should detect pending chat sync (debounce period)", () => { + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: false, + hasPendingSync: true, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.chatSyncing).toBe(true); + expect(result.current.isAnySyncing).toBe(true); + }); + + it("should show correct status message for each sync step", () => { + const steps: Array<{ step: string; expected: string }> = [ + { step: "initializing", expected: "Initializing..." }, + { step: "resolving-ipns", expected: "Resolving chat history..." }, + { step: "fetching-content", expected: "Fetching chat history..." }, + { step: "importing-data", expected: "Importing chat data..." }, + { step: "building-data", expected: "Preparing chat data..." }, + { step: "uploading", expected: "Uploading chat history..." }, + { step: "publishing-ipns", expected: "Publishing chat to network..." }, + ]; + + for (const { step, expected } of steps) { + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: true, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: step as SyncStep, + }); + + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.statusMessage).toBe(expected); + } + }); + + it("should show 'Preparing to sync chat...' for pending sync in idle state", () => { + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: false, + hasPendingSync: true, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.statusMessage).toBe("Preparing to sync chat..."); + }); + }); + + // ========================================== + // Token Syncing Tests + // ========================================== + + describe("token syncing", () => { + it("should detect token sync from service", () => { + mockIsCurrentlySyncing.mockReturnValue(true); + + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.tokenSyncing).toBe(true); + expect(result.current.isAnySyncing).toBe(true); + }); + + it("should show token syncing in status message", () => { + mockIsCurrentlySyncing.mockReturnValue(true); + + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.statusMessage).toContain("Syncing tokens..."); + }); + }); + + // ========================================== + // Combined Sync Status Tests + // ========================================== + + describe("combined sync status", () => { + it("should show both services syncing", () => { + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: true, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "uploading" as SyncStep, + }); + mockIsCurrentlySyncing.mockReturnValue(true); + + const { result } = renderHook(() => useGlobalSyncStatus()); + + expect(result.current.chatSyncing).toBe(true); + expect(result.current.tokenSyncing).toBe(true); + expect(result.current.isAnySyncing).toBe(true); + expect(result.current.statusMessage).toContain("Uploading chat history..."); + expect(result.current.statusMessage).toContain("Syncing tokens..."); + }); + + it("should detect syncing when only one service is active", () => { + // Only chat syncing + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: true, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "uploading" as SyncStep, + }); + mockIsCurrentlySyncing.mockReturnValue(false); + + const { result: result1 } = renderHook(() => useGlobalSyncStatus()); + expect(result1.current.isAnySyncing).toBe(true); + + // Only token syncing + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: false, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + mockIsCurrentlySyncing.mockReturnValue(true); + + const { result: result2 } = renderHook(() => useGlobalSyncStatus()); + expect(result2.current.isAnySyncing).toBe(true); + }); + }); + + // ========================================== + // Event Subscription Tests + // ========================================== + + describe("event subscriptions", () => { + it("should subscribe to chat status changes", () => { + renderHook(() => useGlobalSyncStatus()); + + expect(mockOnStatusChange).toHaveBeenCalled(); + }); + + it("should subscribe to token sync events", () => { + renderHook(() => useGlobalSyncStatus()); + + expect(window.addEventListener).toHaveBeenCalledWith( + "ipfs-storage-event", + expect.any(Function) + ); + }); + + it("should cleanup subscriptions on unmount", () => { + const { unmount } = renderHook(() => useGlobalSyncStatus()); + + unmount(); + + expect(window.removeEventListener).toHaveBeenCalledWith( + "ipfs-storage-event", + expect.any(Function) + ); + }); + }); +}); + +// ========================================== +// waitForAllSyncsToComplete Tests +// ========================================== + +describe("waitForAllSyncsToComplete", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + mockGetStatus.mockReturnValue({ + initialized: false, + isSyncing: false, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + mockIsCurrentlySyncing.mockReturnValue(false); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should resolve immediately when no sync in progress", async () => { + const resultPromise = waitForAllSyncsToComplete(); + + // Advance timer to trigger first check + await vi.advanceTimersByTimeAsync(0); + + const result = await resultPromise; + expect(result).toBe(true); + }); + + it("should wait for chat sync to complete", async () => { + // Start with syncing + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: true, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "uploading" as SyncStep, + }); + + const resultPromise = waitForAllSyncsToComplete(); + + // First check - still syncing + await vi.advanceTimersByTimeAsync(500); + + // Complete sync + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: false, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + + // Second check - should resolve + await vi.advanceTimersByTimeAsync(500); + + const result = await resultPromise; + expect(result).toBe(true); + }); + + it("should wait for pending sync (debounce period)", async () => { + // Start with pending sync + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: false, + hasPendingSync: true, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + + const resultPromise = waitForAllSyncsToComplete(); + + // First check - still has pending + await vi.advanceTimersByTimeAsync(500); + + // Clear pending + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: false, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + + // Second check - should resolve + await vi.advanceTimersByTimeAsync(500); + + const result = await resultPromise; + expect(result).toBe(true); + }); + + it("should timeout after specified duration", async () => { + // Sync never completes + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: true, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "uploading" as SyncStep, + }); + + const resultPromise = waitForAllSyncsToComplete(2000); // 2 second timeout + + // Advance past timeout + await vi.advanceTimersByTimeAsync(2500); + + const result = await resultPromise; + expect(result).toBe(false); + }); + + it("should use default timeout of 60 seconds", async () => { + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: true, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "uploading" as SyncStep, + }); + + const resultPromise = waitForAllSyncsToComplete(); + + // Advance to just before default timeout + await vi.advanceTimersByTimeAsync(59000); + + // Sync completes + mockGetStatus.mockReturnValue({ + initialized: true, + isSyncing: false, + hasPendingSync: false, + lastSync: null, + ipnsName: null, + currentStep: "idle" as SyncStep, + }); + + await vi.advanceTimersByTimeAsync(500); + + const result = await resultPromise; + expect(result).toBe(true); + }); +});