import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { AlertCircle, Brain, CheckCircle2, MessageSquareText, Terminal, Wrench, } from 'lucide-react'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import { buildGraphLogPreviewLaneIdsByMember, useGraphMemberLogPreviews, } from '../hooks/useGraphMemberLogPreviews'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { MemberLogPreviewItem, MemberLogPreviewMember, } from '@features/member-log-stream/contracts'; import type { MemberActivityFilter, MemberDetailTab, } from '@renderer/components/team/members/memberDetailTypes'; const LOG_PREVIEW_FALLBACK_WIDTH = 260; const LOG_PREVIEW_FALLBACK_HEIGHT = 292; const NEW_LOG_HIGHLIGHT_MS = 1_000; const COMPACT_ROW_TITLE_LIMIT = 24; const COMPACT_ROW_TEXT_LIMIT = 110; const COMPACT_ROW_MIN_PREVIEW_LIMIT = 96; const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto'; interface StableRectLike { left: number; top: number; right: number; bottom: number; width: number; height: number; } interface GraphMemberLogPreviewHudProps { teamName: string; nodes: GraphNode[]; getLogWorldRect?: (ownerNodeId: string) => StableRectLike | null; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getViewportSize?: () => { width: number; height: number }; focusNodeIds: ReadonlySet | null; enabled?: boolean; onOpenMemberProfile?: ( memberName: string, options?: { initialTab?: MemberDetailTab; initialActivityFilter?: MemberActivityFilter; } ) => void; } function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } function buildRenderedItemKey(memberName: string, itemId: string): string { return JSON.stringify([normalizeMemberName(memberName), itemId]); } function formatRelativeTime(timestamp: string): string { const parsed = Date.parse(timestamp); if (!Number.isFinite(parsed)) return ''; const diffMs = Date.now() - parsed; if (diffMs < 60_000) return 'now'; const minutes = Math.floor(diffMs / 60_000); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); return `${days}d`; } function itemIcon(item: MemberLogPreviewItem): React.JSX.Element { const className = 'size-3 shrink-0'; const title = item.title.trim().toLowerCase(); if (item.tone === 'error') { return ; } if ( title.includes('message') || title.includes('comment') || title === 'send message' || title === 'message sent' || title === 'add comment' || title === 'comment added' ) { return ; } if (item.kind === 'tool_result') { return ; } if (item.kind === 'tool_use') { return ; } if (item.kind === 'thinking') { return ; } return ; } function hasOpenCodeRuntimeWarning(preview: MemberLogPreviewMember | undefined): boolean { return ( preview?.warnings.some( (warning) => warning.code === 'opencode_runtime_timeout' || warning.code === 'opencode_runtime_unavailable' || warning.code === 'opencode_ambiguous_lane' ) === true ); } function hasOpenCodeDeliveryDelayedWarning(preview: MemberLogPreviewMember | undefined): boolean { return preview?.warnings.some((warning) => warning.code === 'opencode_delivery_delayed') === true; } function hasOpenCodeEmptyStateWarning(preview: MemberLogPreviewMember | undefined): boolean { return hasOpenCodeDeliveryDelayedWarning(preview) || hasOpenCodeRuntimeWarning(preview); } function resolveEmptyText( preview: MemberLogPreviewMember | undefined, loading: boolean, error: string | null ): string { const hasCodexUnsupportedWarning = preview?.warnings.some( (warning) => warning.code === 'codex_member_wide_not_supported' ); const hasOnlyCodexUnsupportedCoverage = hasCodexUnsupportedWarning === true && (preview?.coverage.length ?? 0) > 0 && preview?.coverage.every((coverage) => coverage.provider === 'codex_native_trace'); if (hasOnlyCodexUnsupportedCoverage) { return 'Unsupported provider'; } if ((preview?.items.length ?? 0) === 0 && hasOpenCodeDeliveryDelayedWarning(preview)) { return 'OpenCode logs delayed'; } if ((preview?.items.length ?? 0) === 0 && hasOpenCodeRuntimeWarning(preview)) { return 'Logs unavailable'; } if (loading && !preview) return 'Loading logs'; if (error && !preview) return 'Logs unavailable'; return 'No recent logs'; } function fallbackDisplayTitle(item: MemberLogPreviewItem): string { if (item.kind === 'tool_result') { return item.tone === 'error' ? 'Tool error' : 'Tool result'; } if (item.kind === 'tool_use') { return item.toolName?.trim() || 'Tool use'; } if (item.kind === 'thinking') { return 'Thinking'; } return item.tone === 'error' ? 'Error' : 'Log event'; } function compactDisplayTitle(item: MemberLogPreviewItem): string { const title = item.title.trim() || fallbackDisplayTitle(item); if (title.toLowerCase() === 'tool result') { return title; } if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) { return title.slice(0, -' result'.length).trim() || title; } return title; } function truncateCompactTitle(value: string): string { const compact = value.replace(/\s+/g, ' ').trim(); if (compact.length <= COMPACT_ROW_TITLE_LIMIT) { return compact; } return `${compact.slice(0, COMPACT_ROW_TITLE_LIMIT - 3).trimEnd()}...`; } function trimRepeatedTitlePrefix(preview: string, title: string): string { const normalizedPreview = preview.toLowerCase(); const normalizedTitle = title.toLowerCase(); if (normalizedPreview.startsWith(`${normalizedTitle} - `)) { return preview.slice(title.length + 3).trim(); } if (normalizedPreview.startsWith(`${normalizedTitle}: `)) { return preview.slice(title.length + 2).trim(); } if (normalizedPreview.startsWith(`${normalizedTitle} `)) { return preview.slice(title.length + 1).trim(); } return preview; } function compactPreviewText( item: MemberLogPreviewItem, displayTitle: string, rawDisplayTitle = displayTitle ): string { const preview = item.preview?.trim(); if (preview) { const rawTitle = item.title.trim(); const compact = trimRepeatedTitlePrefix( trimRepeatedTitlePrefix(trimRepeatedTitlePrefix(preview, rawTitle), rawDisplayTitle), displayTitle ); return compact || preview; } if (item.kind === 'tool_result') { return item.tone === 'error' ? 'No error output' : 'No output'; } if (item.kind === 'tool_use') { return 'No input'; } return item.sourceLabel || 'Log event'; } function truncateCompactRowPreview( preview: string, displayTitle: string, relativeTime: string ): string { const normalized = preview.replace(/\s+/g, ' ').trim(); const metaLength = displayTitle.length + relativeTime.length + (relativeTime ? 2 : 1); const previewLimit = Math.max(COMPACT_ROW_MIN_PREVIEW_LIMIT, COMPACT_ROW_TEXT_LIMIT - metaLength); if (normalized.length <= previewLimit) return normalized; return `${normalized.slice(0, Math.max(0, previewLimit - 3)).trimEnd()}...`; } function compactRowLabel(parts: readonly (string | null | undefined)[]): string { return parts .map((part) => part?.trim()) .filter((part): part is string => Boolean(part)) .join(' '); } function setShellHidden(shell: HTMLDivElement): void { shell.style.opacity = '0'; shell.style.pointerEvents = 'none'; } function renderLoadingSkeleton(): React.JSX.Element { return ( ); } export const GraphMemberLogPreviewHud = ({ teamName, nodes, getLogWorldRect = () => null, getCameraZoom = () => 1, worldToScreen, getViewportSize, focusNodeIds, enabled = true, onOpenMemberProfile, }: GraphMemberLogPreviewHudProps): React.JSX.Element | null => { const { t } = useAppTranslation('team'); const worldLayerRef = useRef(null); const shellRefs = useRef(new Map()); const visibleKeyRef = useRef(''); const knownItemIdsByMemberRef = useRef(new Map>()); const highlightTimersRef = useRef(new Map>()); const [visibleMemberNames, setVisibleMemberNames] = useState([]); const [highlightedItemIds, setHighlightedItemIds] = useState>( () => new Set() ); const { teamData } = useGraphActivityContext(teamName); const members = teamData?.members ?? []; const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]); const ownerNodes = useMemo( () => nodes.filter((node): node is GraphNode & { kind: 'lead' | 'member' } => { return ( (node.kind === 'lead' || node.kind === 'member') && (node.domainRef.kind === 'lead' || node.domainRef.kind === 'member') ); }), [nodes] ); const { previewsByMember, loading, error } = useGraphMemberLogPreviews({ teamName, memberNames: visibleMemberNames, laneIdsByMember, enabled: enabled && visibleMemberNames.length > 0, maxItemsPerMember: 3, textLimit: 200, }); const openLogs = useCallback( (memberName: string) => { onOpenMemberProfile?.(memberName, { initialTab: 'logs' }); }, [onOpenMemberProfile] ); useEffect(() => { knownItemIdsByMemberRef.current.clear(); setHighlightedItemIds(new Set()); for (const timer of highlightTimersRef.current.values()) { clearTimeout(timer); } highlightTimersRef.current.clear(); }, [teamName]); useEffect(() => { return () => { for (const timer of highlightTimersRef.current.values()) { clearTimeout(timer); } highlightTimersRef.current.clear(); }; }, []); useEffect(() => { if (!enabled) return; const newItemKeys: string[] = []; for (const [memberKey, preview] of previewsByMember) { const currentIds = new Set(preview.items.map((item) => item.id)); const knownIds = knownItemIdsByMemberRef.current.get(memberKey); if (knownIds) { for (const itemId of currentIds) { if (!knownIds.has(itemId)) { newItemKeys.push(buildRenderedItemKey(memberKey, itemId)); } } } knownItemIdsByMemberRef.current.set(memberKey, currentIds); } if (newItemKeys.length === 0) return; setHighlightedItemIds((current) => { const next = new Set(current); for (const itemKey of newItemKeys) { next.add(itemKey); } return next; }); for (const itemKey of newItemKeys) { const existingTimer = highlightTimersRef.current.get(itemKey); if (existingTimer) { clearTimeout(existingTimer); } const timer = setTimeout(() => { highlightTimersRef.current.delete(itemKey); setHighlightedItemIds((current) => { if (!current.has(itemKey)) return current; const next = new Set(current); next.delete(itemKey); return next; }); }, NEW_LOG_HIGHLIGHT_MS); highlightTimersRef.current.set(itemKey, timer); } }, [enabled, previewsByMember]); useLayoutEffect(() => { if (!enabled || ownerNodes.length === 0) { for (const shell of shellRefs.current.values()) { if (shell) setShellHidden(shell); } setVisibleMemberNames([]); visibleKeyRef.current = ''; return; } let frameId = 0; const updatePositions = (): void => { const worldLayer = worldLayerRef.current; if (worldLayer && worldToScreen) { const origin = worldToScreen(0, 0); const zoom = Math.max(getCameraZoom(), 0.001); worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`; } const visibleNames: string[] = []; for (const node of ownerNodes) { const shell = shellRefs.current.get(node.id); if (!shell) continue; const laneRect = getLogWorldRect(node.id); if (!laneRect || !worldToScreen || laneRect.width <= 0 || laneRect.height <= 0) { setShellHidden(shell); continue; } const zoom = Math.max(getCameraZoom(), 0.001); const screenTopLeft = worldToScreen(laneRect.left, laneRect.top); const widthScreen = Math.max(1, laneRect.width * zoom); const heightScreen = Math.max(1, laneRect.height * zoom); const viewport = getViewportSize?.(); const laneVisible = !viewport || (screenTopLeft.x + widthScreen > -80 && screenTopLeft.x < viewport.width + 80 && screenTopLeft.y + heightScreen > -80 && screenTopLeft.y < viewport.height + 80); if (!laneVisible) { setShellHidden(shell); continue; } const baseOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1; shell.style.opacity = String(baseOpacity); shell.style.pointerEvents = 'none'; shell.style.left = `${Math.round(laneRect.left)}px`; shell.style.top = `${Math.round(laneRect.top)}px`; shell.style.width = `${Math.round(laneRect.width)}px`; shell.style.height = `${Math.round(laneRect.height)}px`; if (node.domainRef.kind === 'lead' || node.domainRef.kind === 'member') { visibleNames.push(node.domainRef.memberName); } } const nextVisibleKey = visibleNames .map(normalizeMemberName) .sort((left, right) => left.localeCompare(right)) .join('|'); if (nextVisibleKey !== visibleKeyRef.current) { visibleKeyRef.current = nextVisibleKey; setVisibleMemberNames(visibleNames); } frameId = window.requestAnimationFrame(updatePositions); }; updatePositions(); return () => { window.cancelAnimationFrame(frameId); }; }, [ enabled, focusNodeIds, getCameraZoom, getLogWorldRect, getViewportSize, ownerNodes, worldToScreen, ]); const forwardWheelToGraph = useCallback((event: WheelEvent, shell: HTMLDivElement) => { const graphRoot = shell.closest('.team-graph-view'); const canvas = graphRoot?.querySelector('canvas'); if (!(canvas instanceof HTMLCanvasElement)) { return; } if (event.cancelable) { event.preventDefault(); } canvas.dispatchEvent( new WheelEvent('wheel', { deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, clientX: event.clientX, clientY: event.clientY, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey, bubbles: true, cancelable: true, }) ); }, []); useEffect(() => { if (!enabled) { return; } const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = []; for (const node of ownerNodes) { const shell = shellRefs.current.get(node.id); if (!shell) continue; const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell); shell.addEventListener('wheel', handler, { passive: false }); listeners.push({ shell, handler }); } return () => { for (const { shell, handler } of listeners) { shell.removeEventListener('wheel', handler); } }; }, [enabled, forwardWheelToGraph, ownerNodes]); const renderItem = useCallback( (memberName: string, item: MemberLogPreviewItem) => { const relativeTime = formatRelativeTime(item.timestamp); const rawDisplayTitle = compactDisplayTitle(item); const displayTitle = truncateCompactTitle(rawDisplayTitle); const fullPreviewText = compactPreviewText(item, displayTitle, rawDisplayTitle); const previewText = truncateCompactRowPreview(fullPreviewText, displayTitle, relativeTime); const titleText = compactRowLabel([rawDisplayTitle, relativeTime, fullPreviewText]); const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id)); const isError = item.tone === 'error'; const rowStateClassName = isHighlighted ? isError ? 'border-rose-300/75 bg-rose-950/35 shadow-[0_0_0_1px_rgba(253,164,175,0.30),0_0_18px_rgba(244,63,94,0.22)] hover:border-rose-300/80 hover:bg-rose-950/45' : 'border-sky-300/70 bg-[rgba(14,34,62,0.74)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)] hover:border-sky-300/75 hover:bg-[rgba(14,34,62,0.82)]' : isError ? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30' : 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]'; const iconClassName = isError ? 'inline-flex size-4 shrink-0 items-center justify-center rounded bg-rose-500/10' : 'inline-flex size-4 shrink-0 items-center justify-center rounded bg-white/5'; const headerClassName = 'flex h-4 min-w-0 items-center gap-1.5'; const titleClassName = isError ? 'min-w-0 truncate text-[10.5px] font-medium leading-4 text-rose-100' : 'min-w-0 truncate text-[10.5px] font-medium leading-4 text-slate-200'; const timeClassName = isError ? 'shrink-0 text-[9px] font-normal leading-4 text-rose-300/70' : 'shrink-0 text-[9px] font-normal leading-4 text-slate-500'; const previewClassName = isError ? 'mt-1 line-clamp-2 min-w-0 break-words text-[10px] leading-[15px] text-rose-100/85' : 'mt-1 line-clamp-2 min-w-0 break-words text-[10px] leading-[15px] text-slate-300/85'; return ( ); }, [highlightedItemIds, openLogs] ); if (!enabled || ownerNodes.length === 0) { return null; } return (
{ownerNodes.map((node) => { const laneRect = getLogWorldRect(node.id); const laneWidth = laneRect?.width ?? LOG_PREVIEW_FALLBACK_WIDTH; const laneHeight = laneRect?.height ?? LOG_PREVIEW_FALLBACK_HEIGHT; const memberName = node.domainRef.kind === 'lead' || node.domainRef.kind === 'member' ? node.domainRef.memberName : node.label; const preview = previewsByMember.get(normalizeMemberName(memberName)); const items = preview?.items ?? []; const isEmptyLoading = loading && (!preview || (items.length === 0 && hasOpenCodeEmptyStateWarning(preview))); return (
{ shellRefs.current.set(node.id, element); }} className="pointer-events-none absolute z-10 origin-top-left select-none opacity-0" style={{ width: `${laneWidth}px`, maxWidth: `${laneWidth}px`, height: `${laneHeight}px`, }} onDragStart={(event) => { event.preventDefault(); }} >
{t('agentGraph.logPreview.logs')}
{items.length > 0 ? ( items.slice(0, 3).map((item) => renderItem(memberName, item)) ) : isEmptyLoading ? ( ) : ( )} {preview && preview.overflowCount > 0 ? ( ) : null}
); })}
); };