import { create } from 'zustand'; import type { CaseRequest, Conversation, Message } from '@/types'; import { api, type CaseFilters } from '@/services/api'; // ───────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────── export type SidebarTab = 'all' | 'pending' | 'resolved'; export type WsStatus = 'connected' | 'disconnected' | 'reconnecting'; interface AppState { // ── Cases slice ────────────────────────────────────────── cases: CaseRequest[]; selectedCaseId: string | number | null; totalCases: number; fetchCases: (filters?: CaseFilters) => Promise; upsertCase: (c: CaseRequest) => void; resolveCase: ( id: string | number, data: { action: string; payload: Record; note?: string }, ) => Promise; // ── Conversations slice ────────────────────────────────── conversations: Conversation[]; selectedConversationId: string | null; fetchConversations: () => Promise; upsertConversation: (c: Conversation) => void; addMessage: (convId: string, msg: Message) => void; appendToken: (convId: string, msgId: string, token: string, index: number) => void; completeStream: (convId: string, msgId: string, fullContent: string) => void; setSelectedConversationId: (convId: string | null) => void; removeConversation: (convId: string) => void; // ── UI slice ───────────────────────────────────────────── sidebarTab: SidebarTab; searchQuery: string; applicativeFilter: string | null; isDarkMode: boolean; wsStatus: WsStatus; setSidebarTab: (tab: SidebarTab) => void; setSearchQuery: (q: string) => void; setApplicativeFilter: (app: string | null) => void; toggleDarkMode: () => void; setWsStatus: (status: WsStatus) => void; } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── /** * Read initial dark mode from localStorage, defaulting to false. */ function readDarkMode(): boolean { try { const stored = localStorage.getItem('claro-cases:darkMode'); return stored === 'true'; } catch { return false; } } /** * Persist dark mode preference to localStorage. */ function persistDarkMode(value: boolean): void { try { localStorage.setItem('claro-cases:darkMode', String(value)); } catch { // localStorage may be unavailable (private browsing, quota, etc.) } } // ───────────────────────────────────────────────────────────── // Token Streaming Buffer (Regla 4 — 50ms throttling, 20 fps) // ───────────────────────────────────────────────────────────── interface PendingToken { msgId: string; token: string; index: number; } interface ConversationBufferEntry { pending: PendingToken[]; timer: ReturnType | null; } /** * External buffer map — NOT stored in Zustand state to avoid * triggering re-renders on every chunk. Each conversation gets * its own entry with a pending queue and a 50ms flush timer. */ const conversationBuffers = new Map(); /** * Flush all pending tokens for a given conversation into the store * with a SINGLE `set()` call. Only updates the store if this * conversation is the actively selected one (Regla 4: solo * re-renderizar conversación seleccionada). */ function flushBuffer( convId: string, get: () => AppState, set: (partial: AppState | ((state: AppState) => Partial)) => void, ): void { const entry = conversationBuffers.get(convId); if (!entry) return; // Clear the timer reference first entry.timer = null; // If the conversation no longer exists in the store, clean up the buffer const currentState = get(); const convExists = currentState.conversations.some((c) => c.id === convId); if (!convExists) { conversationBuffers.delete(convId); return; } // If nothing is pending, delete the entry and bail out if (entry.pending.length === 0) { conversationBuffers.delete(convId); return; } // Only update the store for the selected conversation (Regla 4) if (currentState.selectedConversationId !== convId) { // Keep tokens in buffer — they'll be flushed when this conversation // becomes selected, or cleared by completeStream. return; } // Atomically take and clear the pending queue const pendingToProcess = entry.pending; entry.pending = []; // Sort by index to guarantee correct order even with out-of-order delivery pendingToProcess.sort((a, b) => a.index - b.index); // Single batched set() call — ALL accumulated chunks in one update set((state) => { const convIndex = state.conversations.findIndex((c) => c.id === convId); if (convIndex < 0) return state; const conv = state.conversations[convIndex]; const messages = [...conv.messages]; let hasChanges = false; for (const pending of pendingToProcess) { const msgIndex = messages.findIndex((m) => m.id === pending.msgId); if (msgIndex < 0) continue; const msg = { ...messages[msgIndex] }; const existingChunks: Array<{ token: string; index: number }> = (msg.metadata?._chunks as Array<{ token: string; index: number }>) ?? []; const newChunks = [ ...existingChunks, { token: pending.token, index: pending.index }, ]; newChunks.sort((a, b) => a.index - b.index); messages[msgIndex] = { ...msg, content: newChunks.map((ch) => ch.token).join(''), metadata: { ...msg.metadata, _chunks: newChunks }, isStreaming: true, }; hasChanges = true; } if (!hasChanges) return state; return { conversations: state.conversations.map((c, i) => i === convIndex ? { ...conv, messages } : c, ), }; }); } /** * Schedule a flush for the given conversation in ~50ms. * Does nothing if a timer is already pending for this conversation. */ function scheduleBufferFlush( convId: string, get: () => AppState, set: (partial: AppState | ((state: AppState) => Partial)) => void, ): void { const entry = conversationBuffers.get(convId); if (!entry || entry.timer !== null) return; entry.timer = setTimeout(() => { flushBuffer(convId, get, set); }, 50); } /** * Immediately flush all pending tokens for the given conversation. * Used when switching to a conversation mid-stream. */ function forceFlushBuffer( convId: string, get: () => AppState, set: (partial: AppState | ((state: AppState) => Partial)) => void, ): void { const entry = conversationBuffers.get(convId); if (!entry) return; if (entry.timer !== null) { clearTimeout(entry.timer); } flushBuffer(convId, get, set); } // ───────────────────────────────────────────────────────────── // Store // ───────────────────────────────────────────────────────────── export const useAppStore = create((set, get) => ({ // ── Cases initial state ────────────────────────────────── cases: [], selectedCaseId: null, totalCases: 0, fetchCases: async (filters: CaseFilters = {}) => { try { const response = await api.getCases(filters); set({ cases: response.items, totalCases: response.total }); } catch (err) { console.error('[Store] fetchCases failed:', err); // On failure, keep current state (or set empty) set({ cases: [], totalCases: 0 }); } }, upsertCase: (c: CaseRequest) => set((state) => { const index = state.cases.findIndex( (existing) => existing.id === c.id, ); if (index >= 0) { // Replace existing const updated = [...state.cases]; updated[index] = c; return { cases: updated }; } // Prepend new case return { cases: [c, ...state.cases] }; }), resolveCase: async (id, data) => { try { const updatedCase = await api.resolveCase(id, data); set((state) => { const index = state.cases.findIndex( (existing) => existing.id === id, ); if (index >= 0) { const updated = [...state.cases]; updated[index] = updatedCase; return { cases: updated }; } return state; }); } catch (err) { console.error('[Store] resolveCase failed:', err); throw err; // re-throw so calling code can handle } }, // ── Conversations initial state ────────────────────────── conversations: [], selectedConversationId: null, fetchConversations: async () => { try { const conversations = await api.getActiveConversations(); set({ conversations }); } catch (err) { console.error('[Store] fetchConversations failed:', err); set({ conversations: [] }); } }, upsertConversation: (c: Conversation) => set((state) => { const index = state.conversations.findIndex( (existing) => existing.id === c.id, ); if (index >= 0) { const updated = [...state.conversations]; updated[index] = c; return { conversations: updated }; } return { conversations: [...state.conversations, c] }; }), addMessage: (convId: string, msg: Message) => set((state) => { const convIndex = state.conversations.findIndex( (c) => c.id === convId, ); if (convIndex < 0) return state; const updated = [...state.conversations]; updated[convIndex] = { ...updated[convIndex], messages: [...updated[convIndex].messages, msg], }; return { conversations: updated }; }), appendToken: (convId: string, msgId: string, token: string, index: number) => { // Step 1: Add chunk to the conversation's external buffer let entry = conversationBuffers.get(convId); if (!entry) { entry = { pending: [], timer: null }; conversationBuffers.set(convId, entry); } entry.pending.push({ msgId, token, index }); // Step 2: Schedule a flush only if this is the selected conversation // (non-selected conversations accumulate in buffer without triggering re-renders) const state = get(); if (state.selectedConversationId === convId) { scheduleBufferFlush(convId, get, set); } }, completeStream: (convId: string, msgId: string, fullContent: string) => { // Step 1: Clear the conversation's buffer — no more tokens expected const entry = conversationBuffers.get(convId); if (entry) { if (entry.timer !== null) { clearTimeout(entry.timer); } conversationBuffers.delete(convId); } // Step 2: Perform a single store update to set the final content set((state) => { const convIndex = state.conversations.findIndex( (c) => c.id === convId, ); if (convIndex < 0) return state; const conv = state.conversations[convIndex]; const msgIndex = conv.messages.findIndex((m) => m.id === msgId); if (msgIndex < 0) return state; const messages = [...conv.messages]; const msg = { ...messages[msgIndex] }; // Clear chunk buffer — rebuild metadata without _chunks const cleanMetadata: Record = {}; if (msg.metadata) { for (const [key, value] of Object.entries(msg.metadata)) { if (key !== '_chunks') { cleanMetadata[key] = value; } } } messages[msgIndex] = { ...msg, content: fullContent, isStreaming: false, metadata: cleanMetadata, }; return { conversations: state.conversations.map((c, i) => i === convIndex ? { ...conv, messages } : c, ), }; }); }, setSelectedConversationId: (convId: string | null) => { // Force-flush any pending buffer for the newly selected conversation const prevSelected = get().selectedConversationId; set({ selectedConversationId: convId }); if (convId !== null && convId !== prevSelected) { // If switching to a conversation that has buffered tokens, flush them immediately forceFlushBuffer(convId, get, set); } }, removeConversation: (convId: string) => { // Clear the buffer for this conversation const entry = conversationBuffers.get(convId); if (entry) { if (entry.timer !== null) { clearTimeout(entry.timer); } conversationBuffers.delete(convId); } set((state) => ({ conversations: state.conversations.filter((c) => c.id !== convId), selectedConversationId: state.selectedConversationId === convId ? null : state.selectedConversationId, })); }, // ── UI initial state ────────────────────────────────── sidebarTab: 'all', searchQuery: '', applicativeFilter: null, isDarkMode: readDarkMode(), wsStatus: 'disconnected', setSidebarTab: (tab) => set({ sidebarTab: tab }), setSearchQuery: (q) => set({ searchQuery: q }), setApplicativeFilter: (app) => set({ applicativeFilter: app }), toggleDarkMode: () => set((state) => { const next = !state.isDarkMode; persistDarkMode(next); return { isDarkMode: next }; }), setWsStatus: (status) => set({ wsStatus: status }), }));