"use client"; import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import type { BeadsApiResponse, GraphNode, GraphLink, ColorMode, AssigneeAvatarInfo } from "@/lib/types"; import { getCatppuccinPrefixColor, getPrefixLabel } from "@/lib/types"; import { diffBeadsData, linkKey } from "@/lib/diff-beads"; import type { BeadsDiff } from "@/lib/diff-beads"; import BeadsGraph from "@/components/BeadsGraph"; import type { BeadsGraphHandle } from "@/components/BeadsGraph"; import NodeDetail from "@/components/NodeDetail"; import { AuthButton } from "@/components/AuthButton"; import { BeadsLogo } from "@/components/BeadsLogo"; import { CommentTooltip } from "@/components/CommentTooltip"; import { ContextMenu } from "@/components/ContextMenu"; import { DescriptionModal } from "@/components/DescriptionModal"; import { BeadTooltip } from "@/components/BeadTooltip"; import AllCommentsPanel from "@/components/AllCommentsPanel"; import { ActivityOverlay } from "@/components/ActivityOverlay"; import { ActivityPanel } from "@/components/ActivityPanel"; import { TasksPanel } from "@/components/TasksPanel"; import { HelpPanel } from "@/components/HelpPanel"; import { LeaderboardOverlay } from "@/components/LeaderboardOverlay"; import { LeaderboardPanel } from "@/components/LeaderboardPanel"; import type { LeaderboardEntry } from "@/components/LeaderboardOverlay"; import { SettingsModal } from "@/components/SettingsModal"; import { TutorialOverlay, TUTORIAL_STEPS } from "@/components/TutorialOverlay"; import { MobileActionSheet } from "@/components/MobileActionSheet"; import { ProfilePanel, UserProfile } from "@/components/ProfilePanel"; import { useBeadsComments } from "@/hooks/useBeadsComments"; import type { BeadsComment } from "@/hooks/useBeadsComments"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useUrlState } from "@/hooks/useUrlState"; import { useTheme } from "@/hooks/useTheme"; import { useAuth } from "@/lib/auth"; import { buildHistoricalFeed, diffToActivityEvents, mergeFeedEvents, } from "@/lib/activity"; import type { ActivityEvent } from "@/lib/activity"; import { buildTimelineEvents, filterDataAtTime } from "@/lib/timeline"; import type { TimelineRange } from "@/lib/timeline"; import TimelineBar from "@/components/TimelineBar"; import { formatRelativeTime } from "@/lib/utils"; type SearchResult = | { type: "node"; node: GraphNode } | { type: "profile"; handle: string; avatar?: string }; // Check if a node has been claimed (has a comment that is just "@handle") function isNodeClaimed(comments?: BeadsComment[]): boolean { if (!comments) return false; return comments.some( (c) => c.text.startsWith("@") && c.text.trim().indexOf(" ") === -1 ); } // Find position of a neighbor node (for placing new nodes near connections) function findNeighborPosition( nodeId: string, links: GraphLink[], nodeMap: Map ): { x: number; y: number } | null { for (const link of links) { const src = typeof link.source === "object" ? (link.source as { id: string }).id : link.source; const tgt = typeof link.target === "object" ? (link.target as { id: string }).id : link.target; if (src === nodeId && nodeMap.has(tgt)) { const n = nodeMap.get(tgt)!; if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number }; } if (tgt === nodeId && nodeMap.has(src)) { const n = nodeMap.get(src)!; if (n.x != null && n.y != null) return { x: n.x as number, y: n.y as number }; } } return null; } // Merge old (with simulation positions) and new (from server) beads data, // stamping animation metadata for spawn/exit/change transitions. function mergeBeadsData( oldData: BeadsApiResponse, newData: BeadsApiResponse, diff: BeadsDiff ): BeadsApiResponse { const now = Date.now(); // Build position map from old nodes (preserves x/y/fx/fy from simulation) const oldNodeMap = new Map(oldData.graphData.nodes.map((n) => [n.id, n])); const oldLinkKeySet = new Set(oldData.graphData.links.map(linkKey)); // Merge nodes: carry over positions, stamp animation metadata const mergedNodes: GraphNode[] = newData.graphData.nodes.map((node) => { const oldNode = oldNodeMap.get(node.id); if (!oldNode) { // NEW NODE — stamp spawn time, place near a connected neighbor const neighbor = findNeighborPosition( node.id, newData.graphData.links, oldNodeMap ); return { ...node, _spawnTime: now, x: neighbor ? neighbor.x + (Math.random() - 0.5) * 40 : undefined, y: neighbor ? neighbor.y + (Math.random() - 0.5) * 40 : undefined, } as GraphNode; } // EXISTING NODE — preserve position, check for changes const merged: GraphNode = { ...node, x: oldNode.x, y: oldNode.y, fx: oldNode.fx, fy: oldNode.fy, }; // Stamp change metadata if status changed if (diff.changedNodes.has(node.id)) { const changes = diff.changedNodes.get(node.id)!; const statusChange = changes.find((c) => c.field === "status"); if (statusChange) { merged._changedAt = now; merged._prevStatus = statusChange.from; } } return merged; }); // Handle removed nodes: keep them briefly for exit animation for (const removedId of diff.removedNodeIds) { const oldNode = oldNodeMap.get(removedId); if (oldNode) { mergedNodes.push({ ...oldNode, _removeTime: now, } as GraphNode); } } // Merge links: stamp spawn time on new links const mergedLinks = newData.graphData.links.map((link) => { const key = linkKey(link); if (!oldLinkKeySet.has(key)) { return { ...link, _spawnTime: now }; } return link; }); // Handle removed links: keep briefly for exit animation for (const removedKey of diff.removedLinkKeys) { const oldLink = oldData.graphData.links.find( (l) => linkKey(l) === removedKey ); if (oldLink) { mergedLinks.push({ source: typeof oldLink.source === "object" ? (oldLink.source as { id: string }).id : oldLink.source, target: typeof oldLink.target === "object" ? (oldLink.target as { id: string }).id : oldLink.target, type: oldLink.type, _removeTime: now, }); } } return { ...newData, graphData: { nodes: mergedNodes as GraphNode[], links: mergedLinks as GraphLink[], }, }; } // Status badge colors for search results const STATUS_DOT_COLORS: Record = { open: "bg-emerald-500", in_progress: "bg-amber-500", blocked: "bg-red-500", deferred: "bg-violet-500", closed: "bg-zinc-400", }; export default function Home() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); const [collapsedEpicIds, setCollapsedEpicIds] = useState>(new Set()); const [focusedEpicId, setFocusedEpicId] = useState(null); const [colorMode, setColorMode] = useState("status"); const [activeLegendFilters, setActiveLegendFilters] = useState>(new Set()); const [projectName, setProjectName] = useState("Beads"); const [repoCount, setRepoCount] = useState(0); const [repoUrls, setRepoUrls] = useState>({}); // Auth state const { isAuthenticated, session } = useAuth(); // Theme state const { theme, toggleTheme } = useTheme(); // Comments from ATProto indexer const { commentsByNode, commentedNodeIds, allComments, refetch: refetchComments } = useBeadsComments(); // URL state for shareable views const url = useUrlState(); // Optimistic claims — immediately show avatar after user claims, before indexer picks it up // rkey is undefined for optimistic (not yet indexed), set once comment is fetched const [optimisticClaims, setOptimisticClaims] = useState< Map >(new Map()); // Optimistic unclaims — suppress nodes where user just unclaimed (until refetch clears them) const [optimisticUnclaims, setOptimisticUnclaims] = useState>(new Set()); // Timeline replay state const [timelineActive, setTimelineActive] = useState(false); const [timelineStep, setTimelineStep] = useState(0); const [timelinePlaying, setTimelinePlaying] = useState(false); const [timelineSpeed, setTimelineSpeed] = useState(1); const [timelineData, setTimelineData] = useState(null); // Clear legend filters when color mode changes useEffect(() => { setActiveLegendFilters(new Set()); }, [colorMode]); // Set of node IDs in the local graph — used to filter global comments/activity // to only events relevant to beads in this repo const localNodeIds = useMemo(() => { if (!data) return new Set(); return new Set(data.graphData.nodes.map((n) => n.id)); }, [data]); // Set of known prefixes — used to allow general comments posted to prefixes const knownPrefixes = useMemo(() => { if (!data) return new Set(); const prefixes = data.stats?.prefixes || []; const set = new Set(); for (const p of prefixes) { set.add(p); // backward compat: old bare-prefix comments set.add(`${p}-general`); // new convention: {prefix}-general } return set; }, [data]); // Compute claimed node avatars from comments + optimistic claims // A claim comment has text "@handle" (starts with @, no spaces) const claimedNodeAvatars = useMemo(() => { const map = new Map(); // First: add from comments (has rkey for deletion) if (allComments) { for (const comment of allComments) { if (!localNodeIds.has(comment.nodeId)) continue; if (map.has(comment.nodeId)) continue; if (optimisticUnclaims.has(comment.nodeId)) continue; // suppressed by unclaim const text = comment.text.trim(); if (text.startsWith("@") && text.indexOf(" ") === -1) { map.set(comment.nodeId, { avatar: comment.avatar, handle: comment.handle, claimedAt: comment.createdAt, did: comment.did, rkey: comment.rkey, }); } } } // Then: add optimistic claims (only if not already from comments and not unclaimed) for (const [nodeId, info] of optimisticClaims) { if (!map.has(nodeId) && !optimisticUnclaims.has(nodeId)) { map.set(nodeId, info); } } return map; }, [allComments, optimisticClaims, optimisticUnclaims, localNodeIds]); // Compute assignee node avatars from beads data // Maps node ID -> { handle, avatar? } for nodes with valid ATProto handles // At most ONE entry per unique assignee (most recent node wins) const assigneeNodeAvatars = useMemo(() => { const map = new Map(); if (!data) return map; if (timelineActive && !timelineData) return map; // Group nodes by assignee handle const nodesByAssignee = new Map(); const sourceNodes = timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes; for (const node of sourceNodes) { if (!node.assignee) continue; // Valid ATProto handle must contain at least one dot if (!node.assignee.includes(".")) continue; // Skip if already claimed (claim avatars take priority) if (claimedNodeAvatars.has(node.id)) continue; if (!nodesByAssignee.has(node.assignee)) { nodesByAssignee.set(node.assignee, []); } nodesByAssignee.get(node.assignee)!.push(node); } // For each unique assignee, pick the most recent node for (const [handle, nodes] of nodesByAssignee) { // Sort by updatedAt descending (most recent first) nodes.sort((a, b) => { const cmp = b.updatedAt.localeCompare(a.updatedAt); if (cmp !== 0) return cmp; // Tiebreak: prefer non-closed if (a.status !== "closed" && b.status === "closed") return -1; if (a.status === "closed" && b.status !== "closed") return 1; // Tiebreak: alphabetically-first node ID return a.id.localeCompare(b.id); }); const selectedNode = nodes[0]; map.set(selectedNode.id, { handle, avatar: undefined, // Will be resolved in task 2 updatedAt: selectedNode.updatedAt, }); } return map; }, [data, claimedNodeAvatars, timelineActive, timelineData]); // Resolved assignee avatars from ATProto API // Maps handle -> avatar URL const [resolvedAssigneeAvatars, setResolvedAssigneeAvatars] = useState>(new Map()); // Resolve ATProto handles to avatar URLs useEffect(() => { if (assigneeNodeAvatars.size === 0) return; const controller = new AbortController(); const handlesToResolve: string[] = []; // Collect unique handles that need resolution for (const [, info] of assigneeNodeAvatars) { if (!resolvedAssigneeAvatars.has(info.handle)) { handlesToResolve.push(info.handle); } } if (handlesToResolve.length === 0) return; // Resolve in batches of 5 const batchSize = 5; const batches: string[][] = []; for (let i = 0; i < handlesToResolve.length; i += batchSize) { batches.push(handlesToResolve.slice(i, i + batchSize)); } async function resolveBatch(handles: string[]) { const promises = handles.map(async (handle) => { try { const response = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, { signal: controller.signal } ); if (!response.ok) return null; const data = await response.json(); return { handle, avatar: data.avatar }; } catch (err) { // Skip on error (fallback letter circle will show) return null; } }); const results = await Promise.allSettled(promises); const resolved = new Map(); for (const result of results) { if (result.status === "fulfilled" && result.value && result.value.avatar) { resolved.set(result.value.handle, result.value.avatar); } } return resolved; } // Process all batches sequentially (async () => { for (const batch of batches) { const resolved = await resolveBatch(batch); if (resolved.size > 0) { setResolvedAssigneeAvatars((prev) => new Map([...prev, ...resolved])); } } })(); return () => controller.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [assigneeNodeAvatars]); // Merge resolved avatars into assigneeNodeAvatars const assigneeNodeAvatarsResolved = useMemo(() => { const map = new Map(assigneeNodeAvatars); for (const [nodeId, info] of map) { const resolved = resolvedAssigneeAvatars.get(info.handle); if (resolved) { map.set(nodeId, { ...info, avatar: resolved }); } } return map; }, [assigneeNodeAvatars, resolvedAssigneeAvatars]); // Leaderboard time filter state (local + URL sync) type LeaderboardTimeFilter = "all" | "month" | "week" | "yesterday"; const [leaderboardTimeFilter, setLeaderboardTimeFilterState] = useState(url.initial.timeFilter || "all"); const setLeaderboardTimeFilter = useCallback((filter: LeaderboardTimeFilter) => { setLeaderboardTimeFilterState(filter); url.setTimeFilter(filter); }, [url]); // Compute base leaderboard stats (without time filter) - expensive operation const baseLeaderboardStats = useMemo(() => { if (!data) return new Map; closedNodes: Array<{ closedAt: string }>; comments: Array<{ createdAt: string }>; }>(); const nodes = data.graphData.nodes; const handleStats = new Map; closedNodes: Array<{ closedAt: string }>; comments: Array<{ createdAt: string }>; }>(); const getOrCreate = (handle: string) => { if (!handle) return null; let entry = handleStats.get(handle); if (!entry) { entry = { handle, createdNodes: [], closedNodes: [], comments: [] }; handleStats.set(handle, entry); } return entry; }; // Collect per-person stats from nodes for (const node of nodes) { // Created: credit the creator const creator = node.createdBy || node.owner || node.assignee; if (creator) { const e = getOrCreate(creator); if (e) e.createdNodes.push({ createdAt: node.createdAt }); } // Closed: credit the ASSIGNEE if (node.status === "closed" && node.assignee && node.closedAt) { const e = getOrCreate(node.assignee); if (e) e.closedNodes.push({ closedAt: node.closedAt }); } } // Collect comments (excluding claims) if (allComments) { for (const comment of allComments) { if (!localNodeIds.has(comment.nodeId)) continue; const isClaim = comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1; if (!isClaim) { const e = getOrCreate(comment.handle); if (e) { e.comments.push({ createdAt: comment.createdAt }); if (comment.avatar && !e.avatar) e.avatar = comment.avatar; if (comment.did && !e.did) e.did = comment.did; } } } } // Pull avatars from resolved assignee avatars for (const [, info] of assigneeNodeAvatarsResolved) { const e = handleStats.get(info.handle); if (e && info.avatar && !e.avatar) e.avatar = info.avatar; } // Also check resolvedAssigneeAvatars cache for (const [handle, avatar] of resolvedAssigneeAvatars) { const e = handleStats.get(handle); if (e && !e.avatar) e.avatar = avatar; } return handleStats; }, [data, allComments, assigneeNodeAvatarsResolved, resolvedAssigneeAvatars, localNodeIds]); // Apply time filter to base stats - cheap operation const leaderboardEntries = useMemo((): LeaderboardEntry[] => { // Compute cutoff date for time filter let cutoff: Date | null = null; if (leaderboardTimeFilter !== "all") { const now = new Date(); if (leaderboardTimeFilter === "month") { cutoff = new Date(now); cutoff.setMonth(cutoff.getMonth() - 1); } else if (leaderboardTimeFilter === "week") { cutoff = new Date(now); cutoff.setDate(cutoff.getDate() - 7); } else if (leaderboardTimeFilter === "yesterday") { cutoff = new Date(now); cutoff.setDate(cutoff.getDate() - 1); } } const cutoffISO = cutoff ? cutoff.toISOString() : null; // Apply time filter and compute scores const entries: LeaderboardEntry[] = []; for (const stats of baseLeaderboardStats.values()) { const createdCount = cutoffISO ? stats.createdNodes.filter(n => n.createdAt >= cutoffISO).length : stats.createdNodes.length; const closedCount = cutoffISO ? stats.closedNodes.filter(n => n.closedAt >= cutoffISO).length : stats.closedNodes.length; const commentCount = cutoffISO ? stats.comments.filter(c => c.createdAt >= cutoffISO).length : stats.comments.length; const score = closedCount * 3 + createdCount + commentCount; if (score > 0) { entries.push({ handle: stats.handle, avatar: stats.avatar, did: stats.did, createdCount, closedCount, commentCount, score, }); } } entries.sort((a, b) => b.score - a.score); return entries; }, [baseLeaderboardStats, leaderboardTimeFilter]); const searchableProfiles = useMemo(() => { const profileMap = new Map(); // From leaderboard entries (best source — has avatars) for (const entry of leaderboardEntries) { profileMap.set(entry.handle, { handle: entry.handle, avatar: entry.avatar }); } // From node data (catch handles not in leaderboard) if (data) { for (const node of data.graphData.nodes) { for (const handle of [node.createdBy, node.owner, node.assignee]) { if (handle && !profileMap.has(handle)) { profileMap.set(handle, { handle }); } } } } // From resolved avatars cache (fill in missing avatars) for (const [handle, avatar] of resolvedAssigneeAvatars) { const profile = profileMap.get(handle); if (profile && !profile.avatar) profile.avatar = avatar; } // From comment avatars (catch avatars from comment authors) if (allComments) { for (const comment of allComments) { if (comment.avatar && comment.handle) { const profile = profileMap.get(comment.handle); if (profile && !profile.avatar) { profile.avatar = comment.avatar; } } } } // From claimed node avatars (catch avatars from claims) for (const [, claim] of claimedNodeAvatars) { if (claim.avatar && claim.handle) { const profile = profileMap.get(claim.handle); if (profile && !profile.avatar) { profile.avatar = claim.avatar; } } } return Array.from(profileMap.values()); }, [leaderboardEntries, data, resolvedAssigneeAvatars, allComments, claimedNodeAvatars]); // All Comments panel state const [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false); // Profile panel state const [profilePanelHandle, setProfilePanelHandle] = useState(null); // Activity feed state const [activityFeed, setActivityFeed] = useState([]); const [activityPanelOpen, setActivityPanelOpen] = useState(false); const [activityOverlayCollapsed, setActivityOverlayCollapsed] = useState(true); const [tasksPanelOpen, setTasksPanelOpen] = useState(false); const [helpPanelOpen, setHelpPanelOpen] = useState(false); const [tutorialStep, setTutorialStep] = useState(null); // Leaderboard state const [leaderboardPanelOpen, setLeaderboardPanelOpen] = useState(false); const [leaderboardOverlayCollapsed, setLeaderboardOverlayCollapsed] = useState(true); // Mobile responsiveness const isMobile = useIsMobile(); const isMobileRef = useRef(false); useEffect(() => { isMobileRef.current = isMobile; }, [isMobile]); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileActionSheet, setMobileActionSheet] = useState<{ node: GraphNode } | null>(null); // On mobile, collapse the activity overlay by default (show just the pill button) useEffect(() => { setLeaderboardOverlayCollapsed(isMobile); setActivityOverlayCollapsed(isMobile); }, [isMobile]); // Rebuild historical feed when data or comments change // Filter comments to only those targeting nodes in our graph, since the // Hypergoat indexer returns comments globally across all repos using beads useEffect(() => { if (!data) return; const localComments = allComments ? allComments.filter((c) => localNodeIds.has(c.nodeId) || knownPrefixes.has(c.nodeId)) : null; const historical = buildHistoricalFeed( data.graphData.nodes, data.graphData.links, localComments ); setActivityFeed((prev) => mergeFeedEvents(prev, historical)); }, [data, allComments, localNodeIds, knownPrefixes]); // Resolve missing actor avatars in activity feed useEffect(() => { if (activityFeed.length === 0) return; // Build a handle -> avatar lookup from comments (already resolved by useBeadsComments) const commentAvatars = new Map(); if (allComments) { for (const c of allComments) { if (c.avatar && c.handle) commentAvatars.set(c.handle, c.avatar); for (const like of c.likes) { if (like.avatar && like.handle) commentAvatars.set(like.handle, like.avatar); } } } // Merge comment avatars into the shared cache let cacheUpdated = false; const cacheAdditions = new Map(); for (const [handle, avatar] of commentAvatars) { if (!resolvedAssigneeAvatars.has(handle)) { cacheAdditions.set(handle, avatar); cacheUpdated = true; } } // Collect handles that still need API resolution const handlesToResolve = new Set(); for (const event of activityFeed) { if (event.actor && !event.actor.avatar && event.actor.handle) { const handle = event.actor.handle; if (resolvedAssigneeAvatars.has(handle) || cacheAdditions.has(handle)) continue; // Only try API for valid ATProto handles (has dot, no @) if (handle.includes(".") && !handle.includes("@")) { handlesToResolve.add(handle); } } } if (cacheUpdated && handlesToResolve.size === 0) { // We have new comment-sourced avatars but no API calls needed setResolvedAssigneeAvatars((prev) => new Map([...prev, ...cacheAdditions])); return; } if (handlesToResolve.size === 0) { // All handles resolved — patch events let patched = false; const mergedCache = new Map([...resolvedAssigneeAvatars, ...cacheAdditions]); const updated = activityFeed.map((event) => { if (event.actor && !event.actor.avatar && event.actor.handle) { const avatar = mergedCache.get(event.actor.handle); if (avatar) { patched = true; return { ...event, actor: { ...event.actor, avatar } }; } } return event; }); if (patched) { setActivityFeed(updated); } return; } // Resolve new handles via API const controller = new AbortController(); const handles = [...handlesToResolve]; const batchSize = 5; (async () => { const allResolved = new Map(cacheAdditions); for (let i = 0; i < handles.length; i += batchSize) { const batch = handles.slice(i, i + batchSize); const promises = batch.map(async (handle) => { try { const response = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, { signal: controller.signal } ); if (!response.ok) return null; const data = await response.json(); return data.avatar ? { handle, avatar: data.avatar as string } : null; } catch { return null; } }); const results = await Promise.allSettled(promises); for (const result of results) { if (result.status === "fulfilled" && result.value) { allResolved.set(result.value.handle, result.value.avatar); } } } if (allResolved.size > 0) { setResolvedAssigneeAvatars((prev) => new Map([...prev, ...allResolved])); } })(); return () => controller.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activityFeed, resolvedAssigneeAvatars, allComments]); // Context menu state for right-click (phase 1: shows ContextMenu) const [contextMenu, setContextMenu] = useState<{ node: GraphNode; x: number; y: number; } | null>(null); // Comment tooltip state (phase 2a: opened from context menu "Add comment") const [commentTooltipState, setCommentTooltipState] = useState<{ node: GraphNode; x: number; y: number; } | null>(null); // Description modal state (phase 2b: opened from context menu "Show description") const [descriptionModalNode, setDescriptionModalNode] = useState(null); // Description modal navigation: topological sort of connected component const descriptionModalFamily = useMemo(() => { if (!descriptionModalNode || !data) return []; const nodeId = descriptionModalNode.id; const allNodes = data.graphData.nodes; const allLinks = data.graphData.links; // Helper: force-graph mutates link source/target to objects after simulation const getId = (x: any): string | undefined => typeof x === "string" ? x : x?.id; // Build undirected adjacency (for finding connected component) // and directed dependency map (for topological sort) const neighbors = new Map>(); const dependsOn = new Map>(); // node → nodes it depends on (upstream) for (const link of allLinks) { if ((link as any)._removeTime) continue; // Skip links being removed (fade-out animation) const src = getId(link.source); const tgt = getId(link.target); if (!src || !tgt) continue; // Undirected for BFS if (!neighbors.has(src)) neighbors.set(src, new Set()); if (!neighbors.has(tgt)) neighbors.set(tgt, new Set()); neighbors.get(src)!.add(tgt); neighbors.get(tgt)!.add(src); // Directed: target depends on source (source is upstream) if (!dependsOn.has(tgt)) dependsOn.set(tgt, new Set()); dependsOn.get(tgt)!.add(src); } // BFS to find connected component containing current node const component = new Set(); const bfsQueue = [nodeId]; component.add(nodeId); while (bfsQueue.length > 0) { const current = bfsQueue.shift()!; for (const neighbor of neighbors.get(current) || []) { if (!component.has(neighbor)) { component.add(neighbor); bfsQueue.push(neighbor); } } } // Single node — no navigation if (component.size <= 1) return []; // Tiebreaker: sort by ID with numeric suffix awareness // e.g., "xxx.2" < "xxx.10" (numeric, not lexicographic) const compareIds = (a: string, b: string): number => { const aParts = a.split("."); const bParts = b.split("."); // Compare base part first if (aParts[0] !== bParts[0]) return a.localeCompare(b); // Same base: compare numeric suffixes depth by depth for (let i = 1; i < Math.max(aParts.length, bParts.length); i++) { const aNum = i < aParts.length ? parseInt(aParts[i], 10) : -1; const bNum = i < bParts.length ? parseInt(bParts[i], 10) : -1; if (aNum !== bNum) return aNum - bNum; } return 0; }; // Kahn's topological sort with sorted ready queue (stable, deterministic) const inDegree = new Map(); for (const id of component) { inDegree.set(id, 0); } for (const id of component) { for (const dep of dependsOn.get(id) || []) { if (component.has(dep)) { inDegree.set(id, (inDegree.get(id) || 0) + 1); } } } // Build reverse adjacency: node -> set of nodes that depend on it const dependents = new Map>(); for (const id of component) { for (const dep of dependsOn.get(id) || []) { if (component.has(dep)) { if (!dependents.has(dep)) dependents.set(dep, new Set()); dependents.get(dep)!.add(id); } } } const sorted: string[] = []; // Start with nodes that have no dependencies (in-degree 0) const ready = Array.from(component) .filter(id => (inDegree.get(id) || 0) === 0) .sort(compareIds); while (ready.length > 0) { const node = ready.shift()!; sorted.push(node); // Reduce in-degree for nodes that depend on this one for (const dependent of dependents.get(node) || []) { const newDeg = (inDegree.get(dependent) || 1) - 1; inDegree.set(dependent, newDeg); if (newDeg === 0) { // Insert in sorted position (maintain order in ready queue) const insertIdx = ready.findIndex(r => compareIds(r, dependent) > 0); if (insertIdx === -1) ready.push(dependent); else ready.splice(insertIdx, 0, dependent); } } } // Handle cycles: append remaining nodes in sorted ID order if (sorted.length < component.size) { const sortedSet = new Set(sorted); const remaining = Array.from(component) .filter(id => !sortedSet.has(id)) .sort(compareIds); sorted.push(...remaining); } // Map to GraphNode objects, filter to nodes with descriptions only const nodeMap = new Map(allNodes.map(n => [n.id, n])); return sorted .map(id => nodeMap.get(id)) .filter((n): n is GraphNode => n != null && !!n.description); }, [descriptionModalNode, data]); // Description modal navigation handler const handleDescriptionNavigate = useCallback((direction: "prev" | "next") => { if (!descriptionModalNode || descriptionModalFamily.length <= 1) return; const currentIndex = descriptionModalFamily.findIndex(n => n.id === descriptionModalNode.id); if (currentIndex === -1) { // Current node no longer in family — close modal gracefully setDescriptionModalNode(null); return; } let nextIndex: number; if (direction === "next") { nextIndex = (currentIndex + 1) % descriptionModalFamily.length; } else { nextIndex = (currentIndex - 1 + descriptionModalFamily.length) % descriptionModalFamily.length; } setDescriptionModalNode(descriptionModalFamily[nextIndex]); }, [descriptionModalNode, descriptionModalFamily]); // Description modal navigation label const descriptionNavLabel = useMemo(() => { if (!descriptionModalNode || descriptionModalFamily.length <= 1) return undefined; const idx = descriptionModalFamily.findIndex(n => n.id === descriptionModalNode.id); if (idx === -1) return undefined; return `${idx + 1}/${descriptionModalFamily.length}`; }, [descriptionModalNode, descriptionModalFamily]); // Settings modal state const [settingsModalOpen, setSettingsModalOpen] = useState(false); // Avatar hover tooltip state const [avatarTooltip, setAvatarTooltip] = useState<{ handle: string; avatar?: string; claimedAt: string; did?: string; x: number; y: number; isAssignee?: boolean; nodeId?: string; nodeClosed?: boolean; } | null>(null); // Node hover tooltip state const [nodeTooltip, setNodeTooltip] = useState<{ node: GraphNode; x: number; y: number; } | null>(null); // Search state const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchHighlightIndex, setSearchHighlightIndex] = useState(0); // Auto-fit: when true, graph auto-zooms to fit after data updates and layout changes const [autoFit, setAutoFit] = useState(true); // Pulse: highlight most recently active node with a ripple animation const [showPulse, setShowPulse] = useState(true); // Particles: toggle flow particles on dependency links const [showParticles, setShowParticles] = useState(true); const graphRef = useRef(null); const searchInputRef = useRef(null); const prevDataRef = useRef(null); const urlRestoredRef = useRef(false); // Tracks whether the desc modal was opened from the initial URL (to avoid writing back immediately) const initialDescAppliedRef = useRef(false); // Live-streaming beads data via SSE useEffect(() => { let eventSource: EventSource | null = null; let reconnectTimer: ReturnType | null = null; let fallbackTimer: ReturnType | null = null; let mounted = true; function connect() { eventSource = new EventSource("/api/beads/stream"); eventSource.onmessage = (event) => { if (!mounted) return; try { const parsed = JSON.parse(event.data); if (parsed.error) { setError(parsed.error as string); setLoading(false); return; } const newData = parsed as BeadsApiResponse; const oldData = prevDataRef.current; const diff = diffBeadsData(oldData, newData); if (!oldData) { // Initial load — no animations, just set data prevDataRef.current = newData; setData(newData); setLoading(false); return; } if (!diff.hasChanges) return; // No-op if nothing changed // Append real-time activity events from the diff const diffEvents = diffToActivityEvents(diff, newData.graphData.nodes); if (diffEvents.length > 0) { setActivityFeed((prev) => mergeFeedEvents(prev, diffEvents)); } // Merge: stamp animation metadata and preserve positions const mergedData = mergeBeadsData(oldData, newData, diff); prevDataRef.current = mergedData; setData(mergedData); } catch (err) { console.error("Failed to parse SSE message:", err); } }; eventSource.onerror = () => { // EventSource auto-reconnects, but handle permanent failure if (eventSource?.readyState === EventSource.CLOSED) { reconnectTimer = setTimeout(connect, 5000); } }; // If still loading after 5s, fall back to one-shot fetch fallbackTimer = setTimeout(() => { if (!mounted) return; if (!prevDataRef.current) { fetch("/api/beads") .then((res) => res.json()) .then((fallbackData) => { if (mounted && !prevDataRef.current) { prevDataRef.current = fallbackData; setData(fallbackData); setLoading(false); } }) .catch(() => {}); } }, 5000); } connect(); return () => { mounted = false; eventSource?.close(); if (reconnectTimer) clearTimeout(reconnectTimer); if (fallbackTimer) clearTimeout(fallbackTimer); }; }, []); // Fetch project config for dynamic name useEffect(() => { fetch("/api/config") .then((res) => res.json()) .then((config) => { if (config.name) setProjectName(config.name); if (config.repoCount) setRepoCount(config.repoCount); if (config.repoUrls) setRepoUrls(config.repoUrls); }) .catch(() => { // Fallback to defaults }); }, []); // Restore state from URL on initial data load (one-time) useEffect(() => { if (!data || urlRestoredRef.current) return; urlRestoredRef.current = true; const initial = url.initial; // Restore selected node if (initial.node) { const node = data.graphData.nodes.find(n => n.id === initial.node); if (node) setSelectedNode(node); } // Restore profile panel if (initial.profile) { setProfilePanelHandle(initial.profile); } // Restore panel (comments, activity, tasks, help, leaderboard) if (initial.panel === "comments") setAllCommentsPanelOpen(true); if (initial.panel === "activity") setActivityPanelOpen(true); if (initial.panel === "tasks") setTasksPanelOpen(true); if (initial.panel === "help") setHelpPanelOpen(true); if (initial.panel === "leaderboard") setLeaderboardPanelOpen(true); // Restore focused epic if (initial.epic) { setFocusedEpicId(initial.epic); } // Restore color mode if (initial.color && initial.color !== "status") { setColorMode(initial.color as ColorMode); } // Restore collapsed epic IDs if (initial.collapsed.size > 0) { setCollapsedEpicIds(initial.collapsed); } // Restore legend filters if (initial.filters.size > 0) { setActiveLegendFilters(initial.filters); } // Restore multi-selected nodes if (initial.selected.size > 0) { setSelectedNodeIds(initial.selected); } // Restore timeline replay if (initial.replay) { setTimelineActive(true); } // Restore description modal if (initial.desc) { const descNode = data.graphData.nodes.find(n => n.id === initial.desc); if (descNode) { initialDescAppliedRef.current = true; setDescriptionModalNode(descNode); } else { // Node not found — clear stale desc param url.setDesc(null); } } }, [data]); // Clean up expired exit animations (nodes/links with _removeTime older than 600ms) useEffect(() => { if (!data) return; const timer = setTimeout(() => { const now = Date.now(); const EXPIRE_MS = 600; const nodes = data.graphData.nodes.filter( (n) => !n._removeTime || now - n._removeTime < EXPIRE_MS ); const links = data.graphData.links.filter( (l) => !l._removeTime || now - l._removeTime < EXPIRE_MS ); if ( nodes.length !== data.graphData.nodes.length || links.length !== data.graphData.links.length ) { setData((prev) => prev ? { ...prev, graphData: { nodes, links }, } : prev ); } }, 700); // slightly after animation duration return () => clearTimeout(timer); }, [data]); // Update URL when sidebar state changes (node, profile, or panel) useEffect(() => { if (!urlRestoredRef.current) return; // Don't write URL before restore completes // Determine which sidebar is active (mutually exclusive) if (selectedNode) { url.setNode(selectedNode.id); } else if (profilePanelHandle) { url.setProfile(profilePanelHandle); } else if (allCommentsPanelOpen) { url.setPanel("comments"); } else if (activityPanelOpen) { url.setPanel("activity"); } else if (tasksPanelOpen) { url.setPanel("tasks"); } else if (helpPanelOpen) { url.setPanel("help"); } else if (leaderboardPanelOpen) { url.setPanel("leaderboard"); } else { // No sidebar open — clear sidebar params url.setNode(null); } }, [selectedNode, profilePanelHandle, allCommentsPanelOpen, activityPanelOpen, tasksPanelOpen, helpPanelOpen, leaderboardPanelOpen]); // Update URL when focused epic changes useEffect(() => { if (!urlRestoredRef.current) return; url.setEpic(focusedEpicId); }, [focusedEpicId]); // Update URL when color mode changes useEffect(() => { if (!urlRestoredRef.current) return; url.setColor(colorMode); }, [colorMode]); // Collapsed epics useEffect(() => { if (!urlRestoredRef.current) return; url.setCollapsed(collapsedEpicIds); }, [collapsedEpicIds]); // Legend filters useEffect(() => { if (!urlRestoredRef.current) return; url.setFilters(activeLegendFilters); }, [activeLegendFilters]); // Multi-selected nodes useEffect(() => { if (!urlRestoredRef.current) return; url.setSelected(selectedNodeIds); }, [selectedNodeIds]); // Timeline replay useEffect(() => { if (!urlRestoredRef.current) return; url.setReplay(timelineActive); }, [timelineActive]); // Sync description modal open/close to URL desc param useEffect(() => { if (!urlRestoredRef.current) return; // Skip the very first write if the modal was opened from the URL (avoid loop) if (initialDescAppliedRef.current) { initialDescAppliedRef.current = false; return; } url.setDesc(descriptionModalNode?.id ?? null); }, [descriptionModalNode]); // --- Timeline replay logic --- // Compute timeline event range from full data const timelineRange = useMemo(() => { if (!data) return null; return buildTimelineEvents(data.graphData.nodes, data.graphData.links); }, [data]); // Filter activity feed by timeline time during replay const timelineFilteredActivity = useMemo(() => { if (!timelineActive || !timelineRange) return activityFeed; if (timelineStep === -1) return []; if (timelineRange.events.length === 0) return activityFeed; const event = timelineRange.events[timelineStep]; if (!event) return activityFeed; const currentTime = event.time; return activityFeed.filter((e) => e.time <= currentTime); }, [activityFeed, timelineActive, timelineRange, timelineStep]); // Filter leaderboard by timeline during replay const timelineFilteredLeaderboard = useMemo((): LeaderboardEntry[] => { if (!timelineActive || !timelineRange || !timelineData) return leaderboardEntries; if (timelineStep === -1) return []; if (timelineRange.events.length === 0) return leaderboardEntries; const event = timelineRange.events[timelineStep]; if (!event) return leaderboardEntries; const currentTime = event.time; // Recompute leaderboard from timeline-filtered nodes only const nodes = timelineData.graphData.nodes; const handleStats = new Map(); const getOrCreate = (handle: string) => { if (!handle) return null; let entry = handleStats.get(handle); if (!entry) { entry = { handle, createdCount: 0, closedCount: 0, commentCount: 0 }; handleStats.set(handle, entry); } return entry; }; // Count per-person stats from timeline-filtered nodes for (const node of nodes) { const creator = node.createdBy || node.owner || node.assignee; if (creator) { const e = getOrCreate(creator); if (e) e.createdCount++; } if (node.status === "closed" && node.assignee) { const e = getOrCreate(node.assignee); if (e) e.closedCount++; } } // Count comments that happened before the current timeline time if (allComments) { for (const comment of allComments) { if (!localNodeIds.has(comment.nodeId)) continue; const commentTime = new Date(comment.createdAt).getTime(); if (commentTime > currentTime) continue; // skip future comments const isClaim = comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1; if (!isClaim) { const e = getOrCreate(comment.handle); if (e) { e.commentCount++; if (comment.avatar && !e.avatar) e.avatar = comment.avatar; if (comment.did && !e.did) e.did = comment.did; } } } } // Pull avatars from resolved sources for (const [, info] of assigneeNodeAvatarsResolved) { const e = handleStats.get(info.handle); if (e && info.avatar && !e.avatar) e.avatar = info.avatar; } for (const [handle, avatar] of resolvedAssigneeAvatars) { const e = handleStats.get(handle); if (e && !e.avatar) e.avatar = avatar; } // Build entries with score and sort const entries: LeaderboardEntry[] = []; for (const stats of handleStats.values()) { const score = stats.closedCount * 3 + stats.createdCount + stats.commentCount; if (score > 0) { entries.push({ ...stats, score }); } } entries.sort((a, b) => b.score - a.score); return entries; }, [leaderboardEntries, timelineActive, timelineRange, timelineStep, timelineData, allComments, assigneeNodeAvatarsResolved, resolvedAssigneeAvatars, localNodeIds]); // Compute UserProfile from existing data for ProfilePanel const profilePanelUser = useMemo((): UserProfile | null => { if (!profilePanelHandle || !data) return null; const handle = profilePanelHandle; const nodes = timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes; const currentTimelineTime = timelineActive && timelineRange && timelineStep >= 0 ? timelineRange.events[timelineStep]?.time : undefined; const localComments = allComments ? allComments.filter(c => localNodeIds.has(c.nodeId) || knownPrefixes.has(c.nodeId)) : []; const comments = currentTimelineTime ? localComments.filter(c => new Date(c.createdAt).getTime() <= currentTimelineTime) : localComments; // Find avatar from various sources let avatar: string | undefined; let did: string | undefined; // Check claimedNodeAvatars if (claimedNodeAvatars) { for (const [, claim] of claimedNodeAvatars) { if (claim.handle === handle) { avatar = avatar || claim.avatar; did = did || claim.did; break; } } } // Check assigneeNodeAvatars if (assigneeNodeAvatarsResolved) { for (const [, info] of assigneeNodeAvatarsResolved) { if (info.handle === handle) { avatar = avatar || info.avatar; break; } } } // Check comments for avatar for (const comment of comments) { if (comment.handle === handle) { avatar = avatar || comment.avatar; did = did || comment.did; break; } } // Assigned nodes: nodes where assignee matches handle const assignedNodes = nodes.filter(n => n.assignee === handle); const assignedCount = assignedNodes.length; // Closed nodes: assigned or created by this user AND status is closed const closedCount = nodes.filter(n => n.status === "closed" && (n.assignee === handle || n.createdBy === handle) ).length; // Claimed nodes let claimedCount = 0; if (claimedNodeAvatars) { for (const [, claim] of claimedNodeAvatars) { if (claim.handle === handle) claimedCount++; } } // Comments by this user const userComments = comments.filter(c => c.handle === handle); const nonClaimComments = userComments.filter(c => !(c.text.startsWith("@") && c.text.trim().indexOf(" ") === -1)); const commentCount = nonClaimComments.length; // Likes given by this user (scan all comments for likes by this handle) let likeCount = 0; for (const comment of comments) { for (const like of comment.likes) { if (like.handle === handle) likeCount++; } } // Projects: count nodes per prefix where user is assignee or createdBy const projectCounts = new Map(); for (const node of nodes) { if (node.assignee === handle || node.createdBy === handle) { projectCounts.set(node.prefix, (projectCounts.get(node.prefix) || 0) + 1); } } const projects = Array.from(projectCounts.entries()) .sort((a, b) => b[1] - a[1]) .map(([prefix, count]) => ({ prefix, prefixLabel: getPrefixLabel(prefix), color: getCatppuccinPrefixColor(prefix), count, })); // Recent activity: build from nodes + comments + claims + likes const recentActivity: UserProfile["recentActivity"] = []; // Assigned nodes for (const node of assignedNodes) { recentActivity.push({ type: "assigned", nodeId: node.id, nodeTitle: node.title, time: node.updatedAt, }); } // Closed nodes for (const node of nodes) { if (node.status === "closed" && node.closedAt && (node.assignee === handle || node.createdBy === handle)) { recentActivity.push({ type: "closed", nodeId: node.id, nodeTitle: node.title, time: node.closedAt, }); } } // Comments for (const comment of userComments) { // Skip claim comments — they appear in the "Claims" section below if (comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1) continue; recentActivity.push({ type: "commented", nodeId: comment.nodeId, nodeTitle: nodes.find(n => n.id === comment.nodeId)?.title || comment.nodeId, time: comment.createdAt, detail: comment.text.slice(0, 60) + (comment.text.length > 60 ? "..." : ""), }); } // Claims (claim comments start with @ and have no space) for (const comment of comments) { if (comment.handle === handle && comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1) { recentActivity.push({ type: "claimed", nodeId: comment.nodeId, nodeTitle: nodes.find(n => n.id === comment.nodeId)?.title || comment.nodeId, time: comment.createdAt, }); } } // Likes given for (const comment of comments) { for (const like of comment.likes) { if (like.handle === handle) { recentActivity.push({ type: "liked", nodeId: comment.nodeId, nodeTitle: nodes.find(n => n.id === comment.nodeId)?.title || comment.nodeId, time: like.createdAt, }); } } } // Sort newest-first, cap at 20 recentActivity.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()); const capped = recentActivity.slice(0, 20); // Last active: most recent timestamp from all activity const lastActiveAt = capped.length > 0 ? capped[0].time : undefined; return { handle, avatar, did, assignedCount, closedCount, claimedCount, commentCount, likeCount, projects, recentActivity: capped, lastActiveAt, }; }, [profilePanelHandle, data, claimedNodeAvatars, assigneeNodeAvatarsResolved, allComments, timelineActive, timelineData, timelineRange, timelineStep, localNodeIds]); // Toggle timeline mode on/off const handleTimelineToggle = useCallback(() => { setTimelineActive((prev) => { const next = !prev; if (next) { setTimelineStep(-1); setTimelinePlaying(false); setTimelineData(null); } else { setTimelinePlaying(false); setTimelineData(null); } return next; }); }, []); // Event-step playback: advance one step every 5s/speed useEffect(() => { if (!timelinePlaying || !timelineActive || !timelineRange) return; const intervalMs = 2000 / timelineSpeed; const interval = setInterval(() => { setTimelineStep((prev) => { const next = prev + 1; if (next >= timelineRange.events.length) { setTimelinePlaying(false); return prev; } return next; }); }, intervalMs); return () => clearInterval(interval); }, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]); // Compute timelineData via diff/merge pipeline when step changes useEffect(() => { if (!timelineActive || !data || !timelineRange) return; // Preamble step: empty canvas if (timelineStep === -1) { setTimelineData((prev) => { const empty: BeadsApiResponse = { ...data, graphData: { nodes: [], links: [] }, }; if (!prev) return empty; const diff = diffBeadsData(prev, empty); if (!diff.hasChanges) return prev; return mergeBeadsData(prev, empty, diff); }); return; } if (timelineRange.events.length === 0) return; const event = timelineRange.events[timelineStep]; if (!event) return; const filtered = filterDataAtTime( data.graphData.nodes, data.graphData.links, event.time ); const newSnapshot: BeadsApiResponse = { ...data, graphData: { nodes: filtered.nodes, links: filtered.links }, }; setTimelineData((prev) => { if (!prev) return newSnapshot; // first frame — no merge needed const diff = diffBeadsData(prev, newSnapshot); if (!diff.hasChanges) return prev; return mergeBeadsData(prev, newSnapshot, diff); }); }, [timelineActive, data, timelineRange, timelineStep]); // --- End timeline replay logic --- const handleNodeClick = useCallback((node: GraphNode, event?: MouseEvent) => { // Cmd+Click (Mac) or Ctrl+Click (Windows/Linux): open description modal if ((event?.metaKey || event?.ctrlKey) && node.description) { setDescriptionModalNode(node); setAllCommentsPanelOpen(false); setProfilePanelHandle(null); return; } if (event?.shiftKey) { // Shift+click: toggle node in multi-selection setSelectedNodeIds((prev) => { const next = new Set(prev); if (next.has(node.id)) { next.delete(node.id); } else { next.add(node.id); } return next; }); // Clear single selection when shift-clicking setSelectedNode(null); return; } // Normal click: single-select (existing behavior) setSelectedNode((prev) => (prev?.id === node.id ? null : node)); setSelectedNodeIds(new Set()); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setTasksPanelOpen(false); setHelpPanelOpen(false); setProfilePanelHandle(null); setLeaderboardPanelOpen(false); setTutorialStep(null); setMobileMenuOpen(false); setMobileActionSheet(null); }, []); const handleNodeHover = useCallback((node: GraphNode | null, x: number, y: number) => { // hoveredNode is handled by BeadsGraph internally via refs — no state needed here. // Only set tooltip state (needed for the tooltip DOM element). if (!isMobileRef.current) { setNodeTooltip(node ? { node, x, y } : null); } }, []); const handleAvatarClick = useCallback((info: { handle: string; avatar?: string; did?: string; nodeId: string; isAssignee?: boolean }) => { setProfilePanelHandle(info.handle); // Close other sidebars (mutual exclusivity) setSelectedNode(null); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setTasksPanelOpen(false); setHelpPanelOpen(false); setLeaderboardPanelOpen(false); setTutorialStep(null); setAvatarTooltip(null); }, []); const handleToggleEpicCollapse = useCallback((epicId: string) => { setCollapsedEpicIds((prev) => { const next = new Set(prev); if (next.has(epicId)) next.delete(epicId); else next.add(epicId); return next; }); }, []); // Compute all epic IDs that have children (for collapse-all) const allParentEpicIds = useMemo(() => { if (!data) return new Set(); const { nodes, links } = data.graphData; const parentIds = new Set(); // From parent-child links for (const link of links) { if (link.type === "parent-child") { const src = typeof link.source === "object" ? (link.source as any).id : link.source; parentIds.add(src); } } // From hierarchical IDs const nodeIds = new Set(nodes.map((n) => n.id)); for (const node of nodes) { if (node.id.includes(".")) { const parentId = node.id.split(".")[0]; if (nodeIds.has(parentId)) parentIds.add(parentId); } } return parentIds; }, [data]); const handleCollapseAll = useCallback(() => { setCollapsedEpicIds(new Set(allParentEpicIds)); }, [allParentEpicIds]); const handleExpandAll = useCallback(() => { setCollapsedEpicIds(new Set()); }, []); const handleFocusEpic = useCallback((epicId: string) => { setFocusedEpicId(epicId); }, []); const handleExitFocusedEpic = useCallback(() => { setFocusedEpicId(null); }, []); // --- Tutorial callbacks --- const handleStartTutorial = useCallback(() => { setHelpPanelOpen(true); setSelectedNode(null); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setTasksPanelOpen(false); setLeaderboardPanelOpen(false); setTutorialStep(0); }, []); const handleNextTutorialStep = useCallback(() => { setTutorialStep((prev) => { if (prev === null) return null; if (prev >= TUTORIAL_STEPS.length - 1) return prev; return prev + 1; }); }, []); const handlePrevTutorialStep = useCallback(() => { setTutorialStep((prev) => { if (prev === null || prev <= 0) return prev; return prev - 1; }); }, []); const handleEndTutorial = useCallback(() => { setTutorialStep(null); }, []); const handleBackgroundClick = useCallback(() => { setSelectedNode(null); setSelectedNodeIds(new Set()); // clear multi-selection setContextMenu(null); setCommentTooltipState(null); setMobileActionSheet(null); setProfilePanelHandle(null); setLeaderboardPanelOpen(false); }, []); const handleNodeRightClick = useCallback( (node: GraphNode, event: MouseEvent) => { if (isMobileRef.current) return; // No right-click menu on mobile // Dismiss any open comment tooltip and hover tooltip setCommentTooltipState(null); setNodeTooltip(null); if (!node.description && !isAuthenticated && node.issueType !== "epic") { // No description and not logged in → only action is comment → skip menu setCommentTooltipState({ node, x: event.clientX, y: event.clientY, }); } else { setContextMenu({ node, x: event.clientX, y: event.clientY }); } }, [isAuthenticated] ); const handleNodeDoubleTap = useCallback( (node: GraphNode) => { // On mobile double-tap: show bottom action sheet setCommentTooltipState(null); setNodeTooltip(null); setMobileActionSheet({ node }); }, [] ); const handlePostComment = useCallback( async (nodeId: string, text: string) => { const response = await fetch("/api/records", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ collection: "org.impactindexer.review.comment", record: { $type: "org.impactindexer.review.comment", subject: { uri: `beads:${nodeId}`, type: "record", }, text, createdAt: new Date().toISOString(), }, }), }); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "Failed to post comment"); } // Refetch comments to update the UI await refetchComments(); }, [refetchComments] ); const handleClaimTask = useCallback( async (nodeId: string) => { if (!session?.handle) return; // Resolve avatar: use session avatar, or fetch from Bluesky public API let avatar = session.avatar; if (!avatar && session.did) { try { const res = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(session.did)}` ); if (res.ok) { const profile = await res.json(); avatar = profile.avatar; } } catch { // ignore — fallback letter will show } } // Optimistically show avatar immediately setOptimisticClaims((prev) => { const next = new Map(prev); next.set(nodeId, { avatar, claimedAt: new Date().toISOString(), handle: session.handle, }); return next; }); await handlePostComment(nodeId, `@${session.handle}`); // Delayed refetch — indexer may need a few seconds to pick up the new comment setTimeout(() => refetchComments(), 3000); }, [session?.handle, session?.did, session?.avatar, handlePostComment, refetchComments] ); const handleDeleteComment = useCallback( async (comment: { rkey: string }) => { const response = await fetch( `/api/records?collection=${encodeURIComponent("org.impactindexer.review.comment")}&rkey=${encodeURIComponent(comment.rkey)}`, { method: "DELETE" } ); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "Failed to delete comment"); } await refetchComments(); }, [refetchComments] ); const handleUnclaimTask = useCallback( async (nodeId: string) => { const claim = claimedNodeAvatars.get(nodeId); if (!claim) return; // Optimistically suppress the claim immediately setOptimisticUnclaims((prev) => new Set(prev).add(nodeId)); setOptimisticClaims((prev) => { const next = new Map(prev); next.delete(nodeId); return next; }); // Fallback: clear optimistic unclaim after 10 seconds regardless setTimeout(() => { setOptimisticUnclaims((prev) => { const next = new Set(prev); next.delete(nodeId); return next; }); }, 10000); // Delete the comment if we have the rkey if (claim.rkey) { try { await handleDeleteComment({ rkey: claim.rkey }); // Refetch clears the comment from allComments, so remove from unclaims set setOptimisticUnclaims((prev) => { const next = new Set(prev); next.delete(nodeId); return next; }); } catch (err) { console.error('Failed to unclaim:', err); // Clear optimistic state on error setOptimisticUnclaims((prev) => { const next = new Set(prev); next.delete(nodeId); return next; }); } } else { // Optimistic claim not yet indexed — refetch after a delay, then clear unclaim setTimeout(async () => { try { await refetchComments(); } catch (err) { console.error('Failed to refetch comments:', err); } setOptimisticUnclaims((prev) => { const next = new Set(prev); next.delete(nodeId); return next; }); }, 3000); } }, [claimedNodeAvatars, handleDeleteComment, refetchComments] ); const handleLikeComment = useCallback( async (comment: BeadsComment) => { // Check if already liked by current user const existingLike = comment.likes.find( (l) => l.did === session?.did ); if (existingLike) { // Unlike: DELETE the like record const response = await fetch( `/api/records?collection=${encodeURIComponent("org.impactindexer.review.like")}&rkey=${encodeURIComponent(existingLike.rkey)}`, { method: "DELETE" } ); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "Failed to unlike"); } } else { // Like: POST a new like record const response = await fetch("/api/records", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ collection: "org.impactindexer.review.like", record: { subject: { uri: comment.uri, type: "record" }, createdAt: new Date().toISOString(), }, }), }); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "Failed to like"); } } await refetchComments(); }, [session?.did, refetchComments] ); const handleReplyComment = useCallback( async (parentComment: BeadsComment, text: string) => { const response = await fetch("/api/records", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ collection: "org.impactindexer.review.comment", record: { subject: { uri: `beads:${parentComment.nodeId}`, type: "record", }, text, replyTo: parentComment.uri, createdAt: new Date().toISOString(), }, }), }); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "Failed to post reply"); } await refetchComments(); }, [refetchComments] ); const handleNodeNavigate = useCallback( (nodeId: string) => { if (!data) return; const node = data.graphData.nodes.find((n) => n.id === nodeId); if (node) { setSelectedNode(node); } }, [data] ); // Build a map of nodeId -> commenter handles string for search const commenterHandlesByNode = useMemo(() => { const map = new Map(); if (!allComments) return map; const handlesMap = new Map>(); for (const comment of allComments) { if (!handlesMap.has(comment.nodeId)) { handlesMap.set(comment.nodeId, new Set()); } handlesMap.get(comment.nodeId)!.add(comment.handle); if (comment.displayName) { handlesMap.get(comment.nodeId)!.add(comment.displayName); } } for (const [nodeId, handles] of handlesMap) { map.set(nodeId, Array.from(handles).join(" ")); } return map; }, [allComments]); // Search results - fuzzy match on id, title, people, and commenter handles const searchResults = useMemo((): SearchResult[] => { if (!data || !searchQuery.trim()) return []; const term = searchQuery.toLowerCase(); // Profile results (max 3) const profileResults: SearchResult[] = searchableProfiles .filter(p => p.handle.toLowerCase().includes(term)) .slice(0, 3) .map(p => ({ type: "profile" as const, handle: p.handle, avatar: p.avatar })); // Node results (max 5, or more if fewer profiles matched) const maxNodes = 8 - profileResults.length; const nodeResults: SearchResult[] = data.graphData.nodes .filter((n) => { const commenters = commenterHandlesByNode.get(n.id) || ""; const searchable = `${n.id} ${n.title} ${n.prefix} ${n.owner || ""} ${n.assignee || ""} ${n.createdBy || ""} ${commenters}`.toLowerCase(); return searchable.includes(term); }) .slice(0, maxNodes) .map(n => ({ type: "node" as const, node: n })); return [...profileResults, ...nodeResults]; }, [searchQuery, data, commenterHandlesByNode, searchableProfiles]); // Reset highlight index when query changes useEffect(() => { setSearchHighlightIndex(0); }, [searchQuery]); // Focus node via graph ref, then close search const focusNode = useCallback( (node: GraphNode) => { graphRef.current?.focusNode(node); setSearchOpen(false); setSearchQuery(""); setSearchHighlightIndex(0); }, [] ); // Helper function to find the node ID where a contributor's avatar is displayed const findAvatarNodeId = useCallback( (handle: string): string | null => { // 1. Check claimed nodes first (claims take visual priority) for (const [nodeId, claim] of claimedNodeAvatars) { if (claim.handle === handle) return nodeId; } // 2. Fall back to assignee avatar node for (const [nodeId, info] of assigneeNodeAvatarsResolved) { if (info.handle === handle) return nodeId; } // 3. Last resort: any assigned open node, most recently updated if (data) { const fallback = data.graphData.nodes .filter((n) => n.assignee === handle && n.status !== "closed") .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); if (fallback.length > 0) return fallback[0].id; } return null; }, [claimedNodeAvatars, assigneeNodeAvatarsResolved, data] ); const focusProfile = useCallback( (handle: string) => { // Open profile panel setProfilePanelHandle(handle); // Clear other panels setSelectedNode(null); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setTasksPanelOpen(false); setHelpPanelOpen(false); setLeaderboardPanelOpen(false); // Zoom to avatar node const nodeId = findAvatarNodeId(handle); if (nodeId) graphRef.current?.zoomToNode(nodeId); // Close search setSearchOpen(false); setSearchQuery(""); setSearchHighlightIndex(0); }, [findAvatarNodeId] ); // Keyboard shortcut: Ctrl/Cmd+F to open search, Escape to close, Shift+0 to focus most recent node useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Guard against input fields const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) { return; } if ((e.ctrlKey || e.metaKey) && e.key === "f") { e.preventDefault(); setSearchOpen(true); setTimeout(() => searchInputRef.current?.focus(), 50); } if (e.key === "Escape" && searchOpen) { setSearchOpen(false); setSearchQuery(""); setSearchHighlightIndex(0); } // Shift+0 to focus most recently edited node if (e.shiftKey && e.code === 'Digit0') { e.preventDefault(); const mostRecentNodeId = timelineFilteredActivity.length > 0 ? timelineFilteredActivity[0].nodeId : null; if (mostRecentNodeId && data) { const node = data.graphData.nodes.find(n => n.id === mostRecentNodeId); if (node) { graphRef.current?.focusNode(node); } } } // Shift+1-9 to focus on leaderboard contributor's node const digitMatch = e.code.match(/^Digit([1-9])$/); if (e.shiftKey && digitMatch) { e.preventDefault(); const rank = parseInt(digitMatch[1], 10); const entry = timelineFilteredLeaderboard[rank - 1]; if (!entry || !data) return; const targetNodeId = findAvatarNodeId(entry.handle); if (targetNodeId) { const node = data.graphData.nodes.find((n) => n.id === targetNodeId); if (node) graphRef.current?.focusNode(node); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [searchOpen, timelineFilteredActivity, data, timelineFilteredLeaderboard, findAvatarNodeId]); // Handle search result selection via keyboard const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setSearchHighlightIndex((prev) => Math.min(prev + 1, searchResults.length - 1) ); } else if (e.key === "ArrowUp") { e.preventDefault(); setSearchHighlightIndex((prev) => Math.max(prev - 1, 0)); } else if (e.key === "Enter" && searchResults.length > 0) { e.preventDefault(); const result = searchResults[searchHighlightIndex]; if (result.type === "profile") { focusProfile(result.handle); } else { focusNode(result.node); } } }, [searchResults, searchHighlightIndex, focusNode, focusProfile] ); // Loading state if (loading) { return (
{/* Animated ECG trace with heartbeat logo */}

