packages/chat-next-ui/src/ChatProvider.tsx

'use client';

import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { BannerState, ChatMessage, ChatRequestMessage, PartialReasoningTrace, ProjectDetail, ProjectSummary, ResumeEntry, } from '@portfolio/chat-contract'; import { DEFAULT_CHAT_HISTORY_LIMIT } from '@portfolio/chat-contract'; import { mergeReasoningTraces } from '@portfolio/chat-orchestrator'; import { normalizeProjectKey } from '@/lib/projects/normalize'; import { useChatUiState } from './chatUiState'; import type { ChatUiState } from './chatUiState'; import { useChatStream, type ChatAttachment } from './useChatStream';

export type { ChatSurfaceState } from './chatUiState';

type CacheableProject = ProjectSummary | ProjectDetail; type ProjectCacheMap = Record<string, CacheableProject>; type ExperienceCacheMap = Record<string, ResumeEntry>;

export type ChatProviderProps = { children: ReactNode; endpoint?: string; historyLimit?: number; fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise; requestFormatter?: (messages: ChatMessage[]) => ChatRequestMessage[]; onError?: (error: Error) => void; /**

  • User-facing opt-in for reasoning traces.
  • Defaults to true to stream reasoning by default. / reasoningOptIn?: boolean; /*
  • Retry configuration for failed chat requests. */ retryConfig?: { maxAttempts?: number; baseDelayMs?: number; maxDelayMs?: number; backoffMultiplier?: number; }; };

const STORAGE_KEY_CONVERSATION = 'chat:conversationId'; const buildCompletionStorageKey = (conversationId: string) => chat:completionTimes:${conversationId};

const DEFAULT_RETRY_CONFIG = { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 10000, backoffMultiplier: 2, } as const;

function calculateRetryDelay(attempt: number, config: { maxAttempts: number; baseDelayMs: number; maxDelayMs: number; backoffMultiplier: number }): number { const delay = config.baseDelayMs * Math.pow(config.backoffMultiplier, attempt - 1); return Math.min(delay, config.maxDelayMs); }

function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); }

function getRetryInfo(error: unknown): { retryable: boolean; retryAfterMs?: number } { if (error instanceof TypeError && error.message.includes('fetch')) { return { retryable: true }; } if (error && typeof error === 'object') { const retryable = (error as { retryable?: unknown }).retryable; const retryAfterMs = (error as { retryAfterMs?: unknown }).retryAfterMs; return { retryable: retryable === true, retryAfterMs: typeof retryAfterMs === 'number' ? retryAfterMs : undefined, }; } return { retryable: false }; }

interface ChatContextValue { messages: ChatMessage[]; isBusy: boolean; chatStarted: boolean; bannerState: BannerState; error?: string | null; send: (text: string) => Promise; uiState: ChatUiState; projectCache: ProjectCacheMap; experienceCache: ExperienceCacheMap; reasoningTraces: Record<string, PartialReasoningTrace>; reasoningEnabled: boolean; completionTimes: Record<string, number>; markMessageRendered: (messageId: string) => void; }

const ChatContext = createContext<ChatContextValue | undefined>(undefined);

export function ChatProvider({ children, endpoint = '/api/chat', historyLimit = DEFAULT_CHAT_HISTORY_LIMIT, fetcher, requestFormatter, onError, reasoningOptIn = true, retryConfig = {}, }: ChatProviderProps) { const [messages, setMessages] = useState<ChatMessage[]>([]); const [isBusy, setBusy] = useState(false); const [chatStarted, setChatStarted] = useState(false); const [bannerState, setBanner] = useState({ mode: 'idle' }); const [error, setError] = useState<string | null>(null); const { uiState, applyUiActions } = useChatUiState(); const [projectCache, setProjectCache] = useState({}); const [experienceCache, setExperienceCache] = useState({}); const [reasoningTraces, setReasoningTraces] = useState<Record<string, PartialReasoningTrace>>({}); const [completionTimes, setCompletionTimes] = useState<Record<string, number>>({}); const messagesRef = useRef<ChatMessage[]>([]); const conversationIdRef = useRef(createMessageId());

const historyWindow = Number.isFinite(historyLimit) && historyLimit > 0 ? historyLimit : DEFAULT_CHAT_HISTORY_LIMIT; const formatMessages = useCallback( (ms: ChatMessage[]) => (requestFormatter ? requestFormatter(ms) : flatten(ms, historyWindow)), [historyWindow, requestFormatter] );

// Hydrate conversation + completion times from sessionStorage to keep durations stable across soft reloads useEffect(() => { if (typeof window === 'undefined') { return; } const storedConversationId = window.sessionStorage.getItem(STORAGE_KEY_CONVERSATION); const conversationId = storedConversationId?.trim() || conversationIdRef.current; if (!storedConversationId) { window.sessionStorage.setItem(STORAGE_KEY_CONVERSATION, conversationId); } conversationIdRef.current = conversationId;

const completionKey = buildCompletionStorageKey(conversationId);
const storedCompletions = window.sessionStorage.getItem(completionKey);
if (storedCompletions) {
  try {
    const parsed = JSON.parse(storedCompletions) as Record<string, number>;
    if (parsed && typeof parsed === 'object') {
      setCompletionTimes(parsed);
    }
  } catch {
    // ignore invalid storage
  }
}

}, []);

