Skip to content
88 changes: 81 additions & 7 deletions src/components/agents/shared/AgentChat.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -115,10 +116,15 @@ export function AgentChat<TCardData, TItem extends SidebarItem = SidebarItem>({
const currentAgentId = useRef(agent.id);
const currentNametag = useRef<string | null>(null);
const lastSavedMessagesRef = useRef<string>('');
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,
Expand All @@ -127,9 +133,11 @@ export function AgentChat<TCardData, TItem extends SidebarItem = SidebarItem>({
deleteSession,
clearAllHistory,
resetCurrentSession,
showDeleteSuccess,
saveCurrentMessages,
searchSessions,
syncState,
syncImmediately,
justDeleted,
} = useChatHistory({
agentId: agent.id,
Expand Down Expand Up @@ -183,6 +191,14 @@ export function AgentChat<TCardData, TItem extends SidebarItem = SidebarItem>({
}
};

// 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) {
Expand Down Expand Up @@ -211,6 +227,43 @@ export function AgentChat<TCardData, TItem extends SidebarItem = SidebarItem>({
}
}, [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<TCardData>[] = 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;
Expand Down Expand Up @@ -262,9 +315,9 @@ export function AgentChat<TCardData, TItem extends SidebarItem = SidebarItem>({
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([{
Expand All @@ -282,7 +335,7 @@ export function AgentChat<TCardData, TItem extends SidebarItem = SidebarItem>({
}]);
}
}
}, [agent.greetingMessage, extendedMessages.length, setMessages, useMockMode]);
}, [agent.greetingMessage, extendedMessages.length, setMessages, useMockMode, urlSessionId]);

const scrollToBottom = useCallback((instant = false) => {
const el = messagesContainerRef.current;
Expand Down Expand Up @@ -418,20 +471,41 @@ export function AgentChat<TCardData, TItem extends SidebarItem = SidebarItem>({
}
}, [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 => {
Expand Down Expand Up @@ -988,7 +1062,7 @@ export function AgentChat<TCardData, TItem extends SidebarItem = SidebarItem>({
return (
<>
{/* Layout with left sidebar for chat history */}
<div className="bg-white/60 dark:bg-neutral-900/70 backdrop-blur-xl rounded-3xl border border-neutral-200 dark:border-neutral-800/50 overflow-hidden grid grid-cols-[auto_1fr] relative lg:shadow-xl dark:lg:shadow-2xl h-full min-h-0 theme-transition">
<div className="bg-white/60 dark:bg-neutral-900/70 backdrop-blur-xl rounded-3xl border border-neutral-200 dark:border-neutral-800/50 overflow-hidden relative lg:grid lg:grid-cols-[auto_1fr] lg:shadow-xl dark:lg:shadow-2xl h-full min-h-0 theme-transition">
<div className={`absolute -top-20 -right-20 w-96 h-96 ${bgGradient.from} rounded-full blur-3xl`} />
<div className={`absolute -bottom-20 -left-20 w-96 h-96 ${bgGradient.to} rounded-full blur-3xl`} />
{renderHistorySidebar()}
Expand Down
80 changes: 72 additions & 8 deletions src/components/agents/shared/ChatHistoryIpfsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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<ChatSyncResult> {
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<ChatSyncResult>((resolve) => {
this.pendingSyncResolvers.push(resolve);
});
}

this.isSyncing = true;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -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<string, ChatTombstone> = JSON.parse(tombstonesRaw);
const now = Date.now();
const cutoffTime = now - TOMBSTONE_MAX_AGE_MS;

let removedCount = 0;
const remainingTombstones: Record<string, ChatTombstone> = {};

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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down
Loading