import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { type SubagentPreviewMessage, SubagentRecentMessagesPreview, } from '@renderer/components/team/members/SubagentRecentMessagesPreview'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; import { formatDuration } from '@renderer/utils/formatters'; import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; import { AlertCircle, ChevronDown, ChevronRight, Clock, FileText, Loader2, MessageSquare, } from 'lucide-react'; import type { EnhancedChunk } from '@renderer/types/data'; import type { MemberLogSummary } from '@shared/types'; interface MemberLogsTabProps { teamName: string; memberName?: string; taskId?: string; /** When viewing task logs: include owner's sessions when task is in_progress */ taskOwner?: string; taskStatus?: string; /** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */ taskWorkIntervals?: { startedAt: string; completedAt?: string }[]; /** Notifies parent when a background refresh starts/ends. */ onRefreshingChange?: (isRefreshing: boolean) => void; /** Show last few subagent messages as a quick "where are we?" preview (task view only). */ showSubagentPreview?: boolean; /** * Optional: for lead-owned tasks, show a quick preview from the lead session. * (This is lead activity, not "member-only" activity.) */ showLeadPreview?: boolean; /** Notifies parent when preview looks "online" (recent output). */ onPreviewOnlineChange?: (isOnline: boolean) => void; } export const MemberLogsTab = ({ teamName, memberName, taskId, taskOwner, taskStatus, taskWorkIntervals, onRefreshingChange, showSubagentPreview = false, showLeadPreview = false, onPreviewOnlineChange, }: MemberLogsTabProps): React.JSX.Element => { const MIN_REFRESH_VISIBLE_MS = 250; const intervalsKey = useMemo( () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), [taskWorkIntervals] ); const isMountedRef = useRef(true); const hasLoadedRef = useRef(false); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const refreshCountRef = useRef(0); const refreshBeganAtRef = useRef(null); const refreshHideTimeoutRef = useRef | null>(null); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); const expandedIdRef = useRef(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [previewChunks, setPreviewChunks] = useState(null); useEffect(() => { return () => { isMountedRef.current = false; if (refreshHideTimeoutRef.current) { clearTimeout(refreshHideTimeoutRef.current); refreshHideTimeoutRef.current = null; } }; }, []); useEffect(() => { expandedIdRef.current = expandedId; }, [expandedId]); const beginRefreshing = useCallback((): void => { if (refreshCountRef.current === 0) { refreshBeganAtRef.current = Date.now(); if (refreshHideTimeoutRef.current) { clearTimeout(refreshHideTimeoutRef.current); refreshHideTimeoutRef.current = null; } } refreshCountRef.current += 1; if (isMountedRef.current) setRefreshing(true); }, []); const endRefreshing = useCallback((): void => { refreshCountRef.current = Math.max(0, refreshCountRef.current - 1); if (refreshCountRef.current > 0) { if (isMountedRef.current) setRefreshing(true); return; } const beganAt = refreshBeganAtRef.current; refreshBeganAtRef.current = null; const elapsed = beganAt ? Date.now() - beganAt : Number.POSITIVE_INFINITY; if (!isMountedRef.current) return; if (elapsed >= MIN_REFRESH_VISIBLE_MS) { setRefreshing(false); return; } const remaining = Math.max(0, MIN_REFRESH_VISIBLE_MS - elapsed); refreshHideTimeoutRef.current = setTimeout(() => { refreshHideTimeoutRef.current = null; if (!isMountedRef.current) return; if (refreshCountRef.current === 0) setRefreshing(false); }, remaining); }, []); const getRowId = useCallback((log: MemberLogSummary): string => { return log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` : `lead:${log.sessionId}`; }, []); const sortedLogs = useMemo(() => { const nowMs = Date.now(); const getLastActivityMs = (log: MemberLogSummary): number => { const startMs = new Date(log.startTime).getTime(); if (!Number.isFinite(startMs)) return Number.NaN; const durationMs = Number.isFinite(log.durationMs) ? Math.max(0, log.durationMs) : 0; const endMs = startMs + durationMs; // Keep actively-updating logs at the top even if duration lags slightly. return log.isOngoing ? Math.max(endMs, nowMs) : endMs; }; const withIndex = logs.map((log, index) => ({ log, index })); withIndex.sort((a, b) => { const aTime = getLastActivityMs(a.log); const bTime = getLastActivityMs(b.log); if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime; if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1; if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1; return a.index - b.index; }); return withIndex.map((x) => x.log); }, [logs]); const shouldShowPreview = useMemo(() => { return taskId != null && (showSubagentPreview || showLeadPreview); }, [showLeadPreview, showSubagentPreview, taskId]); const previewLog = useMemo((): MemberLogSummary | null => { if (!shouldShowPreview) return null; if (showSubagentPreview) { const candidates = sortedLogs.filter((l) => l.kind === 'subagent'); if (candidates.length === 0) return null; if (taskOwner) { const target = taskOwner.trim().toLowerCase(); const match = candidates.find((l) => (l.memberName ?? '').trim().toLowerCase() === target); // When viewing task logs, this preview is intended to show the assigned owner's progress. // If we can't confidently match a subagent log to the owner, don't show anything // rather than risk showing a different member's activity. return match ?? null; } return candidates[0] ?? null; } if (showLeadPreview) { return sortedLogs.find((l) => l.kind === 'lead_session') ?? null; } return null; }, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]); const previewMessages = useMemo((): SubagentPreviewMessage[] => { if (!previewChunks || previewChunks.length === 0) return []; return extractSubagentPreviewMessages(previewChunks, 4); }, [previewChunks]); const previewOnline = useMemo((): boolean => { const newest = previewMessages[0]; if (!newest) return false; return Date.now() - newest.timestamp.getTime() <= 10_000; }, [previewMessages]); const expandedLogSummary = useMemo(() => { if (!expandedId) return null; return logs.find((log) => getRowId(log) === expandedId) ?? null; }, [expandedId, getRowId, logs]); useEffect(() => { onRefreshingChange?.(refreshing); return () => onRefreshingChange?.(false); }, [refreshing, onRefreshingChange]); useEffect(() => { onPreviewOnlineChange?.(previewOnline); }, [onPreviewOnlineChange, previewOnline]); useEffect(() => { return () => onPreviewOnlineChange?.(false); }, [onPreviewOnlineChange]); useEffect(() => { if (!expandedId) return; if (expandedLogSummary) return; setExpandedId(null); setDetailChunks(null); setDetailLoading(false); }, [expandedId, expandedLogSummary]); useEffect(() => { let cancelled = false; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; const load = async (): Promise => { let didBeginRefreshing = false; try { if (taskId == null && !memberName) { if (!cancelled) setLogs([]); return; } if (!hasLoadedRef.current) { setLoading(true); } else { beginRefreshing(); didBeginRefreshing = true; } setError(null); const result = taskId != null ? await api.teams.getLogsForTask(teamName, taskId, { owner: taskOwner, status: taskStatus, intervals: taskWorkIntervals, }) : await api.teams.getMemberLogs(teamName, memberName!); const nextLogs = Array.isArray(result) ? [...result] : []; if (!cancelled) { setLogs(nextLogs); hasLoadedRef.current = true; } // Keep expanded session details in sync with the same refresh // cadence as the summary (counts/titles) while "Updating..." is shown. if (!cancelled && didBeginRefreshing) { try { await refreshExpandedDetailFromLogs(nextLogs); } catch { // Keep last successful detail view; avoid flicker on transient failures. } } } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : 'Unknown error'); } } finally { if (!cancelled) { setLoading(false); if (didBeginRefreshing) endRefreshing(); } } }; void load(); const interval = shouldAutoRefresh ? setInterval(() => void load(), 5000) : null; return () => { cancelled = true; if (interval) clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); const fetchDetailForLog = useCallback( async ( log: MemberLogSummary, options?: { bypassCache?: boolean } ): Promise => { if (log.kind === 'subagent') { const d = await api.getSubagentDetail( log.projectId, log.sessionId, log.subagentId, options ); return d?.chunks ?? null; } const d = await api.getSessionDetail(log.projectId, log.sessionId, options); return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; }, [] ); const refreshExpandedDetailFromLogs = useCallback( async (nextLogs: MemberLogSummary[]): Promise => { const rowId = expandedIdRef.current; if (!rowId) return; if (!isMountedRef.current) return; const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId); if (!nextExpanded) return; const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return; const next = await fetchDetailForLog(nextExpanded, { bypassCache: true }); if (!isMountedRef.current) return; // Ensure new reference so memoized transforms update. setDetailChunks(next ? [...next] : null); }, [fetchDetailForLog, getRowId, taskId, taskStatus] ); useEffect(() => { if (!shouldShowPreview) { setPreviewChunks(null); return; } if (!previewLog) { setPreviewChunks(null); return; } let cancelled = false; const run = async (): Promise => { try { const next = await fetchDetailForLog(previewLog); if (cancelled) return; setPreviewChunks(next ? [...next] : null); } catch { if (cancelled) return; setPreviewChunks(null); } }; void run(); return () => { cancelled = true; }; }, [fetchDetailForLog, previewLog, shouldShowPreview]); useEffect(() => { if (!shouldShowPreview) return; if (!previewLog) return; const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing; if (!shouldAutoRefreshPreview) return; let cancelled = false; const interval = setInterval(async () => { beginRefreshing(); try { const next = await fetchDetailForLog(previewLog, { bypassCache: true }); if (cancelled) return; setPreviewChunks(next ? [...next] : null); } catch { // keep last successful preview } finally { endRefreshing(); } }, 5000); return () => { cancelled = true; clearInterval(interval); }; }, [ beginRefreshing, endRefreshing, fetchDetailForLog, previewLog, shouldShowPreview, taskStatus, ]); useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!expandedLogSummary) return; // When task logs are auto-refreshing, the summary refresh loop also refreshes // expanded details to keep everything in sync (and avoid duplicate requests). if (shouldAutoRefreshSummary) return; if (!expandedLogSummary.isOngoing) return; let cancelled = false; const refreshDetail = async (): Promise => { beginRefreshing(); try { const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true }); if (cancelled) return; // Ensure new reference so memoized transforms update. setDetailChunks(next ? [...next] : null); } catch { // Keep last successful data; avoid flicker during transient errors. } finally { endRefreshing(); } }; const interval = setInterval(() => void refreshDetail(), 5000); return () => { cancelled = true; clearInterval(interval); }; }, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]); const handleExpand = useCallback( async (log: MemberLogSummary) => { const rowId = getRowId(log); if (expandedId === rowId) { setExpandedId(null); setDetailChunks(null); return; } setExpandedId(rowId); setDetailChunks(null); setDetailLoading(true); try { const shouldBypassCache = log.isOngoing || taskStatus === 'in_progress'; const chunks = await fetchDetailForLog( log, shouldBypassCache ? { bypassCache: true } : undefined ); setDetailChunks(chunks ? [...chunks] : null); } catch { setDetailChunks(null); } finally { setDetailLoading(false); } }, [expandedId, fetchDetailForLog, getRowId, taskStatus] ); if (loading && logs.length === 0) { return (
Searching logs...
); } if (error) { return (
{error}
); } if (logs.length === 0) { return (
No logs found

{taskId != null ? taskStatus === 'in_progress' ? 'Task is in progress — waiting for session activity (auto-refreshing)...' : 'No session activity for this task yet' : 'This member has no recorded session activity yet'}

); } return (
{shouldShowPreview && previewLog && previewMessages.length > 0 ? ( ) : null} {sortedLogs.map((log) => ( void handleExpand(log)} /> ))}
); }; interface LogCardProps { log: MemberLogSummary; expanded: boolean; detailChunks: EnhancedChunk[] | null; detailLoading: boolean; onToggle: () => void; } const LogCard = ({ log, expanded, detailChunks, detailLoading, onToggle, }: LogCardProps): React.JSX.Element => { const timeAgo = formatRelativeTime(log.startTime); return (
{expanded ? 'Hide details' : 'Show details'} {expanded && (
{detailLoading && (
Loading details...
)} {!detailLoading && !detailChunks && (
Failed to load details
)} {!detailLoading && detailChunks && (
)}
)}
); }; function formatRelativeTime(isoString: string): string { const date = new Date(isoString); const now = Date.now(); const diffMs = now - date.getTime(); const diffMin = Math.floor(diffMs / 60_000); const diffHours = Math.floor(diffMin / 60); const diffDays = Math.floor(diffHours / 24); if (diffMin < 1) return 'just now'; if (diffMin < 60) return `${diffMin}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } function extractSubagentPreviewMessages( chunks: EnhancedChunk[], limit: number ): SubagentPreviewMessage[] { const conversation = transformChunksToConversation(chunks, [], false); const out: SubagentPreviewMessage[] = []; // Collect newest-first and stop as soon as we have enough. for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) { const item = conversation.items[i]; if (item.type === 'ai') { const enhanced = enhanceAIGroup(item.group); const items = enhanced.displayItems ?? []; for (let j = items.length - 1; j >= 0 && out.length < limit; j--) { const di = items[j]; if (di.type === 'output' && di.content.trim()) { out.push({ id: `${item.group.id}:output:${di.timestamp.toISOString()}:${j}`, timestamp: di.timestamp, kind: 'output', label: 'Output', content: di.content, }); } else if (di.type === 'teammate_message') { out.push({ id: `${item.group.id}:teammate:${di.teammateMessage.id}`, timestamp: di.teammateMessage.timestamp, kind: 'teammate_message', label: `Message — ${di.teammateMessage.teammateId}`, content: di.teammateMessage.content || di.teammateMessage.summary, }); } } } else if (item.type === 'user') { const text = item.group.content.rawText ?? item.group.content.text ?? ''; if (text.trim()) { out.push({ id: `${item.group.id}:user:${item.group.timestamp.toISOString()}`, timestamp: item.group.timestamp, kind: 'user', label: 'User', content: text, }); } } } return out; }