// Persist completion timestamps useEffect(() => { if (typeof window === 'undefined') { return; } const conversationId = window.sessionStorage.getItem(STORAGE_KEY_CONVERSATION) || conversationIdRef.current; const completionKey = buildCompletionStorageKey(conversationId); try { window.sessionStorage.setItem(completionKey, JSON.stringify(completionTimes)); } catch { // ignore storage write errors } }, [completionTimes]);

const resolveFetcher = useCallback(() => { if (fetcher) { return fetcher; } if (typeof globalThis.fetch === 'function') { return (input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init); } throw new Error('ChatProvider requires a fetch implementation.'); }, [fetcher]);

const commitMessages = useCallback((next: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[])) => { setMessages((prev) => { const resolved = typeof next === 'function' ? (next as (prev: ChatMessage[]) => ChatMessage[])(prev) : next; messagesRef.current = resolved; return resolved; }); }, []);

const pushMessage = useCallback( (message: ChatMessage) => { commitMessages((prev) => [...prev, message]); }, [commitMessages] );

const replaceMessage = useCallback( (updated: ChatMessage) => { commitMessages((prev) => prev.map((msg) => (msg.id === updated.id ? updated : msg))); }, [commitMessages] );

const cacheProjects = useCallback((payload?: CacheableProject | CacheableProject[]) => { if (!payload) { return; } const projects = Array.isArray(payload) ? payload : [payload]; if (!projects.length) { return; }

setProjectCache((prev) => {
  let mutated = false;
  const next = { ...prev };
  for (const project of projects) {
    const candidate = normalizeProjectPayload(project);
    const key = normalizeProjectKey(candidate?.slug ?? candidate?.name);
    if (!key || !candidate) {
      continue;
    }
    next[key] = candidate;
    mutated = true;
  }
  return mutated ? next : prev;
});

}, []);

const cacheExperiences = useCallback((payload?: ResumeEntry | ResumeEntry[]) => { if (!payload) { return; } const experiences = Array.isArray(payload) ? payload : [payload]; if (!experiences.length) { return; }

setExperienceCache((prev) => {
  let mutated = false;
  const next = { ...prev };
  for (const experience of experiences) {
    const idKey = normalizeExperienceKey(
      experience.id ||
        ('slug' in experience ? experience.slug : null) ||
        ('title' in experience ? experience.title : null)
    );
    if (!idKey) {
      continue;
    }
    const slugKey = 'slug' in experience ? normalizeExperienceKey(experience.slug) : '';
    const titleKey = 'title' in experience ? normalizeExperienceKey(experience.title) : '';
    const normalized: ResumeEntry =
      'company' in experience && (!experience.type || experience.type === 'experience')
        ? { ...experience, type: 'experience' }
        : { ...experience };

    const assignIfMissing = (key: string) => {
      if (!key) {
        return;
      }
      const existing = next[key];
      if (!existing) {
        next[key] = normalized;
        mutated = true;
      }
    };

    assignIfMissing(idKey); // Always key by resume id for UI payload lookups
    assignIfMissing(slugKey);
    assignIfMissing(titleKey);
  }
  return mutated ? next : prev;
});

}, []);