Warming up the heartbeat...

Discovering issues and mapping dependencies

); } // Error state if (error) { return (

Unable to load data

{error}

); } if (!data) return null; return (
{/* Header */}
{/* Left: Logo */}

{projectName}

Heartbeads v{process.env.NEXT_PUBLIC_APP_VERSION}
{/* Center: Search */}
{searchOpen ? (
setSearchQuery(e.target.value)} onKeyDown={handleSearchKeyDown} placeholder="Search issues or profiles..." className="flex-1 px-2 py-1.5 text-xs text-zinc-800 dark:text-zinc-200 bg-transparent outline-none placeholder:text-zinc-400 dark:placeholder:text-zinc-500" autoFocus />
{/* Search results dropdown */} {searchQuery.trim() && (
{searchResults.length === 0 ? (
No matching issues or profiles
) : ( searchResults.map((result, i) => ( result.type === "profile" ? ( ) : ( ) )) )} {searchResults.length > 0 && (
{searchResults.length} result {searchResults.length !== 1 ? "s" : ""} Enter {" "} to focus
)}
)}
) : ( )}
{/* Mobile hamburger button */} {/* Right: Nav items (desktop) */}
{/* Replay pill */} {/* Comments pill */} {/* Tasks pill */} {/* Learn pill */}
{ setProfilePanelHandle(handle); setSelectedNode(null); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setTasksPanelOpen(false); setHelpPanelOpen(false); setLeaderboardPanelOpen(false); setTutorialStep(null); }} />
{/* Mobile nav drawer — slides in from right */} {mobileMenuOpen && (
{/* Backdrop */}
setMobileMenuOpen(false)} /> {/* Drawer */}
{/* Drawer header */}
Menu
{/* Nav items */}
)} {/* Main content */}
{/* Graph area — full width on mobile, flex-1 on desktop */}
{/* Subtle grid pattern */}
setActiveLegendFilters(prev => { const next = new Set(prev); if (next.has(label)) next.delete(label); else next.add(label); return next; })} onClearLegendFilters={() => setActiveLegendFilters(new Set())} autoFit={autoFit} onAutoFitToggle={() => setAutoFit((v) => !v)} pulseNodeId={timelineFilteredActivity.length > 0 ? timelineFilteredActivity[0].nodeId : null} showPulse={showPulse} onShowPulseToggle={() => setShowPulse((v) => !v)} showParticles={showParticles} onShowParticlesToggle={() => setShowParticles((v) => !v)} isMobile={isMobile} onNodeDoubleTap={handleNodeDoubleTap} theme={theme} /> {/* Timeline bar — replaces legend hint when active */} {timelineActive && timelineRange && timelineRange.events.length > 0 && (
= 0 ? (timelineRange.events[timelineStep]?.time ?? timelineRange.minTime) : timelineRange.minTime} isPlaying={timelinePlaying} speed={timelineSpeed} onStepChange={setTimelineStep} onPlayPause={() => setTimelinePlaying((prev) => !prev)} onSpeedChange={setTimelineSpeed} />
)} {/* Activity overlay + Leaderboard overlay — top-right of canvas */} {!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !tasksPanelOpen && !helpPanelOpen && !profilePanelHandle && !leaderboardPanelOpen && (
setActivityOverlayCollapsed((prev) => !prev)} onExpandPanel={() => { setActivityPanelOpen(true); setSelectedNode(null); setAllCommentsPanelOpen(false); setHelpPanelOpen(false); setLeaderboardPanelOpen(false); }} onNodeClick={(nodeId) => { const node = data?.graphData.nodes.find((n) => n.id === nodeId); if (node) focusNode(node); }} onHandleClick={(handle) => { setProfilePanelHandle(handle); setSelectedNode(null); setAllCommentsPanelOpen(false); setHelpPanelOpen(false); setLeaderboardPanelOpen(false); }} /> setLeaderboardOverlayCollapsed((prev) => !prev)} onExpandPanel={() => { setLeaderboardPanelOpen(true); setSelectedNode(null); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setTasksPanelOpen(false); setHelpPanelOpen(false); setProfilePanelHandle(null); }} onProfileClick={(handle) => { setProfilePanelHandle(handle); setSelectedNode(null); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setTasksPanelOpen(false); setHelpPanelOpen(false); setLeaderboardPanelOpen(false); // Zoom to the avatar node const nodeId = findAvatarNodeId(handle); if (nodeId) graphRef.current?.zoomToNode(nodeId); }} />
)} {/* Right-click context menu */} {contextMenu && ( { setDescriptionModalNode(contextMenu.node); setContextMenu(null); }} onAddComment={() => { setCommentTooltipState({ node: contextMenu.node, x: contextMenu.x, y: contextMenu.y, }); setContextMenu(null); }} onClaimTask={ isAuthenticated && !claimedNodeAvatars.has(contextMenu.node.id) ? () => { handleClaimTask(contextMenu.node.id); setContextMenu(null); } : undefined } onUnclaimTask={(() => { if (!isAuthenticated) return undefined; const claim = claimedNodeAvatars.get(contextMenu.node.id); if (!claim) return undefined; // Show "Unclaim" if claim.did matches current user, or if no did (optimistic = mine) const isMyClaim = claim.did === session?.did || !claim.did; if (!isMyClaim) return undefined; return () => { handleUnclaimTask(contextMenu.node.id); setContextMenu(null); }; })()} onCollapseEpic={ contextMenu.node.issueType === "epic" && !collapsedEpicIds.has(contextMenu.node.id) ? () => { handleToggleEpicCollapse(contextMenu.node.id); setContextMenu(null); } : undefined } onUncollapseEpic={ contextMenu.node.issueType === "epic" && collapsedEpicIds.has(contextMenu.node.id) ? () => { handleToggleEpicCollapse(contextMenu.node.id); setContextMenu(null); } : undefined } onFocusEpic={ contextMenu.node.issueType === "epic" && !focusedEpicId ? () => { handleFocusEpic(contextMenu.node.id); setContextMenu(null); } : undefined } onExitFocusEpic={ contextMenu.node.issueType === "epic" && focusedEpicId === contextMenu.node.id ? () => { handleExitFocusedEpic(); setContextMenu(null); } : undefined } onClose={() => setContextMenu(null)} /> )} {/* Mobile action sheet (double-tap on mobile) */} {mobileActionSheet && ( { setDescriptionModalNode(mobileActionSheet.node); setMobileActionSheet(null); } : undefined } onAddComment={() => { setCommentTooltipState({ node: mobileActionSheet.node, x: window.innerWidth / 2, y: window.innerHeight / 2, }); setMobileActionSheet(null); }} onClaimTask={ isAuthenticated && !claimedNodeAvatars.has(mobileActionSheet.node.id) ? () => { handleClaimTask(mobileActionSheet.node.id); setMobileActionSheet(null); } : undefined } onUnclaimTask={(() => { if (!isAuthenticated) return undefined; const claim = claimedNodeAvatars.get(mobileActionSheet.node.id); if (!claim) return undefined; const isMyClaim = claim.did === session?.did || !claim.did; if (!isMyClaim) return undefined; return () => { handleUnclaimTask(mobileActionSheet.node.id); setMobileActionSheet(null); }; })()} onCollapseEpic={ mobileActionSheet.node.issueType === "epic" && !collapsedEpicIds.has(mobileActionSheet.node.id) ? () => { handleToggleEpicCollapse(mobileActionSheet.node.id); setMobileActionSheet(null); } : undefined } onUncollapseEpic={ mobileActionSheet.node.issueType === "epic" && collapsedEpicIds.has(mobileActionSheet.node.id) ? () => { handleToggleEpicCollapse(mobileActionSheet.node.id); setMobileActionSheet(null); } : undefined } onFocusEpic={ mobileActionSheet.node.issueType === "epic" && !focusedEpicId ? () => { handleFocusEpic(mobileActionSheet.node.id); setMobileActionSheet(null); } : undefined } onExitFocusEpic={ mobileActionSheet.node.issueType === "epic" && focusedEpicId === mobileActionSheet.node.id ? () => { handleExitFocusedEpic(); setMobileActionSheet(null); } : undefined } onClose={() => setMobileActionSheet(null)} /> )} {/* Comment tooltip (opened from context menu "Add comment") */} {commentTooltipState && ( setCommentTooltipState(null)} onSubmit={async (text) => { await handlePostComment(commentTooltipState.node.id, text); setCommentTooltipState(null); }} isAuthenticated={isAuthenticated} existingComments={commentsByNode.get( commentTooltipState.node.id )} /> )} {/* Description modal (opened from context menu "Show description") */} {descriptionModalNode && ( setDescriptionModalNode(null)} repoUrl={repoUrls[descriptionModalNode.prefix]} onOpenSettings={() => setSettingsModalOpen(true)} onNavigate={descriptionModalFamily.length > 1 ? handleDescriptionNavigate : undefined} navigationLabel={descriptionNavLabel} isAuthenticated={isAuthenticated} session={session} comments={allComments?.filter(c => c.nodeId === descriptionModalNode.id)} onPostComment={handlePostComment} onReplyComment={handleReplyComment} onDeleteComment={handleDeleteComment} onLikeComment={handleLikeComment} /> )} {/* Settings modal */} setSettingsModalOpen(false)} /> {/* Node hover tooltip */} {nodeTooltip && !avatarTooltip && !isMobile && ( )} {/* Avatar hover tooltip */} {avatarTooltip && !isMobile && (
{avatarTooltip.avatar ? ( /* eslint-disable-next-line @next/next/no-img-element */ ) : (
{avatarTooltip.handle.charAt(0).toUpperCase()}
)}
{avatarTooltip.handle} {" "} {avatarTooltip.isAssignee ? (avatarTooltip.nodeClosed ? "closed" : "is assigned to") : (avatarTooltip.nodeClosed ? "closed" : "claimed") } {" "} {avatarTooltip.nodeId} {formatRelativeTime(avatarTooltip.claimedAt)}
)}
{/* Desktop sidebar — slides in from right as an overlay when a node is selected */} {/* Mobile bottom drawer — slides up when a node is selected */}
{/* Drag handle + close */}
{/* Drawer content */}
handlePostComment(selectedNode.id, text) : undefined } onDeleteComment={handleDeleteComment} onLikeComment={handleLikeComment} onReplyComment={handleReplyComment} isAuthenticated={isAuthenticated} currentDid={session?.did} repoUrls={repoUrls} onOpenSettings={() => setSettingsModalOpen(true)} onProfileClick={(handle) => { setProfilePanelHandle(handle); setSelectedNode(null); }} onShowDescription={() => { if (selectedNode) setDescriptionModalNode(selectedNode); }} />
{/* All Comments panel */} setAllCommentsPanelOpen(false)} allComments={allComments.filter((c) => localNodeIds.has(c.nodeId) || knownPrefixes.has(c.nodeId))} onNodeNavigate={(nodeId) => { handleNodeNavigate(nodeId); setAllCommentsPanelOpen(false); }} isAuthenticated={isAuthenticated} currentDid={session?.did} onLikeComment={handleLikeComment} onDeleteComment={handleDeleteComment} onReplyComment={handleReplyComment} onPostComment={handlePostComment} prefixes={data?.stats?.prefixes || []} /> {/* Activity panel */} setActivityPanelOpen(false)} onNodeClick={(nodeId) => { const node = data?.graphData.nodes.find((n) => n.id === nodeId); if (node) { focusNode(node); setActivityPanelOpen(false); } }} onHandleClick={(handle) => { setProfilePanelHandle(handle); setActivityPanelOpen(false); setSelectedNode(null); setAllCommentsPanelOpen(false); setTasksPanelOpen(false); setHelpPanelOpen(false); setLeaderboardPanelOpen(false); }} /> {/* Tasks panel */} setTasksPanelOpen(false)} onNodeClick={(nodeId) => { const node = data?.graphData.nodes.find(n => n.id === nodeId); if (node) { setSelectedNode(node); setSelectedNodeIds(new Set()); setTasksPanelOpen(false); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setHelpPanelOpen(false); setProfilePanelHandle(null); setLeaderboardPanelOpen(false); graphRef.current?.focusNode(node); } }} /> {/* Leaderboard panel */} setLeaderboardPanelOpen(false)} onProfileClick={(handle) => { setProfilePanelHandle(handle); setLeaderboardPanelOpen(false); setSelectedNode(null); setAllCommentsPanelOpen(false); setActivityPanelOpen(false); setTasksPanelOpen(false); setHelpPanelOpen(false); // Zoom to the avatar node const nodeId = findAvatarNodeId(handle); if (nodeId) graphRef.current?.zoomToNode(nodeId); }} timeFilter={leaderboardTimeFilter} onTimeFilterChange={setLeaderboardTimeFilter} /> {/* Help panel */} { setHelpPanelOpen(false); setTutorialStep(null); }} tutorialStep={tutorialStep} onStartTutorial={handleStartTutorial} onNextStep={handleNextTutorialStep} onPrevStep={handlePrevTutorialStep} onEndTutorial={handleEndTutorial} /> {/* Profile panel */} setProfilePanelHandle(null)} profile={profilePanelUser} onNodeNavigate={(nodeId) => { const node = data?.graphData.nodes.find(n => n.id === nodeId); if (node) { setProfilePanelHandle(null); handleNodeClick(node); graphRef.current?.focusNode(node); } }} />
{/* Tutorial spotlight overlay */}
); }