import React, { useMemo, useRef, useEffect } from 'react'; import { Observation, Summary, UserPrompt, FeedItem } from '../types'; import { ObservationCard } from './ObservationCard'; import { SummaryCard } from './SummaryCard'; import { SummarySkeleton } from './SummarySkeleton'; import { PromptCard } from './PromptCard'; import { UI } from '../constants/ui'; interface FeedProps { observations: Observation[]; summaries: Summary[]; prompts: UserPrompt[]; processingSessions: Set; onLoadMore: () => void; isLoading: boolean; hasMore: boolean; } export function Feed({ observations, summaries, prompts, processingSessions, onLoadMore, isLoading, hasMore }: FeedProps) { const loadMoreRef = useRef(null); const onLoadMoreRef = useRef(onLoadMore); // Keep the callback ref up to date useEffect(() => { onLoadMoreRef.current = onLoadMore; }, [onLoadMore]); // Set up intersection observer for infinite scroll useEffect(() => { const element = loadMoreRef.current; if (!element) return; const observer = new IntersectionObserver( (entries) => { const first = entries[0]; if (first.isIntersecting && hasMore && !isLoading) { onLoadMoreRef.current?.(); } }, { threshold: UI.LOAD_MORE_THRESHOLD } ); observer.observe(element); return () => { if (element) { observer.unobserve(element); } observer.disconnect(); }; }, [hasMore, isLoading]); const items = useMemo(() => { // Create a set of session IDs that already have summaries const sessionsWithSummaries = new Set(summaries.map(s => s.session_id)); // Find the most recent prompt for each processing session const sessionPrompts = new Map(); prompts.forEach(p => { const existing = sessionPrompts.get(p.claude_session_id); if (!existing || p.created_at_epoch > existing.created_at_epoch) { sessionPrompts.set(p.claude_session_id, p); } }); // Create skeleton items for sessions being processed that don't have summaries yet const skeletons: FeedItem[] = []; processingSessions.forEach(sessionId => { if (!sessionsWithSummaries.has(sessionId)) { const prompt = sessionPrompts.get(sessionId); skeletons.push({ itemType: 'skeleton', id: sessionId, // Don't add prefix - key construction adds itemType already session_id: sessionId, project: prompt?.project, // Always use current time so skeletons appear at top of feed created_at_epoch: Date.now() }); } }); // Data is already filtered by App.tsx - no need to filter again const combined = [ ...observations.map(o => ({ ...o, itemType: 'observation' as const })), ...summaries.map(s => ({ ...s, itemType: 'summary' as const })), ...prompts.map(p => ({ ...p, itemType: 'prompt' as const })), ...skeletons ]; return combined .sort((a, b) => b.created_at_epoch - a.created_at_epoch); }, [observations, summaries, prompts, processingSessions]); return (
{items.map(item => { const key = `${item.itemType}-${item.id}`; if (item.itemType === 'observation') { return ; } else if (item.itemType === 'summary') { return ; } else if (item.itemType === 'skeleton') { return ; } else { return ; } })} {items.length === 0 && !isLoading && (
No items to display
)} {isLoading && (
Loading more...
)} {hasMore && !isLoading && items.length > 0 && (
)} {!hasMore && items.length > 0 && (
No more items to load
)}
); }