const ingestAttachment = useCallback( (attachment: ChatAttachment) => { if (!attachment?.id) { return; } if (attachment.type === 'project') { const project = coerceProjectAttachment(attachment); if (project) { cacheProjects(project); } return; } if (attachment.type === 'resume') { const entry = coerceResumeAttachment(attachment); if (entry) { cacheExperiences(entry); } } }, [cacheExperiences, cacheProjects] );

useEffect(() => { const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; let cancelled = false;

(async () => {
  try {
    const resolvedFetcher = resolveFetcher();
    const response = await resolvedFetcher('/api/projects', { signal: controller?.signal });
    if (!response.ok) {
      throw new Error('Failed to fetch project list');
    }
    const payload = (await response.json()) as { projects?: CacheableProject[] };
    if (!cancelled && Array.isArray(payload?.projects)) {
      cacheProjects(payload.projects);
    }
  } catch (error) {
    if (!cancelled) {
      console.warn('[ChatProvider] Failed to hydrate project cache', error);
    }
  }
})();

return () => {
  cancelled = true;
  controller?.abort();
};

}, [cacheProjects, resolveFetcher]);

useEffect(() => { const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; let cancelled = false;

(async () => {
  try {
    const resolvedFetcher = resolveFetcher();
    const response = await resolvedFetcher('/api/resume', { signal: controller?.signal });
    if (!response.ok) {
      throw new Error('Failed to fetch resume entries');
    }
    const payload = (await response.json()) as { entries?: ResumeEntry[] };
    if (!cancelled && Array.isArray(payload?.entries) && payload.entries.length) {
      cacheExperiences(payload.entries);
    }
  } catch (error) {
    if (!cancelled) {
      console.warn('[ChatProvider] Failed to hydrate resume cache', error);
    }
  }
})();

return () => {
  cancelled = true;
  controller?.abort();
};

}, [cacheExperiences, resolveFetcher]);

const applyReasoningTrace = useCallback( (itemId?: string, trace?: PartialReasoningTrace) => { if (!itemId) { return; } setReasoningTraces((prev) => { if (!trace) { if (!(itemId in prev)) { return prev; } const next = { ...prev }; delete next[itemId]; return next; } const existing = prev[itemId]; const merged = mergeReasoningTraces(existing, trace); if (existing && merged === existing) { return prev; } return { ...prev, [itemId]: merged }; }); }, [setReasoningTraces] );

const markStreamCompletion = useCallback( (messageId: string, totalDurationMs?: number, createdAt?: string) => { if (!messageId || typeof totalDurationMs !== 'number' || !Number.isFinite(totalDurationMs)) { return; } setCompletionTimes((prev) => { if (prev[messageId]) { return prev; } const createdAtMs = createdAt ? new Date(createdAt).getTime() : NaN; const completedAt = Number.isFinite(createdAtMs) ? createdAtMs + totalDurationMs : Date.now(); return { ...prev, [messageId]: completedAt }; }); }, [setCompletionTimes] );

const markMessageRendered = useCallback( (messageId: string) => { if (!messageId) return; // Mark completion timestamp if missing setCompletionTimes((prev) => { if (prev[messageId]) { return prev; } return { ...prev, [messageId]: Date.now() }; }); // Flip animated flag off for the rendered message commitMessages((prev) => prev.map((msg) => (msg.id === messageId ? { ...msg, animated: false } : msg))); }, [commitMessages] );

const streamAssistantResponse = useChatStream({ replaceMessage, applyUiActions, applyReasoningTrace, applyAttachment: ingestAttachment, recordCompletionTime: markStreamCompletion, }); const shouldRequestReasoning = useMemo(() => Boolean(reasoningOptIn), [reasoningOptIn]); const reasoningEnabled = shouldRequestReasoning; const mergedRetryConfig = useMemo(() => ({ ...DEFAULT_RETRY_CONFIG, ...retryConfig }), [retryConfig]);

const executeChatRequest = useCallback(async ( requestMessages: ChatRequestMessage[], assistantMessage: ChatMessage, anchorId: string ): Promise => { const resolvedFetcher = resolveFetcher();

const response = await resolvedFetcher(endpoint, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    messages: requestMessages,
    responseAnchorId: anchorId,
    reasoningEnabled: shouldRequestReasoning,
    conversationId: conversationIdRef.current,
  }),
});

const contentType = response.headers.get('content-type') ?? '';
const isEventStream = contentType.includes('text/event-stream');
if (!isEventStream) {
  const raw = await response.text();
  try {
    const parsed = JSON.parse(raw) as { error?: { message?: string } };
    if (parsed?.error?.message) {
      throw new Error(parsed.error.message);
    }
  } catch {
    // ignore JSON parse errors
  }
  throw new Error(raw || 'Unable to start chat.');
}
if (!response.body) {
  const message = await response.text();
  throw new Error(message || 'Unable to start chat.');
}

await streamAssistantResponse({ response, assistantMessage });

}, [endpoint, resolveFetcher, shouldRequestReasoning, streamAssistantResponse]);

