import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ACTIVITY_LANE, getTransientHandoffCardAlpha, type TransientHandoffCard, } from '@claude-teams/agent-graph'; import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import { buildTransientHandoffMessage } from './buildTransientHandoffMessage'; import { GraphActivityCard } from './GraphActivityCard'; interface GraphTransientHandoffHudProps { teamName: string; getTransientHandoffSnapshot?: (options?: { focusNodeIds?: ReadonlySet | null; focusEdgeIds?: ReadonlySet | null; }) => { cards: TransientHandoffCard[]; time: number }; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; focusNodeIds: ReadonlySet | null; focusEdgeIds: ReadonlySet | null; enabled?: boolean; } const CARD_WIDTH = ACTIVITY_LANE.width; const CARD_HEIGHT = 72; const STACK_GAP = 10; export const GraphTransientHandoffHud = ({ teamName, getTransientHandoffSnapshot = () => ({ cards: [], time: 0 }), getCameraZoom = () => 1, worldToScreen, getNodeWorldPosition = () => null, focusNodeIds, focusEdgeIds, enabled = true, }: GraphTransientHandoffHudProps): React.JSX.Element | null => { const worldLayerRef = useRef(null); const shellRefs = useRef(new Map()); const signatureRef = useRef(''); const [cards, setCards] = useState([]); const { teamData, teams } = useGraphActivityContext(teamName); const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); useEffect(() => { signatureRef.current = ''; setCards([]); }, [teamName]); useLayoutEffect(() => { if (!enabled) { setCards([]); return; } let frameId = 0; const tick = (): void => { const snapshot = getTransientHandoffSnapshot({ focusNodeIds, focusEdgeIds, }); const nextCards = snapshot.cards.filter( (card) => card.anchorKind === 'lead' || card.anchorKind === 'member' ); const nextSignature = nextCards .map((card) => `${card.key}:${card.count}:${card.updatedAt}:${card.anchorNodeId}`) .join('|'); if (nextSignature !== signatureRef.current) { signatureRef.current = nextSignature; setCards(nextCards); } 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 stackIndexByAnchor = new Map(); for (const card of nextCards) { const shell = shellRefs.current.get(card.key); if (!shell) { continue; } const nodeWorld = getNodeWorldPosition(card.anchorNodeId); const alpha = getTransientHandoffCardAlpha(card, snapshot.time); if (!nodeWorld || !worldToScreen || alpha <= 0.001) { shell.style.opacity = '0'; continue; } const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0; stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1); const lift = stackIndex * (CARD_HEIGHT * 0.34 + STACK_GAP); const scale = 0.94 + alpha * 0.06; shell.style.left = `${Math.round(nodeWorld.x)}px`; shell.style.top = `${Math.round(nodeWorld.y)}px`; shell.style.opacity = String(alpha); shell.style.transform = `translate(-50%, calc(-50% - ${lift.toFixed(1)}px)) scale(${scale.toFixed(3)})`; } frameId = window.requestAnimationFrame(tick); }; tick(); return () => { window.cancelAnimationFrame(frameId); }; }, [ enabled, focusEdgeIds, focusNodeIds, getCameraZoom, getNodeWorldPosition, getTransientHandoffSnapshot, worldToScreen, ]); const handoffMessages = useMemo( () => cards.map((card, index) => ({ card, message: buildTransientHandoffMessage(teamName, card), zebraShade: index % 2 === 1, })), [cards, teamName] ); if (!enabled || !teamData || cards.length === 0) { return null; } return (
{handoffMessages.map(({ card, message, zebraShade }) => (
{ shellRefs.current.set(card.key, element); }} className="pointer-events-none absolute z-[9] origin-center opacity-0 transition-opacity duration-150 ease-out" style={{ width: `${CARD_WIDTH}px`, maxWidth: `${CARD_WIDTH}px`, }} onDragStart={(event) => { event.preventDefault(); }} >
))}
); };