import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ACTIVITY_LANE } from '@claude-teams/agent-graph'; import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext'; import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { buildInlineActivityEntries, getGraphLeadMemberName, type InlineActivityEntry, } from '../../core/domain/buildInlineActivityEntries'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import { GraphActivityCard } from './GraphActivityCard'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; import type { MemberActivityFilter, MemberDetailTab, } from '@renderer/components/team/members/memberDetailTypes'; import type { ResolvedTeamMember } from '@shared/types/team'; const ACTIVITY_SHELL_HEIGHT = ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.overflowHeight; interface GraphActivityHudProps { teamName: string; nodes: GraphNode[]; getActivityWorldRect?: (ownerNodeId: string) => { left: number; top: number; right: number; bottom: number; width: number; height: number; } | null; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; getViewportSize?: () => { width: number; height: number }; focusNodeIds: ReadonlySet | null; enabled?: boolean; onOpenTaskDetail?: (taskId: string) => void; onOpenMemberProfile?: ( memberName: string, options?: { initialTab?: MemberDetailTab; initialActivityFilter?: MemberActivityFilter; } ) => void; } export const GraphActivityHud = ({ teamName, nodes, getActivityWorldRect = () => null, getCameraZoom = () => 1, worldToScreen, getNodeWorldPosition = () => null, getViewportSize, focusNodeIds, enabled = true, onOpenTaskDetail, onOpenMemberProfile, }: GraphActivityHudProps): React.JSX.Element | null => { const worldLayerRef = useRef(null); const shellRefs = useRef(new Map()); const connectorRefs = useRef(new Map()); const connectorPathRefs = useRef(new Map()); const [expandedItem, setExpandedItem] = useState(null); const { teamData, teams } = useGraphActivityContext(teamName); const teamSnapshot = teamData; const members = teamData?.members ?? []; const messages = teamData?.messageFeed ?? []; const ownerNodes = useMemo( () => nodes.filter( (node): node is GraphNode & { kind: 'lead' | 'member' } => node.kind === 'lead' || node.kind === 'member' ), [nodes] ); const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`; const leadName = teamSnapshot ? getGraphLeadMemberName({ members }, teamName) : `${teamName}-lead`; const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]); const entryMapByOwnerNodeId = useMemo(() => { if (!teamSnapshot) { return new Map(); } return buildInlineActivityEntries({ data: { members, tasks: teamSnapshot.tasks, messages, }, teamName, leadId: leadNodeId, leadName, ownerNodeIds, }); }, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]); const messageContext = useMemo(() => buildMessageContext(members), [members]); const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); const { readSet } = useTeamMessagesRead(teamName); useEffect(() => { setExpandedItem(null); }, [teamName]); const visibleLanes = useMemo(() => { return ownerNodes .map((node) => { const graphItems = node.activityItems ?? []; const overflowCount = node.activityOverflowCount ?? 0; const visibleCount = Math.max(0, graphItems.length - overflowCount); const visibleGraphItems = graphItems.slice(0, visibleCount); const entriesById = new Map( (entryMapByOwnerNodeId.get(node.id) ?? []).map( (entry) => [entry.graphItem.id, entry] as const ) ); const entries = visibleGraphItems .map((item) => entriesById.get(item.id)) .filter((entry): entry is NonNullable => Boolean(entry)); return { node, entries, overflowCount, }; }) .filter( (lane) => lane.node.kind === 'member' || lane.entries.length > 0 || lane.overflowCount > 0 ); }, [entryMapByOwnerNodeId, ownerNodes]); useLayoutEffect(() => { if (!enabled || visibleLanes.length === 0) { for (const shell of shellRefs.current.values()) { if (shell) { shell.style.opacity = '0'; } } for (const connector of connectorRefs.current.values()) { if (connector) { connector.style.opacity = '0'; } } 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 measurableLanes: { lane: (typeof visibleLanes)[number]; shell: HTMLDivElement; connector: SVGSVGElement | null; connectorPath: SVGPathElement | null; laneRect: NonNullable>; nodeWorld: { x: number; y: number }; }[] = []; for (const lane of visibleLanes) { const shell = shellRefs.current.get(lane.node.id); if (!shell) { continue; } const connector = connectorRefs.current.get(lane.node.id) ?? null; const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null; const laneRect = getActivityWorldRect(lane.node.id); const nodeWorld = getNodeWorldPosition(lane.node.id); if (!laneRect || !nodeWorld || !worldToScreen) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; } 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) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; } continue; } measurableLanes.push({ lane, shell, connector, connectorPath, laneRect, nodeWorld, }); } for (const entry of measurableLanes) { const { lane, shell, connector, connectorPath, laneRect, nodeWorld } = entry; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; shell.style.opacity = String(baseOpacity); shell.style.left = `${Math.round(laneRect.left)}px`; shell.style.top = `${Math.round(laneRect.top)}px`; shell.style.transform = ''; if (connector && connectorPath) { const endX = laneRect.left + laneRect.width / 2; const endY = laneRect.top >= nodeWorld.y ? laneRect.top + 10 : laneRect.bottom - 10; const startX = nodeWorld.x; const startY = nodeWorld.y - 18; const minX = Math.min(startX, endX); const minY = Math.min(startY, endY); const connectorWidth = Math.max(1, Math.abs(endX - startX)); const connectorHeight = Math.max(1, Math.abs(endY - startY)); const localStartX = startX - minX; const localStartY = startY - minY; const localEndX = endX - minX; const localEndY = endY - minY; const dx = localEndX - localStartX; const curve = Math.max(28, Math.abs(dx) * 0.35); const c1x = localStartX + Math.sign(dx || 1) * curve; const c1y = localStartY; const c2x = localEndX - Math.sign(dx || 1) * curve; const c2y = localEndY; connector.style.opacity = String(baseOpacity); connector.style.left = `${Math.round(minX)}px`; connector.style.top = `${Math.round(minY)}px`; connector.setAttribute('width', String(Math.ceil(connectorWidth))); connector.setAttribute('height', String(Math.ceil(connectorHeight))); connector.setAttribute( 'viewBox', `0 0 ${Math.ceil(connectorWidth)} ${Math.ceil(connectorHeight)}` ); connectorPath.setAttribute( 'd', `M ${localStartX.toFixed(1)} ${localStartY.toFixed(1)} C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${localEndX.toFixed(1)} ${localEndY.toFixed(1)}` ); } } frameId = window.requestAnimationFrame(updatePositions); }; updatePositions(); return () => { window.cancelAnimationFrame(frameId); }; }, [ enabled, focusNodeIds, getActivityWorldRect, getCameraZoom, getNodeWorldPosition, getViewportSize, worldToScreen, visibleLanes, ]); const handleMessageClick = useCallback((item: TimelineItem) => { setExpandedItem(item); }, []); const handleMessageKeyDown = useCallback( (event: React.KeyboardEvent, item: TimelineItem): void => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleMessageClick(item); } }, [handleMessageClick] ); const handleMemberClick = useCallback( (member: ResolvedTeamMember) => { onOpenMemberProfile?.(member.name); }, [onOpenMemberProfile] ); const handleOpenOwnerActivity = useCallback( (node: GraphNode & { kind: 'lead' | 'member' }) => { if (node.domainRef.kind !== 'lead' && node.domainRef.kind !== 'member') { return; } onOpenMemberProfile?.(node.domainRef.memberName, { initialTab: 'activity', initialActivityFilter: 'all', }); }, [onOpenMemberProfile] ); 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 lane of visibleLanes) { const shell = shellRefs.current.get(lane.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, visibleLanes]); const renderLaneEntry = useCallback( (entry: InlineActivityEntry, index: number): React.JSX.Element => { const messageKey = toMessageKey(entry.message); const timelineItem: TimelineItem = { type: 'message', message: entry.message, }; const isUnread = !entry.message.read && !readSet.has(messageKey); return (
handleMessageClick(timelineItem)} onKeyDown={(event) => handleMessageKeyDown(event, timelineItem)} > handleMessageClick(timelineItem)} onOpenTaskDetail={onOpenTaskDetail} onOpenMemberProfile={onOpenMemberProfile} />
); }, [ handleMessageClick, handleMessageKeyDown, messageContext, onOpenMemberProfile, onOpenTaskDetail, readSet, teamColorByName, teamName, teamNames, ] ); if (!enabled || !teamSnapshot || visibleLanes.length === 0) { return null; } return ( <>
{visibleLanes.map((lane) => (
{(() => { const laneRect = getActivityWorldRect(lane.node.id); const laneWidth = laneRect?.width ?? ACTIVITY_LANE.width; const laneHeight = laneRect?.height ?? ACTIVITY_SHELL_HEIGHT; return ( <> { connectorRefs.current.set(lane.node.id, element); }} className="pointer-events-none absolute z-[9] overflow-visible opacity-0" > { connectorPathRefs.current.set(lane.node.id, element); }} d="" fill="none" stroke="rgba(148, 163, 184, 0.3)" strokeWidth="1.25" strokeLinecap="round" strokeDasharray="3 4" />
{ shellRefs.current.set(lane.node.id, element); }} className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0" style={{ width: `${laneWidth}px`, maxWidth: `${laneWidth}px`, height: `${laneHeight}px`, }} onDragStart={(event) => { event.preventDefault(); }} >
Activity
{lane.entries.length === 0 && lane.overflowCount === 0 ? (
No recent activity
) : null} {lane.entries.map(renderLaneEntry)} {lane.overflowCount > 0 ? ( ) : null}
); })()}
))}
{ if (!open) { setExpandedItem(null); } }} teamName={teamName} members={members} onMemberClick={handleMemberClick} onTaskIdClick={onOpenTaskDetail} teamNames={teamNames} teamColorByName={teamColorByName} /> ); };