const send = useCallback( async (text: string) => { const trimmed = text.trim(); if (!trimmed || isBusy) { return; }

  const userMessage: ChatMessage = {
    id: createMessageId(),
    role: 'user',
    parts: [{ kind: 'text', text: trimmed }],
    createdAt: new Date().toISOString(),
  };

  const nextMessages = [...messagesRef.current, userMessage];
  commitMessages(nextMessages);

  if (!chatStarted) {
    setChatStarted(true);
  }

  setBanner({ mode: 'thinking' });
  setBusy(true);
  setError(null);

  const assistantMessageId = createMessageId();
  const assistantCreatedAt = new Date().toISOString();
  let assistantInserted = false;
  try {
    const requestMessages = formatMessages(nextMessages);

    // Insert the streaming assistant placeholder immediately so loading state is tied to the new turn,
    // not the previous assistant message.
    const assistantMessage: ChatMessage = {
      id: assistantMessageId,
      role: 'assistant',
      parts: [{ kind: 'text', text: '', itemId: assistantMessageId }],
      createdAt: assistantCreatedAt,
      animated: true,
    };
    pushMessage(assistantMessage);
    assistantInserted = true;

    const resetAssistantForRetry = (anchorId: string) => {
      const refreshed: ChatMessage = {
        id: assistantMessageId,
        role: 'assistant',
        parts: [{ kind: 'text', text: '', itemId: anchorId }],
        createdAt: assistantCreatedAt,
        animated: true,
      };
      commitMessages((prev) => prev.map((msg) => (msg.id === assistantMessageId ? refreshed : msg)));
      applyReasoningTrace(assistantMessageId, undefined);
      return refreshed;
    };

    // Retry logic with exponential backoff
    let lastError: Error | null = null;
    let currentAssistant = assistantMessage;
    for (let attempt = 1; attempt <= mergedRetryConfig.maxAttempts; attempt++) {
      try {
        const anchorId = attempt === 1 ? assistantMessageId : createMessageId();
        if (attempt > 1) {
          currentAssistant = resetAssistantForRetry(anchorId);
        }

        if (attempt > 1) {
          // Update banner to show retry attempt
          setBanner({ mode: 'thinking', message: `Retrying... (${attempt}/${mergedRetryConfig.maxAttempts})` });
          const delay = calculateRetryDelay(attempt - 1, mergedRetryConfig);
          await sleep(delay);
        }

        await executeChatRequest(requestMessages, currentAssistant, anchorId);
        lastError = null; // Success, clear any previous error
        break; // Exit retry loop on success

      } catch (err) {
        lastError = err as Error;
        console.error(`Chat attempt ${attempt}/${mergedRetryConfig.maxAttempts} failed:`, err);

        const retryInfo = getRetryInfo(err);
        // Don't retry if error is not retryable or we've exhausted attempts
        if (!retryInfo.retryable || attempt === mergedRetryConfig.maxAttempts) {
          break;
        }

        const backoffDelay = calculateRetryDelay(attempt, mergedRetryConfig);
        const retryDelay = Math.max(backoffDelay, retryInfo.retryAfterMs ?? 0);
        if (retryDelay > 0) {
          await sleep(retryDelay);
        }
        // Continue to next attempt
      }
    }

    // If we still have an error after all retries, throw it
    if (lastError) {
      throw lastError;
    }
  } catch (err) {
    console.error('Chat error', err);
    onError?.(err as Error);
    const bannerMessage = (err as { banner?: string })?.banner;
    if (bannerMessage) {
      setBanner({ mode: 'warning', message: bannerMessage });
    }
    setError((err as Error)?.message || 'Something went wrong. Mind trying again?');
    const hasStreamedContent = messagesRef.current.some(
      (msg) =>
        msg.id === assistantMessageId &&
        (msg.parts ?? []).some((part) => part.kind === 'text' && part.text.trim().length > 0)
    );
    // Roll back the placeholder assistant message if the request failed before streaming.
    commitMessages((prev) => {
      if (!assistantInserted) {
        return prev;
      }
      if (hasStreamedContent) {
        return prev.map((msg) => (msg.id === assistantMessageId ? { ...msg, animated: false } : msg));
      }
      return prev.filter((msg) => msg.id !== assistantMessageId);
    });
  } finally {
    setBanner((prev) => (prev.mode === 'thinking' ? { mode: 'hover' } : prev));
    setBusy(false);
  }
},
[
  chatStarted,
  commitMessages,
  endpoint,
  formatMessages,
  isBusy,
  onError,
  pushMessage,
  executeChatRequest,
  mergedRetryConfig,
  applyReasoningTrace,
]

);

const value = useMemo( () => ({ messages, isBusy, chatStarted, bannerState, error, send, uiState, projectCache, experienceCache, reasoningTraces, reasoningEnabled, completionTimes, markMessageRendered, }), [ messages, isBusy, chatStarted, bannerState, error, send, uiState, projectCache, experienceCache, reasoningTraces, reasoningEnabled, completionTimes, markMessageRendered, ] );

return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>; }

export function useChat() { const context = useContext(ChatContext); if (!context) { throw new Error('useChat must be used within a ChatProvider'); } return context; }

function flatten(ms: ChatMessage[], limit: number): ChatRequestMessage[] { return ms .slice(-limit) .map((message) => ({ role: message.role, content: message.parts .map((part) => { if (part.kind === 'text') { return part.text; } return '[unsupported part]'; }) .join('

') .trim(), })) .filter((entry) => entry.content.length > 0); }

function normalizeProjectPayload(project?: CacheableProject): CacheableProject | null { if (!project) { return null; } const slug = typeof project.slug === 'string' && project.slug.trim().length ? project.slug.trim() : undefined; const name = typeof project.name === 'string' && project.name.trim().length ? project.name.trim() : undefined; const id = typeof project.id === 'string' && project.id.trim().length ? project.id.trim() : undefined; const fallback = slug ?? id ?? name; if (!fallback) { return null; } return { ...project, id: id ?? fallback, slug: slug ?? fallback, name: name ?? fallback, }; }

function coerceProjectAttachment(attachment: ChatAttachment): CacheableProject | null { if (!attachment?.data || typeof attachment.data !== 'object') { return null; } const record = { ...(attachment.data as Record<string, unknown>) } as CacheableProject & Record<string, unknown>; if (!('id' in record) || typeof record.id !== 'string' || !record.id?.trim()) { (record as Record<string, unknown>).id = attachment.id; } if (!('slug' in record) || typeof record.slug !== 'string' || !record.slug?.trim()) { (record as Record<string, unknown>).slug = attachment.id; } if (!('name' in record) || typeof record.name !== 'string' || !record.name?.trim()) { (record as Record<string, unknown>).name = (record as { slug?: string }).slug ?? attachment.id; } return normalizeProjectPayload(record as CacheableProject); }

function coerceResumeAttachment(attachment: ChatAttachment): ResumeEntry | null { if (!attachment?.data || typeof attachment.data !== 'object') { return null; } const record = { ...(attachment.data as Record<string, unknown>) } as ResumeEntry & Record<string, unknown>; if (!('id' in record) || typeof record.id !== 'string' || !record.id?.trim()) { (record as Record<string, unknown>).id = attachment.id; } if ('slug' in record && typeof record.slug === 'string') { (record as Record<string, unknown>).slug = record.slug.trim(); } if ('company' in record && (!record.type || record.type === 'experience')) { (record as Record<string, unknown>).type = 'experience'; } return record as ResumeEntry; }

function normalizeExperienceKey(value?: string | null) { return value?.trim().toLowerCase() ?? ''; }

function createMessageId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return chat-${Date.now()}-${Math.random().toString(16).slice(2)}; }