heartbeads 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
package/app/page.tsx ADDED
@@ -0,0 +1,2041 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback, useMemo, useRef } from "react";
4
+ import type { BeadsApiResponse, GraphNode, GraphLink, ColorMode } from "@/lib/types";
5
+ import { getCatppuccinPrefixColor } from "@/lib/types";
6
+ import { diffBeadsData, linkKey } from "@/lib/diff-beads";
7
+ import type { BeadsDiff } from "@/lib/diff-beads";
8
+ import BeadsGraph from "@/components/BeadsGraph";
9
+ import type { BeadsGraphHandle } from "@/components/BeadsGraph";
10
+ import NodeDetail from "@/components/NodeDetail";
11
+
12
+ import { AuthButton } from "@/components/AuthButton";
13
+ import { BeadsLogo } from "@/components/BeadsLogo";
14
+ import { CommentTooltip } from "@/components/CommentTooltip";
15
+ import { ContextMenu } from "@/components/ContextMenu";
16
+ import { DescriptionModal } from "@/components/DescriptionModal";
17
+ import { BeadTooltip } from "@/components/BeadTooltip";
18
+ import AllCommentsPanel from "@/components/AllCommentsPanel";
19
+ import { ActivityOverlay } from "@/components/ActivityOverlay";
20
+ import { ActivityPanel } from "@/components/ActivityPanel";
21
+ import { HelpPanel } from "@/components/HelpPanel";
22
+ import { SettingsModal } from "@/components/SettingsModal";
23
+ import { TutorialOverlay, TUTORIAL_STEPS } from "@/components/TutorialOverlay";
24
+ import { MobileActionSheet } from "@/components/MobileActionSheet";
25
+ import { useBeadsComments } from "@/hooks/useBeadsComments";
26
+ import type { BeadsComment } from "@/hooks/useBeadsComments";
27
+ import { useIsMobile } from "@/hooks/useIsMobile";
28
+ import { useAuth } from "@/lib/auth";
29
+ import {
30
+ buildHistoricalFeed,
31
+ diffToActivityEvents,
32
+ mergeFeedEvents,
33
+ } from "@/lib/activity";
34
+ import type { ActivityEvent } from "@/lib/activity";
35
+ import { buildTimelineEvents, filterDataAtTime } from "@/lib/timeline";
36
+ import type { TimelineRange } from "@/lib/timeline";
37
+ import TimelineBar from "@/components/TimelineBar";
38
+ import { formatRelativeTime } from "@/lib/utils";
39
+
40
+ // Check if a node has been claimed (has a comment that is just "@handle")
41
+ function isNodeClaimed(comments?: BeadsComment[]): boolean {
42
+ if (!comments) return false;
43
+ return comments.some(
44
+ (c) => c.text.startsWith("@") && c.text.trim().indexOf(" ") === -1
45
+ );
46
+ }
47
+
48
+ // Find position of a neighbor node (for placing new nodes near connections)
49
+ function findNeighborPosition(
50
+ nodeId: string,
51
+ links: GraphLink[],
52
+ nodeMap: Map<string, GraphNode>
53
+ ): { x: number; y: number } | null {
54
+ for (const link of links) {
55
+ const src =
56
+ typeof link.source === "object"
57
+ ? (link.source as { id: string }).id
58
+ : link.source;
59
+ const tgt =
60
+ typeof link.target === "object"
61
+ ? (link.target as { id: string }).id
62
+ : link.target;
63
+ if (src === nodeId && nodeMap.has(tgt)) {
64
+ const n = nodeMap.get(tgt)!;
65
+ if (n.x != null && n.y != null)
66
+ return { x: n.x as number, y: n.y as number };
67
+ }
68
+ if (tgt === nodeId && nodeMap.has(src)) {
69
+ const n = nodeMap.get(src)!;
70
+ if (n.x != null && n.y != null)
71
+ return { x: n.x as number, y: n.y as number };
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // Merge old (with simulation positions) and new (from server) beads data,
78
+ // stamping animation metadata for spawn/exit/change transitions.
79
+ function mergeBeadsData(
80
+ oldData: BeadsApiResponse,
81
+ newData: BeadsApiResponse,
82
+ diff: BeadsDiff
83
+ ): BeadsApiResponse {
84
+ const now = Date.now();
85
+
86
+ // Build position map from old nodes (preserves x/y/fx/fy from simulation)
87
+ const oldNodeMap = new Map(oldData.graphData.nodes.map((n) => [n.id, n]));
88
+ const oldLinkKeySet = new Set(oldData.graphData.links.map(linkKey));
89
+
90
+ // Merge nodes: carry over positions, stamp animation metadata
91
+ const mergedNodes: GraphNode[] = newData.graphData.nodes.map((node) => {
92
+ const oldNode = oldNodeMap.get(node.id);
93
+
94
+ if (!oldNode) {
95
+ // NEW NODE — stamp spawn time, place near a connected neighbor
96
+ const neighbor = findNeighborPosition(
97
+ node.id,
98
+ newData.graphData.links,
99
+ oldNodeMap
100
+ );
101
+ return {
102
+ ...node,
103
+ _spawnTime: now,
104
+ x: neighbor ? neighbor.x + (Math.random() - 0.5) * 40 : undefined,
105
+ y: neighbor ? neighbor.y + (Math.random() - 0.5) * 40 : undefined,
106
+ } as GraphNode;
107
+ }
108
+
109
+ // EXISTING NODE — preserve position, check for changes
110
+ const merged: GraphNode = {
111
+ ...node,
112
+ x: oldNode.x,
113
+ y: oldNode.y,
114
+ fx: oldNode.fx,
115
+ fy: oldNode.fy,
116
+ };
117
+
118
+ // Stamp change metadata if status changed
119
+ if (diff.changedNodes.has(node.id)) {
120
+ const changes = diff.changedNodes.get(node.id)!;
121
+ const statusChange = changes.find((c) => c.field === "status");
122
+ if (statusChange) {
123
+ merged._changedAt = now;
124
+ merged._prevStatus = statusChange.from;
125
+ }
126
+ }
127
+
128
+ return merged;
129
+ });
130
+
131
+ // Handle removed nodes: keep them briefly for exit animation
132
+ for (const removedId of diff.removedNodeIds) {
133
+ const oldNode = oldNodeMap.get(removedId);
134
+ if (oldNode) {
135
+ mergedNodes.push({
136
+ ...oldNode,
137
+ _removeTime: now,
138
+ } as GraphNode);
139
+ }
140
+ }
141
+
142
+ // Merge links: stamp spawn time on new links
143
+ const mergedLinks = newData.graphData.links.map((link) => {
144
+ const key = linkKey(link);
145
+ if (!oldLinkKeySet.has(key)) {
146
+ return { ...link, _spawnTime: now };
147
+ }
148
+ return link;
149
+ });
150
+
151
+ // Handle removed links: keep briefly for exit animation
152
+ for (const removedKey of diff.removedLinkKeys) {
153
+ const oldLink = oldData.graphData.links.find(
154
+ (l) => linkKey(l) === removedKey
155
+ );
156
+ if (oldLink) {
157
+ mergedLinks.push({
158
+ source:
159
+ typeof oldLink.source === "object"
160
+ ? (oldLink.source as { id: string }).id
161
+ : oldLink.source,
162
+ target:
163
+ typeof oldLink.target === "object"
164
+ ? (oldLink.target as { id: string }).id
165
+ : oldLink.target,
166
+ type: oldLink.type,
167
+ _removeTime: now,
168
+ });
169
+ }
170
+ }
171
+
172
+ return {
173
+ ...newData,
174
+ graphData: {
175
+ nodes: mergedNodes as GraphNode[],
176
+ links: mergedLinks as GraphLink[],
177
+ },
178
+ };
179
+ }
180
+
181
+ // Status badge colors for search results
182
+ const STATUS_DOT_COLORS: Record<string, string> = {
183
+ open: "bg-emerald-500",
184
+ in_progress: "bg-amber-500",
185
+ blocked: "bg-red-500",
186
+ deferred: "bg-violet-500",
187
+ closed: "bg-zinc-400",
188
+ };
189
+
190
+ export default function Home() {
191
+ const [data, setData] = useState<BeadsApiResponse | null>(null);
192
+ const [loading, setLoading] = useState(true);
193
+ const [error, setError] = useState<string | null>(null);
194
+ const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
195
+ const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
196
+ const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set());
197
+ const [focusedEpicId, setFocusedEpicId] = useState<string | null>(null);
198
+ const [colorMode, setColorMode] = useState<ColorMode>("status");
199
+ const [projectName, setProjectName] = useState("Beads");
200
+ const [repoCount, setRepoCount] = useState(0);
201
+ const [repoUrls, setRepoUrls] = useState<Record<string, string>>({});
202
+
203
+ // Auth state
204
+ const { isAuthenticated, session } = useAuth();
205
+
206
+ // Comments from ATProto indexer
207
+ const { commentsByNode, commentedNodeIds, allComments, refetch: refetchComments } =
208
+ useBeadsComments();
209
+
210
+ // Optimistic claims — immediately show avatar after user claims, before indexer picks it up
211
+ // rkey is undefined for optimistic (not yet indexed), set once comment is fetched
212
+ const [optimisticClaims, setOptimisticClaims] = useState<
213
+ Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string; rkey?: string }>
214
+ >(new Map());
215
+
216
+ // Optimistic unclaims — suppress nodes where user just unclaimed (until refetch clears them)
217
+ const [optimisticUnclaims, setOptimisticUnclaims] = useState<Set<string>>(new Set());
218
+
219
+ // Compute claimed node avatars from comments + optimistic claims
220
+ // A claim comment has text "@handle" (starts with @, no spaces)
221
+ const claimedNodeAvatars = useMemo(() => {
222
+ const map = new Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string; rkey?: string }>();
223
+ // First: add from comments (has rkey for deletion)
224
+ if (allComments) {
225
+ for (const comment of allComments) {
226
+ if (map.has(comment.nodeId)) continue;
227
+ if (optimisticUnclaims.has(comment.nodeId)) continue; // suppressed by unclaim
228
+ const text = comment.text.trim();
229
+ if (text.startsWith("@") && text.indexOf(" ") === -1) {
230
+ map.set(comment.nodeId, {
231
+ avatar: comment.avatar,
232
+ handle: comment.handle,
233
+ claimedAt: comment.createdAt,
234
+ did: comment.did,
235
+ rkey: comment.rkey,
236
+ });
237
+ }
238
+ }
239
+ }
240
+ // Then: add optimistic claims (only if not already from comments and not unclaimed)
241
+ for (const [nodeId, info] of optimisticClaims) {
242
+ if (!map.has(nodeId) && !optimisticUnclaims.has(nodeId)) {
243
+ map.set(nodeId, info);
244
+ }
245
+ }
246
+ return map;
247
+ }, [allComments, optimisticClaims, optimisticUnclaims]);
248
+
249
+ // All Comments panel state
250
+ const [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false);
251
+
252
+ // Activity feed state
253
+ const [activityFeed, setActivityFeed] = useState<ActivityEvent[]>([]);
254
+ const [activityPanelOpen, setActivityPanelOpen] = useState(false);
255
+ const [activityOverlayCollapsed, setActivityOverlayCollapsed] = useState(false);
256
+ const [helpPanelOpen, setHelpPanelOpen] = useState(false);
257
+ const [tutorialStep, setTutorialStep] = useState<number | null>(null);
258
+
259
+ // Mobile responsiveness
260
+ const isMobile = useIsMobile();
261
+ const isMobileRef = useRef(false);
262
+ useEffect(() => { isMobileRef.current = isMobile; }, [isMobile]);
263
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
264
+ const [mobileActionSheet, setMobileActionSheet] = useState<{ node: GraphNode } | null>(null);
265
+
266
+ // On mobile, collapse the activity overlay by default (show just the pill button)
267
+ useEffect(() => {
268
+ if (isMobile) {
269
+ setActivityOverlayCollapsed(true);
270
+ }
271
+ }, [isMobile]);
272
+
273
+ // Set of node IDs in the local graph — used to filter global comments/activity
274
+ // to only events relevant to beads in this repo
275
+ const localNodeIds = useMemo(() => {
276
+ if (!data) return new Set<string>();
277
+ return new Set(data.graphData.nodes.map((n) => n.id));
278
+ }, [data]);
279
+
280
+ // Rebuild historical feed when data or comments change
281
+ // Filter comments to only those targeting nodes in our graph, since the
282
+ // Hypergoat indexer returns comments globally across all repos using beads
283
+ useEffect(() => {
284
+ if (!data) return;
285
+ const localComments = allComments
286
+ ? allComments.filter((c) => localNodeIds.has(c.nodeId))
287
+ : null;
288
+ const historical = buildHistoricalFeed(
289
+ data.graphData.nodes,
290
+ data.graphData.links,
291
+ localComments
292
+ );
293
+ setActivityFeed((prev) => mergeFeedEvents(prev, historical));
294
+ }, [data, allComments, localNodeIds]);
295
+
296
+ // Context menu state for right-click (phase 1: shows ContextMenu)
297
+ const [contextMenu, setContextMenu] = useState<{
298
+ node: GraphNode;
299
+ x: number;
300
+ y: number;
301
+ } | null>(null);
302
+
303
+ // Comment tooltip state (phase 2a: opened from context menu "Add comment")
304
+ const [commentTooltipState, setCommentTooltipState] = useState<{
305
+ node: GraphNode;
306
+ x: number;
307
+ y: number;
308
+ } | null>(null);
309
+
310
+ // Description modal state (phase 2b: opened from context menu "Show description")
311
+ const [descriptionModalNode, setDescriptionModalNode] =
312
+ useState<GraphNode | null>(null);
313
+
314
+ // Settings modal state
315
+ const [settingsModalOpen, setSettingsModalOpen] = useState(false);
316
+
317
+ // Avatar hover tooltip state
318
+ const [avatarTooltip, setAvatarTooltip] = useState<{
319
+ handle: string;
320
+ avatar?: string;
321
+ claimedAt: string;
322
+ did?: string;
323
+ x: number;
324
+ y: number;
325
+ } | null>(null);
326
+
327
+ // Node hover tooltip state
328
+ const [nodeTooltip, setNodeTooltip] = useState<{
329
+ node: GraphNode;
330
+ x: number;
331
+ y: number;
332
+ } | null>(null);
333
+
334
+ // Search state
335
+ const [searchOpen, setSearchOpen] = useState(false);
336
+ const [searchQuery, setSearchQuery] = useState("");
337
+ const [searchHighlightIndex, setSearchHighlightIndex] = useState(0);
338
+
339
+ // Timeline replay state
340
+ const [timelineActive, setTimelineActive] = useState(false);
341
+ const [timelineStep, setTimelineStep] = useState(0);
342
+ const [timelinePlaying, setTimelinePlaying] = useState(false);
343
+ const [timelineSpeed, setTimelineSpeed] = useState(1);
344
+ const [timelineData, setTimelineData] = useState<BeadsApiResponse | null>(null);
345
+
346
+ // Auto-fit: when true, graph auto-zooms to fit after data updates and layout changes
347
+ const [autoFit, setAutoFit] = useState(true);
348
+ // Pulse: highlight most recently active node with a ripple animation
349
+ const [showPulse, setShowPulse] = useState(true);
350
+
351
+ const graphRef = useRef<BeadsGraphHandle>(null);
352
+ const searchInputRef = useRef<HTMLInputElement>(null);
353
+ const prevDataRef = useRef<BeadsApiResponse | null>(null);
354
+
355
+ // Live-streaming beads data via SSE
356
+ useEffect(() => {
357
+ let eventSource: EventSource | null = null;
358
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
359
+ let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
360
+ let mounted = true;
361
+
362
+ function connect() {
363
+ eventSource = new EventSource("/api/beads/stream");
364
+
365
+ eventSource.onmessage = (event) => {
366
+ if (!mounted) return;
367
+ try {
368
+ const parsed = JSON.parse(event.data);
369
+ if (parsed.error) {
370
+ setError(parsed.error as string);
371
+ setLoading(false);
372
+ return;
373
+ }
374
+
375
+ const newData = parsed as BeadsApiResponse;
376
+ const oldData = prevDataRef.current;
377
+ const diff = diffBeadsData(oldData, newData);
378
+
379
+ if (!oldData) {
380
+ // Initial load — no animations, just set data
381
+ prevDataRef.current = newData;
382
+ setData(newData);
383
+ setLoading(false);
384
+ return;
385
+ }
386
+
387
+ if (!diff.hasChanges) return; // No-op if nothing changed
388
+
389
+ // Append real-time activity events from the diff
390
+ const diffEvents = diffToActivityEvents(diff, newData.graphData.nodes);
391
+ if (diffEvents.length > 0) {
392
+ setActivityFeed((prev) => mergeFeedEvents(prev, diffEvents));
393
+ }
394
+
395
+ // Merge: stamp animation metadata and preserve positions
396
+ const mergedData = mergeBeadsData(oldData, newData, diff);
397
+ prevDataRef.current = mergedData;
398
+ setData(mergedData);
399
+ } catch (err) {
400
+ console.error("Failed to parse SSE message:", err);
401
+ }
402
+ };
403
+
404
+ eventSource.onerror = () => {
405
+ // EventSource auto-reconnects, but handle permanent failure
406
+ if (eventSource?.readyState === EventSource.CLOSED) {
407
+ reconnectTimer = setTimeout(connect, 5000);
408
+ }
409
+ };
410
+
411
+ // If still loading after 5s, fall back to one-shot fetch
412
+ fallbackTimer = setTimeout(() => {
413
+ if (!mounted) return;
414
+ if (!prevDataRef.current) {
415
+ fetch("/api/beads")
416
+ .then((res) => res.json())
417
+ .then((fallbackData) => {
418
+ if (mounted && !prevDataRef.current) {
419
+ prevDataRef.current = fallbackData;
420
+ setData(fallbackData);
421
+ setLoading(false);
422
+ }
423
+ })
424
+ .catch(() => {});
425
+ }
426
+ }, 5000);
427
+ }
428
+
429
+ connect();
430
+
431
+ return () => {
432
+ mounted = false;
433
+ eventSource?.close();
434
+ if (reconnectTimer) clearTimeout(reconnectTimer);
435
+ if (fallbackTimer) clearTimeout(fallbackTimer);
436
+ };
437
+ }, []);
438
+
439
+ // Fetch project config for dynamic name
440
+ useEffect(() => {
441
+ fetch("/api/config")
442
+ .then((res) => res.json())
443
+ .then((config) => {
444
+ if (config.name) setProjectName(config.name);
445
+ if (config.repoCount) setRepoCount(config.repoCount);
446
+ if (config.repoUrls) setRepoUrls(config.repoUrls);
447
+ })
448
+ .catch(() => {
449
+ // Fallback to defaults
450
+ });
451
+ }, []);
452
+
453
+ // Clean up expired exit animations (nodes/links with _removeTime older than 600ms)
454
+ useEffect(() => {
455
+ if (!data) return;
456
+ const timer = setTimeout(() => {
457
+ const now = Date.now();
458
+ const EXPIRE_MS = 600;
459
+ const nodes = data.graphData.nodes.filter(
460
+ (n) => !n._removeTime || now - n._removeTime < EXPIRE_MS
461
+ );
462
+ const links = data.graphData.links.filter(
463
+ (l) => !l._removeTime || now - l._removeTime < EXPIRE_MS
464
+ );
465
+ if (
466
+ nodes.length !== data.graphData.nodes.length ||
467
+ links.length !== data.graphData.links.length
468
+ ) {
469
+ setData((prev) =>
470
+ prev
471
+ ? {
472
+ ...prev,
473
+ graphData: { nodes, links },
474
+ }
475
+ : prev
476
+ );
477
+ }
478
+ }, 700); // slightly after animation duration
479
+ return () => clearTimeout(timer);
480
+ }, [data]);
481
+
482
+ // --- Timeline replay logic ---
483
+
484
+ // Compute timeline event range from full data
485
+ const timelineRange = useMemo<TimelineRange | null>(() => {
486
+ if (!data) return null;
487
+ return buildTimelineEvents(data.graphData.nodes, data.graphData.links);
488
+ }, [data]);
489
+
490
+ // Toggle timeline mode on/off
491
+ const handleTimelineToggle = useCallback(() => {
492
+ setTimelineActive((prev) => {
493
+ const next = !prev;
494
+ if (next) {
495
+ setTimelineStep(-1);
496
+ setTimelinePlaying(false);
497
+ setTimelineData(null);
498
+ } else {
499
+ setTimelinePlaying(false);
500
+ setTimelineData(null);
501
+ }
502
+ return next;
503
+ });
504
+ }, []);
505
+
506
+ // Event-step playback: advance one step every 5s/speed
507
+ useEffect(() => {
508
+ if (!timelinePlaying || !timelineActive || !timelineRange) return;
509
+ const intervalMs = 2000 / timelineSpeed;
510
+ const interval = setInterval(() => {
511
+ setTimelineStep((prev) => {
512
+ const next = prev + 1;
513
+ if (next >= timelineRange.events.length) {
514
+ setTimelinePlaying(false);
515
+ return prev;
516
+ }
517
+ return next;
518
+ });
519
+ }, intervalMs);
520
+ return () => clearInterval(interval);
521
+ }, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);
522
+
523
+ // Compute timelineData via diff/merge pipeline when step changes
524
+ useEffect(() => {
525
+ if (!timelineActive || !data || !timelineRange) return;
526
+
527
+ // Preamble step: empty canvas
528
+ if (timelineStep === -1) {
529
+ setTimelineData((prev) => {
530
+ const empty: BeadsApiResponse = {
531
+ ...data,
532
+ graphData: { nodes: [], links: [] },
533
+ };
534
+ if (!prev) return empty;
535
+ const diff = diffBeadsData(prev, empty);
536
+ if (!diff.hasChanges) return prev;
537
+ return mergeBeadsData(prev, empty, diff);
538
+ });
539
+ return;
540
+ }
541
+
542
+ if (timelineRange.events.length === 0) return;
543
+ const event = timelineRange.events[timelineStep];
544
+ if (!event) return;
545
+
546
+ const filtered = filterDataAtTime(
547
+ data.graphData.nodes,
548
+ data.graphData.links,
549
+ event.time
550
+ );
551
+ const newSnapshot: BeadsApiResponse = {
552
+ ...data,
553
+ graphData: { nodes: filtered.nodes, links: filtered.links },
554
+ };
555
+
556
+ setTimelineData((prev) => {
557
+ if (!prev) return newSnapshot; // first frame — no merge needed
558
+ const diff = diffBeadsData(prev, newSnapshot);
559
+ if (!diff.hasChanges) return prev;
560
+ return mergeBeadsData(prev, newSnapshot, diff);
561
+ });
562
+ }, [timelineActive, data, timelineRange, timelineStep]);
563
+
564
+ // --- End timeline replay logic ---
565
+
566
+ const handleNodeClick = useCallback((node: GraphNode) => {
567
+ setSelectedNode((prev) => (prev?.id === node.id ? null : node));
568
+ setAllCommentsPanelOpen(false);
569
+ setActivityPanelOpen(false);
570
+ setHelpPanelOpen(false);
571
+ setTutorialStep(null);
572
+ setMobileMenuOpen(false);
573
+ setMobileActionSheet(null);
574
+ }, []);
575
+
576
+ const handleNodeHover = useCallback((node: GraphNode | null, x: number, y: number) => {
577
+ setHoveredNode(node);
578
+ if (!isMobileRef.current) {
579
+ setNodeTooltip(node ? { node, x, y } : null);
580
+ }
581
+ }, []);
582
+
583
+ const handleToggleEpicCollapse = useCallback((epicId: string) => {
584
+ setCollapsedEpicIds((prev) => {
585
+ const next = new Set(prev);
586
+ if (next.has(epicId)) next.delete(epicId);
587
+ else next.add(epicId);
588
+ return next;
589
+ });
590
+ }, []);
591
+
592
+ // Compute all epic IDs that have children (for collapse-all)
593
+ const allParentEpicIds = useMemo(() => {
594
+ if (!data) return new Set<string>();
595
+ const { nodes, links } = data.graphData;
596
+ const parentIds = new Set<string>();
597
+ // From parent-child links
598
+ for (const link of links) {
599
+ if (link.type === "parent-child") {
600
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
601
+ parentIds.add(src);
602
+ }
603
+ }
604
+ // From hierarchical IDs
605
+ const nodeIds = new Set(nodes.map((n) => n.id));
606
+ for (const node of nodes) {
607
+ if (node.id.includes(".")) {
608
+ const parentId = node.id.split(".")[0];
609
+ if (nodeIds.has(parentId)) parentIds.add(parentId);
610
+ }
611
+ }
612
+ return parentIds;
613
+ }, [data]);
614
+
615
+ const handleCollapseAll = useCallback(() => {
616
+ setCollapsedEpicIds(new Set(allParentEpicIds));
617
+ }, [allParentEpicIds]);
618
+
619
+ const handleExpandAll = useCallback(() => {
620
+ setCollapsedEpicIds(new Set());
621
+ }, []);
622
+
623
+ const handleFocusEpic = useCallback((epicId: string) => {
624
+ setFocusedEpicId(epicId);
625
+ }, []);
626
+
627
+ const handleExitFocusedEpic = useCallback(() => {
628
+ setFocusedEpicId(null);
629
+ }, []);
630
+
631
+ // --- Tutorial callbacks ---
632
+ const handleStartTutorial = useCallback(() => {
633
+ setHelpPanelOpen(true);
634
+ setSelectedNode(null);
635
+ setAllCommentsPanelOpen(false);
636
+ setActivityPanelOpen(false);
637
+ setTutorialStep(0);
638
+ }, []);
639
+
640
+ const handleNextTutorialStep = useCallback(() => {
641
+ setTutorialStep((prev) => {
642
+ if (prev === null) return null;
643
+ if (prev >= TUTORIAL_STEPS.length - 1) return prev;
644
+ return prev + 1;
645
+ });
646
+ }, []);
647
+
648
+ const handlePrevTutorialStep = useCallback(() => {
649
+ setTutorialStep((prev) => {
650
+ if (prev === null || prev <= 0) return prev;
651
+ return prev - 1;
652
+ });
653
+ }, []);
654
+
655
+ const handleEndTutorial = useCallback(() => {
656
+ setTutorialStep(null);
657
+ }, []);
658
+
659
+ const handleBackgroundClick = useCallback(() => {
660
+ setSelectedNode(null);
661
+ setContextMenu(null);
662
+ setCommentTooltipState(null);
663
+ setMobileActionSheet(null);
664
+ }, []);
665
+
666
+ const handleNodeRightClick = useCallback(
667
+ (node: GraphNode, event: MouseEvent) => {
668
+ if (isMobileRef.current) return; // No right-click menu on mobile
669
+ // Dismiss any open comment tooltip and hover tooltip
670
+ setCommentTooltipState(null);
671
+ setNodeTooltip(null);
672
+ if (!node.description && !isAuthenticated && node.issueType !== "epic") {
673
+ // No description and not logged in → only action is comment → skip menu
674
+ setCommentTooltipState({
675
+ node,
676
+ x: event.clientX,
677
+ y: event.clientY,
678
+ });
679
+ } else {
680
+ setContextMenu({ node, x: event.clientX, y: event.clientY });
681
+ }
682
+ },
683
+ [isAuthenticated]
684
+ );
685
+
686
+ const handleNodeDoubleTap = useCallback(
687
+ (node: GraphNode) => {
688
+ // On mobile double-tap: show bottom action sheet
689
+ setCommentTooltipState(null);
690
+ setNodeTooltip(null);
691
+ setMobileActionSheet({ node });
692
+ },
693
+ []
694
+ );
695
+
696
+ const handlePostComment = useCallback(
697
+ async (nodeId: string, text: string) => {
698
+ const response = await fetch("/api/records", {
699
+ method: "POST",
700
+ headers: { "Content-Type": "application/json" },
701
+ body: JSON.stringify({
702
+ collection: "org.impactindexer.review.comment",
703
+ record: {
704
+ $type: "org.impactindexer.review.comment",
705
+ subject: {
706
+ uri: `beads:${nodeId}`,
707
+ type: "record",
708
+ },
709
+ text,
710
+ createdAt: new Date().toISOString(),
711
+ },
712
+ }),
713
+ });
714
+
715
+ if (!response.ok) {
716
+ const errData = await response.json();
717
+ throw new Error(errData.error || "Failed to post comment");
718
+ }
719
+
720
+ // Refetch comments to update the UI
721
+ await refetchComments();
722
+ },
723
+ [refetchComments]
724
+ );
725
+
726
+ const handleClaimTask = useCallback(
727
+ async (nodeId: string) => {
728
+ if (!session?.handle) return;
729
+ // Resolve avatar: use session avatar, or fetch from Bluesky public API
730
+ let avatar = session.avatar;
731
+ if (!avatar && session.did) {
732
+ try {
733
+ const res = await fetch(
734
+ `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(session.did)}`
735
+ );
736
+ if (res.ok) {
737
+ const profile = await res.json();
738
+ avatar = profile.avatar;
739
+ }
740
+ } catch {
741
+ // ignore — fallback letter will show
742
+ }
743
+ }
744
+ // Optimistically show avatar immediately
745
+ setOptimisticClaims((prev) => {
746
+ const next = new Map(prev);
747
+ next.set(nodeId, {
748
+ avatar,
749
+ claimedAt: new Date().toISOString(),
750
+ handle: session.handle,
751
+ });
752
+ return next;
753
+ });
754
+ await handlePostComment(nodeId, `@${session.handle}`);
755
+ // Delayed refetch — indexer may need a few seconds to pick up the new comment
756
+ setTimeout(() => refetchComments(), 3000);
757
+ },
758
+ [session?.handle, session?.did, session?.avatar, handlePostComment, refetchComments]
759
+ );
760
+
761
+ const handleDeleteComment = useCallback(
762
+ async (comment: { rkey: string }) => {
763
+ const response = await fetch(
764
+ `/api/records?collection=${encodeURIComponent("org.impactindexer.review.comment")}&rkey=${encodeURIComponent(comment.rkey)}`,
765
+ { method: "DELETE" }
766
+ );
767
+ if (!response.ok) {
768
+ const errData = await response.json();
769
+ throw new Error(errData.error || "Failed to delete comment");
770
+ }
771
+ await refetchComments();
772
+ },
773
+ [refetchComments]
774
+ );
775
+
776
+ const handleUnclaimTask = useCallback(
777
+ async (nodeId: string) => {
778
+ const claim = claimedNodeAvatars.get(nodeId);
779
+ if (!claim) return;
780
+
781
+ // Optimistically suppress the claim immediately
782
+ setOptimisticUnclaims((prev) => new Set(prev).add(nodeId));
783
+ setOptimisticClaims((prev) => {
784
+ const next = new Map(prev);
785
+ next.delete(nodeId);
786
+ return next;
787
+ });
788
+
789
+ // Delete the comment if we have the rkey
790
+ if (claim.rkey) {
791
+ await handleDeleteComment({ rkey: claim.rkey });
792
+ // Refetch clears the comment from allComments, so remove from unclaims set
793
+ setOptimisticUnclaims((prev) => {
794
+ const next = new Set(prev);
795
+ next.delete(nodeId);
796
+ return next;
797
+ });
798
+ } else {
799
+ // Optimistic claim not yet indexed — refetch after a delay, then clear unclaim
800
+ setTimeout(async () => {
801
+ await refetchComments();
802
+ setOptimisticUnclaims((prev) => {
803
+ const next = new Set(prev);
804
+ next.delete(nodeId);
805
+ return next;
806
+ });
807
+ }, 3000);
808
+ }
809
+ },
810
+ [claimedNodeAvatars, handleDeleteComment, refetchComments]
811
+ );
812
+
813
+ const handleLikeComment = useCallback(
814
+ async (comment: BeadsComment) => {
815
+ // Check if already liked by current user
816
+ const existingLike = comment.likes.find(
817
+ (l) => l.did === session?.did
818
+ );
819
+
820
+ if (existingLike) {
821
+ // Unlike: DELETE the like record
822
+ const response = await fetch(
823
+ `/api/records?collection=${encodeURIComponent("org.impactindexer.review.like")}&rkey=${encodeURIComponent(existingLike.rkey)}`,
824
+ { method: "DELETE" }
825
+ );
826
+ if (!response.ok) {
827
+ const errData = await response.json();
828
+ throw new Error(errData.error || "Failed to unlike");
829
+ }
830
+ } else {
831
+ // Like: POST a new like record
832
+ const response = await fetch("/api/records", {
833
+ method: "POST",
834
+ headers: { "Content-Type": "application/json" },
835
+ body: JSON.stringify({
836
+ collection: "org.impactindexer.review.like",
837
+ record: {
838
+ subject: { uri: comment.uri, type: "record" },
839
+ createdAt: new Date().toISOString(),
840
+ },
841
+ }),
842
+ });
843
+ if (!response.ok) {
844
+ const errData = await response.json();
845
+ throw new Error(errData.error || "Failed to like");
846
+ }
847
+ }
848
+
849
+ await refetchComments();
850
+ },
851
+ [session?.did, refetchComments]
852
+ );
853
+
854
+ const handleReplyComment = useCallback(
855
+ async (parentComment: BeadsComment, text: string) => {
856
+ const response = await fetch("/api/records", {
857
+ method: "POST",
858
+ headers: { "Content-Type": "application/json" },
859
+ body: JSON.stringify({
860
+ collection: "org.impactindexer.review.comment",
861
+ record: {
862
+ subject: {
863
+ uri: `beads:${parentComment.nodeId}`,
864
+ type: "record",
865
+ },
866
+ text,
867
+ replyTo: parentComment.uri,
868
+ createdAt: new Date().toISOString(),
869
+ },
870
+ }),
871
+ });
872
+
873
+ if (!response.ok) {
874
+ const errData = await response.json();
875
+ throw new Error(errData.error || "Failed to post reply");
876
+ }
877
+
878
+ await refetchComments();
879
+ },
880
+ [refetchComments]
881
+ );
882
+
883
+ const handleNodeNavigate = useCallback(
884
+ (nodeId: string) => {
885
+ if (!data) return;
886
+ const node = data.graphData.nodes.find((n) => n.id === nodeId);
887
+ if (node) {
888
+ setSelectedNode(node);
889
+ }
890
+ },
891
+ [data]
892
+ );
893
+
894
+ // Build a map of nodeId -> commenter handles string for search
895
+ const commenterHandlesByNode = useMemo(() => {
896
+ const map = new Map<string, string>();
897
+ if (!allComments) return map;
898
+ const handlesMap = new Map<string, Set<string>>();
899
+ for (const comment of allComments) {
900
+ if (!handlesMap.has(comment.nodeId)) {
901
+ handlesMap.set(comment.nodeId, new Set());
902
+ }
903
+ handlesMap.get(comment.nodeId)!.add(comment.handle);
904
+ if (comment.displayName) {
905
+ handlesMap.get(comment.nodeId)!.add(comment.displayName);
906
+ }
907
+ }
908
+ for (const [nodeId, handles] of handlesMap) {
909
+ map.set(nodeId, Array.from(handles).join(" "));
910
+ }
911
+ return map;
912
+ }, [allComments]);
913
+
914
+ // Search results - fuzzy match on id, title, people, and commenter handles
915
+ const searchResults = useMemo(() => {
916
+ if (!data || !searchQuery.trim()) return [];
917
+ const term = searchQuery.toLowerCase();
918
+ return data.graphData.nodes
919
+ .filter((n) => {
920
+ const commenters = commenterHandlesByNode.get(n.id) || "";
921
+ const searchable = `${n.id} ${n.title} ${n.prefix} ${n.owner || ""} ${n.assignee || ""} ${n.createdBy || ""} ${commenters}`.toLowerCase();
922
+ return searchable.includes(term);
923
+ })
924
+ .slice(0, 8);
925
+ }, [searchQuery, data, commenterHandlesByNode]);
926
+
927
+ // Reset highlight index when query changes
928
+ useEffect(() => {
929
+ setSearchHighlightIndex(0);
930
+ }, [searchQuery]);
931
+
932
+ // Focus node via graph ref, then close search
933
+ const focusNode = useCallback(
934
+ (node: GraphNode) => {
935
+ graphRef.current?.focusNode(node);
936
+ setSearchOpen(false);
937
+ setSearchQuery("");
938
+ setSearchHighlightIndex(0);
939
+ },
940
+ []
941
+ );
942
+
943
+ // Keyboard shortcut: Ctrl/Cmd+F to open search, Escape to close
944
+ useEffect(() => {
945
+ const handleKeyDown = (e: KeyboardEvent) => {
946
+ if ((e.ctrlKey || e.metaKey) && e.key === "f") {
947
+ e.preventDefault();
948
+ setSearchOpen(true);
949
+ setTimeout(() => searchInputRef.current?.focus(), 50);
950
+ }
951
+ if (e.key === "Escape" && searchOpen) {
952
+ setSearchOpen(false);
953
+ setSearchQuery("");
954
+ setSearchHighlightIndex(0);
955
+ }
956
+ };
957
+ window.addEventListener("keydown", handleKeyDown);
958
+ return () => window.removeEventListener("keydown", handleKeyDown);
959
+ }, [searchOpen]);
960
+
961
+ // Handle search result selection via keyboard
962
+ const handleSearchKeyDown = useCallback(
963
+ (e: React.KeyboardEvent) => {
964
+ if (e.key === "ArrowDown") {
965
+ e.preventDefault();
966
+ setSearchHighlightIndex((prev) =>
967
+ Math.min(prev + 1, searchResults.length - 1)
968
+ );
969
+ } else if (e.key === "ArrowUp") {
970
+ e.preventDefault();
971
+ setSearchHighlightIndex((prev) => Math.max(prev - 1, 0));
972
+ } else if (e.key === "Enter" && searchResults.length > 0) {
973
+ e.preventDefault();
974
+ focusNode(searchResults[searchHighlightIndex]);
975
+ }
976
+ },
977
+ [searchResults, searchHighlightIndex, focusNode]
978
+ );
979
+
980
+ // Loading state
981
+ if (loading) {
982
+ return (
983
+ <div className="h-screen flex flex-col items-center justify-center bg-white text-zinc-800 px-6 select-none">
984
+ {/* Animated ECG trace with heartbeat logo */}
985
+ <div className="relative w-full max-w-sm mb-8">
986
+ <svg
987
+ viewBox="0 0 400 40"
988
+ fill="none"
989
+ className="w-full text-zinc-200"
990
+ aria-hidden="true"
991
+ >
992
+ <line
993
+ x1="0"
994
+ y1="20"
995
+ x2="400"
996
+ y2="20"
997
+ stroke="currentColor"
998
+ strokeWidth="1.5"
999
+ strokeLinecap="round"
1000
+ strokeDasharray="4 6"
1001
+ opacity="0.6"
1002
+ />
1003
+ </svg>
1004
+ <div className="absolute inset-0 flex items-center justify-center">
1005
+ <div className="bg-white px-4">
1006
+ <BeadsLogo className="w-10 h-10 text-emerald-500" />
1007
+ </div>
1008
+ </div>
1009
+ </div>
1010
+
1011
+ <p className="text-sm text-zinc-500 font-light animate-fade-in">
1012
+ Warming up the heartbeat...
1013
+ </p>
1014
+ <p
1015
+ className="text-xs text-zinc-400 mt-1.5 animate-fade-in"
1016
+ style={{ animationDelay: "0.1s" }}
1017
+ >
1018
+ Discovering issues and mapping dependencies
1019
+ </p>
1020
+ </div>
1021
+ );
1022
+ }
1023
+
1024
+ // Error state
1025
+ if (error) {
1026
+ return (
1027
+ <div className="h-screen flex items-center justify-center bg-white">
1028
+ <div className="max-w-sm text-center">
1029
+ <div className="w-14 h-14 mx-auto mb-4 bg-red-50 rounded-full flex items-center justify-center">
1030
+ <svg
1031
+ className="w-7 h-7 text-red-400"
1032
+ fill="none"
1033
+ stroke="currentColor"
1034
+ viewBox="0 0 24 24"
1035
+ >
1036
+ <path
1037
+ strokeLinecap="round"
1038
+ strokeLinejoin="round"
1039
+ strokeWidth={1.5}
1040
+ d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
1041
+ />
1042
+ </svg>
1043
+ </div>
1044
+ <h2 className="text-lg font-semibold text-zinc-900 mb-1">
1045
+ Unable to load data
1046
+ </h2>
1047
+ <p className="text-sm text-zinc-500 mb-4">{error}</p>
1048
+ <button
1049
+ onClick={() => window.location.reload()}
1050
+ className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors"
1051
+ >
1052
+ <svg
1053
+ className="w-4 h-4"
1054
+ fill="none"
1055
+ stroke="currentColor"
1056
+ viewBox="0 0 24 24"
1057
+ >
1058
+ <path
1059
+ strokeLinecap="round"
1060
+ strokeLinejoin="round"
1061
+ strokeWidth={2}
1062
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1063
+ />
1064
+ </svg>
1065
+ Try Again
1066
+ </button>
1067
+ </div>
1068
+ </div>
1069
+ );
1070
+ }
1071
+
1072
+ if (!data) return null;
1073
+
1074
+ return (
1075
+ <div className="h-screen flex flex-col overflow-hidden bg-white">
1076
+ {/* Header */}
1077
+ <header className="sticky top-0 z-50 shrink-0 bg-white/95 backdrop-blur-sm border-b border-zinc-200/80">
1078
+ <div className="px-3 sm:px-6 h-14 flex items-center">
1079
+ {/* Left: Logo */}
1080
+ <div className="flex items-center gap-3 shrink-0 group">
1081
+ <BeadsLogo className="w-8 h-8 text-emerald-500 transition-transform group-hover:scale-105" />
1082
+ <div className="flex items-baseline gap-2">
1083
+ <h1 className="text-[15px] font-semibold text-zinc-900 tracking-tight">
1084
+ {projectName}
1085
+ </h1>
1086
+ <span className="font-normal text-zinc-400 text-[15px] hidden sm:inline">
1087
+ Heartbeads
1088
+ </span>
1089
+ {repoCount > 1 && (
1090
+ <span className="text-[10px] text-zinc-400 bg-zinc-100 rounded-full px-1.5 py-0.5 font-medium hidden sm:inline">
1091
+ {repoCount} repos
1092
+ </span>
1093
+ )}
1094
+ </div>
1095
+ </div>
1096
+
1097
+ {/* Center: Search */}
1098
+ <div className="flex-1 flex justify-center px-2 sm:px-4">
1099
+ <div className="relative w-full max-w-md" data-tutorial="search">
1100
+ {searchOpen ? (
1101
+ <div className="flex flex-col">
1102
+ <div className="flex items-center bg-white rounded-full border border-zinc-200 shadow-sm overflow-hidden">
1103
+ <div className="pl-3 pr-1 text-zinc-400">
1104
+ <svg
1105
+ className="w-3.5 h-3.5"
1106
+ fill="none"
1107
+ stroke="currentColor"
1108
+ viewBox="0 0 24 24"
1109
+ strokeWidth={2}
1110
+ >
1111
+ <path
1112
+ strokeLinecap="round"
1113
+ strokeLinejoin="round"
1114
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
1115
+ />
1116
+ </svg>
1117
+ </div>
1118
+ <input
1119
+ ref={searchInputRef}
1120
+ type="text"
1121
+ value={searchQuery}
1122
+ onChange={(e) => setSearchQuery(e.target.value)}
1123
+ onKeyDown={handleSearchKeyDown}
1124
+ placeholder="Search issues..."
1125
+ className="flex-1 px-2 py-1.5 text-xs text-zinc-800 bg-transparent outline-none placeholder:text-zinc-400"
1126
+ autoFocus
1127
+ />
1128
+ <button
1129
+ onClick={() => {
1130
+ setSearchOpen(false);
1131
+ setSearchQuery("");
1132
+ setSearchHighlightIndex(0);
1133
+ }}
1134
+ className="px-2 py-1.5 text-zinc-400 hover:text-zinc-600"
1135
+ >
1136
+ <svg
1137
+ className="w-3.5 h-3.5"
1138
+ fill="none"
1139
+ stroke="currentColor"
1140
+ viewBox="0 0 24 24"
1141
+ strokeWidth={2}
1142
+ >
1143
+ <path
1144
+ strokeLinecap="round"
1145
+ strokeLinejoin="round"
1146
+ d="M6 18L18 6M6 6l12 12"
1147
+ />
1148
+ </svg>
1149
+ </button>
1150
+ </div>
1151
+
1152
+ {/* Search results dropdown */}
1153
+ {searchQuery.trim() && (
1154
+ <div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-zinc-100 shadow-xl overflow-hidden max-h-72 overflow-y-auto z-50">
1155
+ {searchResults.length === 0 ? (
1156
+ <div className="px-3 py-3 text-xs text-zinc-400 text-center">
1157
+ No matching issues
1158
+ </div>
1159
+ ) : (
1160
+ searchResults.map((node, i) => (
1161
+ <button
1162
+ key={node.id}
1163
+ onClick={() => focusNode(node)}
1164
+ onMouseEnter={() => setSearchHighlightIndex(i)}
1165
+ className={`w-full text-left px-3 py-2 text-xs transition-colors flex items-start gap-2.5 ${
1166
+ i === searchHighlightIndex
1167
+ ? "bg-emerald-50"
1168
+ : "hover:bg-zinc-50"
1169
+ }`}
1170
+ >
1171
+ <div className="shrink-0 mt-1">
1172
+ <div
1173
+ className={`w-2 h-2 rounded-full ${
1174
+ STATUS_DOT_COLORS[node.status] || "bg-zinc-400"
1175
+ }`}
1176
+ />
1177
+ </div>
1178
+ <div className="min-w-0 flex-1">
1179
+ <div className="flex items-center gap-1.5">
1180
+ <span className="font-medium text-zinc-600 shrink-0">
1181
+ {node.id}
1182
+ </span>
1183
+ {node.priority <= 1 && (
1184
+ <span className="text-[10px]">
1185
+ {node.priority === 0
1186
+ ? "\uD83D\uDD25\uD83D\uDD25"
1187
+ : "\uD83D\uDD25"}
1188
+ </span>
1189
+ )}
1190
+ </div>
1191
+ <div className="text-zinc-400 truncate mt-0.5">
1192
+ {node.title}
1193
+ </div>
1194
+ </div>
1195
+ <span className="shrink-0 text-[10px] text-zinc-400 bg-zinc-100 rounded px-1 py-0.5 mt-0.5">
1196
+ {node.prefix}
1197
+ </span>
1198
+ </button>
1199
+ ))
1200
+ )}
1201
+ {searchResults.length > 0 && (
1202
+ <div className="px-3 py-1.5 text-[10px] text-zinc-400 border-t border-zinc-100 bg-zinc-50/50 flex items-center justify-between">
1203
+ <span>
1204
+ {searchResults.length} result
1205
+ {searchResults.length !== 1 ? "s" : ""}
1206
+ </span>
1207
+ <span>
1208
+ <kbd className="px-1 py-0.5 bg-white rounded border border-zinc-200 text-[9px] font-mono">
1209
+ Enter
1210
+ </kbd>{" "}
1211
+ to focus
1212
+ </span>
1213
+ </div>
1214
+ )}
1215
+ </div>
1216
+ )}
1217
+ </div>
1218
+ ) : (
1219
+ <button
1220
+ onClick={() => {
1221
+ setSearchOpen(true);
1222
+ setTimeout(() => searchInputRef.current?.focus(), 50);
1223
+ }}
1224
+ className="w-full flex items-center gap-2 px-3.5 py-1.5 text-sm text-zinc-400 rounded-full bg-zinc-50/80 border border-zinc-200/60 hover:text-zinc-500 hover:border-zinc-300 hover:bg-zinc-100/50 transition-all"
1225
+ title="Search issues (Ctrl+F)"
1226
+ >
1227
+ <svg
1228
+ className="w-3.5 h-3.5 shrink-0"
1229
+ fill="none"
1230
+ stroke="currentColor"
1231
+ viewBox="0 0 24 24"
1232
+ strokeWidth={2}
1233
+ >
1234
+ <path
1235
+ strokeLinecap="round"
1236
+ strokeLinejoin="round"
1237
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
1238
+ />
1239
+ </svg>
1240
+ <span className="flex-1 text-left">Search issues...</span>
1241
+ <kbd className="hidden sm:inline-block px-1 py-0.5 bg-zinc-100 rounded border border-zinc-200 text-[9px] font-mono text-zinc-400">
1242
+ {typeof navigator !== "undefined" &&
1243
+ navigator.platform?.includes("Mac")
1244
+ ? "\u2318"
1245
+ : "Ctrl"}
1246
+ F
1247
+ </kbd>
1248
+ </button>
1249
+ )}
1250
+ </div>
1251
+ </div>
1252
+
1253
+ {/* Mobile hamburger button */}
1254
+ <button
1255
+ onClick={() => setMobileMenuOpen(true)}
1256
+ className="md:hidden p-2 text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50 rounded-full transition-colors shrink-0"
1257
+ aria-label="Open menu"
1258
+ >
1259
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1260
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
1261
+ </svg>
1262
+ </button>
1263
+
1264
+ {/* Right: Nav items (desktop) */}
1265
+ <div className="hidden md:flex items-center gap-1 shrink-0" data-tutorial="nav-pills">
1266
+ {/* Replay pill */}
1267
+ <button
1268
+ onClick={handleTimelineToggle}
1269
+ data-tutorial="nav-replay"
1270
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
1271
+ timelineActive
1272
+ ? "text-emerald-700 bg-emerald-50"
1273
+ : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
1274
+ }`}
1275
+ >
1276
+ <svg
1277
+ className="w-3.5 h-3.5"
1278
+ viewBox="0 0 16 16"
1279
+ fill="none"
1280
+ stroke="currentColor"
1281
+ strokeWidth="1.5"
1282
+ >
1283
+ <circle cx="8" cy="8" r="6" />
1284
+ <polyline points="8,4 8,8 11,10" />
1285
+ </svg>
1286
+ <span className="hidden sm:inline">Replay</span>
1287
+ </button>
1288
+ {/* Comments pill */}
1289
+ <button
1290
+ onClick={() => {
1291
+ setAllCommentsPanelOpen((prev) => !prev);
1292
+ if (!allCommentsPanelOpen) {
1293
+ setSelectedNode(null);
1294
+ setActivityPanelOpen(false);
1295
+ setHelpPanelOpen(false);
1296
+ setTutorialStep(null);
1297
+ }
1298
+ }}
1299
+ data-tutorial="nav-comments"
1300
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
1301
+ allCommentsPanelOpen
1302
+ ? "text-emerald-700 bg-emerald-50"
1303
+ : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
1304
+ }`}
1305
+ >
1306
+ <svg
1307
+ className="w-3.5 h-3.5"
1308
+ fill="none"
1309
+ viewBox="0 0 24 24"
1310
+ strokeWidth={1.5}
1311
+ stroke="currentColor"
1312
+ >
1313
+ <path
1314
+ strokeLinecap="round"
1315
+ strokeLinejoin="round"
1316
+ d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
1317
+ />
1318
+ </svg>
1319
+ <span className="hidden sm:inline">Comments</span>
1320
+ </button>
1321
+ {/* Activity pill */}
1322
+ <button
1323
+ onClick={() => {
1324
+ setActivityPanelOpen((prev) => !prev);
1325
+ if (!activityPanelOpen) {
1326
+ setSelectedNode(null);
1327
+ setAllCommentsPanelOpen(false);
1328
+ setHelpPanelOpen(false);
1329
+ setTutorialStep(null);
1330
+ }
1331
+ }}
1332
+ data-tutorial="nav-activity"
1333
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
1334
+ activityPanelOpen
1335
+ ? "text-emerald-700 bg-emerald-50"
1336
+ : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
1337
+ }`}
1338
+ >
1339
+ <svg
1340
+ className="w-3.5 h-3.5"
1341
+ viewBox="0 0 16 16"
1342
+ fill="none"
1343
+ stroke="currentColor"
1344
+ strokeWidth="1.5"
1345
+ >
1346
+ <circle cx="8" cy="8" r="6" />
1347
+ <polyline points="8,4 8,8 11,10" />
1348
+ </svg>
1349
+ <span className="hidden sm:inline">Activity</span>
1350
+ </button>
1351
+ {/* Learn pill */}
1352
+ <button
1353
+ onClick={() => {
1354
+ setHelpPanelOpen((prev) => !prev);
1355
+ if (!helpPanelOpen) {
1356
+ setSelectedNode(null);
1357
+ setAllCommentsPanelOpen(false);
1358
+ setActivityPanelOpen(false);
1359
+ }
1360
+ }}
1361
+ data-tutorial="nav-learn"
1362
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
1363
+ helpPanelOpen
1364
+ ? "text-emerald-700 bg-emerald-50"
1365
+ : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
1366
+ }`}
1367
+ >
1368
+ <svg
1369
+ className="w-3.5 h-3.5"
1370
+ fill="none"
1371
+ viewBox="0 0 24 24"
1372
+ strokeWidth={1.5}
1373
+ stroke="currentColor"
1374
+ >
1375
+ <path
1376
+ strokeLinecap="round"
1377
+ strokeLinejoin="round"
1378
+ d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"
1379
+ />
1380
+ </svg>
1381
+ <span className="hidden sm:inline">Learn</span>
1382
+ </button>
1383
+ <div className="w-px h-5 bg-zinc-200 mx-2" />
1384
+ <button
1385
+ onClick={() => setSettingsModalOpen(true)}
1386
+ className="p-2 text-zinc-400 hover:text-zinc-600 hover:bg-zinc-50 rounded-full transition-colors"
1387
+ title="Settings"
1388
+ >
1389
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1390
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
1391
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
1392
+ </svg>
1393
+ </button>
1394
+ <AuthButton />
1395
+ </div>
1396
+ </div>
1397
+ </header>
1398
+
1399
+ {/* Mobile nav drawer — slides in from right */}
1400
+ {mobileMenuOpen && (
1401
+ <div className="md:hidden fixed inset-0 z-[60]">
1402
+ {/* Backdrop */}
1403
+ <div
1404
+ className="absolute inset-0 bg-black/30 backdrop-blur-[2px]"
1405
+ onClick={() => setMobileMenuOpen(false)}
1406
+ />
1407
+ {/* Drawer */}
1408
+ <div className="absolute top-0 right-0 h-full w-72 bg-white shadow-2xl flex flex-col animate-slide-in-right">
1409
+ {/* Drawer header */}
1410
+ <div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100">
1411
+ <span className="text-sm font-semibold text-zinc-700">Menu</span>
1412
+ <button
1413
+ onClick={() => setMobileMenuOpen(false)}
1414
+ className="p-1.5 text-zinc-400 hover:text-zinc-600 rounded-full hover:bg-zinc-100 transition-colors"
1415
+ >
1416
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1417
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
1418
+ </svg>
1419
+ </button>
1420
+ </div>
1421
+ {/* Nav items */}
1422
+ <nav className="flex-1 overflow-y-auto py-2">
1423
+ {/* Replay */}
1424
+ <button
1425
+ onClick={() => {
1426
+ handleTimelineToggle();
1427
+ setMobileMenuOpen(false);
1428
+ }}
1429
+ className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
1430
+ timelineActive ? "text-emerald-700 bg-emerald-50" : "text-zinc-600 hover:bg-zinc-50"
1431
+ }`}
1432
+ >
1433
+ <svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
1434
+ <circle cx="8" cy="8" r="6" />
1435
+ <polyline points="8,4 8,8 11,10" />
1436
+ </svg>
1437
+ Replay
1438
+ </button>
1439
+ {/* Comments */}
1440
+ <button
1441
+ onClick={() => {
1442
+ setAllCommentsPanelOpen((prev) => !prev);
1443
+ if (!allCommentsPanelOpen) {
1444
+ setSelectedNode(null);
1445
+ setActivityPanelOpen(false);
1446
+ setHelpPanelOpen(false);
1447
+ setTutorialStep(null);
1448
+ }
1449
+ setMobileMenuOpen(false);
1450
+ }}
1451
+ className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
1452
+ allCommentsPanelOpen ? "text-emerald-700 bg-emerald-50" : "text-zinc-600 hover:bg-zinc-50"
1453
+ }`}
1454
+ >
1455
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1456
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
1457
+ </svg>
1458
+ Comments
1459
+ </button>
1460
+ {/* Activity */}
1461
+ <button
1462
+ onClick={() => {
1463
+ setActivityPanelOpen((prev) => !prev);
1464
+ if (!activityPanelOpen) {
1465
+ setSelectedNode(null);
1466
+ setAllCommentsPanelOpen(false);
1467
+ setHelpPanelOpen(false);
1468
+ setTutorialStep(null);
1469
+ }
1470
+ setMobileMenuOpen(false);
1471
+ }}
1472
+ className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
1473
+ activityPanelOpen ? "text-emerald-700 bg-emerald-50" : "text-zinc-600 hover:bg-zinc-50"
1474
+ }`}
1475
+ >
1476
+ <svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
1477
+ <circle cx="8" cy="8" r="6" />
1478
+ <polyline points="8,4 8,8 11,10" />
1479
+ </svg>
1480
+ Activity
1481
+ </button>
1482
+ {/* Learn */}
1483
+ <button
1484
+ onClick={() => {
1485
+ setHelpPanelOpen((prev) => !prev);
1486
+ if (!helpPanelOpen) {
1487
+ setSelectedNode(null);
1488
+ setAllCommentsPanelOpen(false);
1489
+ setActivityPanelOpen(false);
1490
+ }
1491
+ setMobileMenuOpen(false);
1492
+ }}
1493
+ className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
1494
+ helpPanelOpen ? "text-emerald-700 bg-emerald-50" : "text-zinc-600 hover:bg-zinc-50"
1495
+ }`}
1496
+ >
1497
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1498
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
1499
+ </svg>
1500
+ Learn
1501
+ </button>
1502
+ {/* Divider */}
1503
+ <div className="h-px bg-zinc-100 mx-4 my-2" />
1504
+ {/* Settings */}
1505
+ <button
1506
+ onClick={() => {
1507
+ setSettingsModalOpen(true);
1508
+ setMobileMenuOpen(false);
1509
+ }}
1510
+ className="w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium text-zinc-600 hover:bg-zinc-50 transition-colors"
1511
+ >
1512
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1513
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
1514
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
1515
+ </svg>
1516
+ Settings
1517
+ </button>
1518
+ {/* Auth */}
1519
+ <div className="px-5 py-3.5">
1520
+ <AuthButton />
1521
+ </div>
1522
+ </nav>
1523
+ </div>
1524
+ </div>
1525
+ )}
1526
+
1527
+ {/* Main content */}
1528
+ <div className="flex-1 flex overflow-hidden relative">
1529
+ {/* Graph area — full width on mobile, flex-1 on desktop */}
1530
+ <div className="flex-1 relative bg-zinc-50/50">
1531
+ {/* Subtle grid pattern */}
1532
+ <div
1533
+ className="absolute inset-0 opacity-[0.03]"
1534
+ style={{
1535
+ backgroundImage:
1536
+ "radial-gradient(circle, #000 1px, transparent 1px)",
1537
+ backgroundSize: "24px 24px",
1538
+ }}
1539
+ />
1540
+
1541
+ <BeadsGraph
1542
+ ref={graphRef}
1543
+ nodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
1544
+ links={timelineActive && timelineData ? timelineData.graphData.links : data.graphData.links}
1545
+ selectedNode={selectedNode}
1546
+ hoveredNode={hoveredNode}
1547
+ onNodeClick={handleNodeClick}
1548
+ onNodeHover={handleNodeHover}
1549
+ onBackgroundClick={handleBackgroundClick}
1550
+ onNodeRightClick={handleNodeRightClick}
1551
+ commentedNodeIds={commentedNodeIds}
1552
+ claimedNodeAvatars={claimedNodeAvatars}
1553
+ onAvatarHover={setAvatarTooltip}
1554
+ timelineActive={timelineActive}
1555
+ stats={data.stats}
1556
+ sidebarOpen={!!selectedNode || allCommentsPanelOpen || activityPanelOpen || helpPanelOpen}
1557
+ collapsedEpicIds={collapsedEpicIds}
1558
+ onCollapseAll={handleCollapseAll}
1559
+ onExpandAll={handleExpandAll}
1560
+ focusedEpicId={focusedEpicId}
1561
+ onExitFocusedEpic={handleExitFocusedEpic}
1562
+ colorMode={colorMode}
1563
+ onColorModeChange={setColorMode}
1564
+ autoFit={autoFit}
1565
+ onAutoFitToggle={() => setAutoFit((v) => !v)}
1566
+ pulseNodeId={activityFeed.length > 0 ? activityFeed[0].nodeId : null}
1567
+ showPulse={showPulse}
1568
+ onShowPulseToggle={() => setShowPulse((v) => !v)}
1569
+ isMobile={isMobile}
1570
+ onNodeDoubleTap={handleNodeDoubleTap}
1571
+ />
1572
+
1573
+ {/* Timeline bar — replaces legend hint when active */}
1574
+ {timelineActive && timelineRange && timelineRange.events.length > 0 && (
1575
+ <div
1576
+ className="absolute bottom-4 z-10 transition-[right] duration-300 ease-out"
1577
+ style={{ right: selectedNode || allCommentsPanelOpen || activityPanelOpen ? "calc(360px + 1rem)" : "1rem" }}
1578
+ >
1579
+ <TimelineBar
1580
+ totalSteps={timelineRange.events.length}
1581
+ currentStep={Math.max(timelineStep, 0)}
1582
+ currentTime={timelineStep >= 0 ? (timelineRange.events[timelineStep]?.time ?? timelineRange.minTime) : timelineRange.minTime}
1583
+ isPlaying={timelinePlaying}
1584
+ speed={timelineSpeed}
1585
+ onStepChange={setTimelineStep}
1586
+ onPlayPause={() => setTimelinePlaying((prev) => !prev)}
1587
+ onSpeedChange={setTimelineSpeed}
1588
+ />
1589
+ </div>
1590
+ )}
1591
+
1592
+ {/* Activity overlay — top-right of canvas */}
1593
+ {!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (
1594
+ <div className="absolute top-3 right-3 sm:top-4 sm:right-4 z-10">
1595
+ <ActivityOverlay
1596
+ events={activityFeed}
1597
+ collapsed={activityOverlayCollapsed}
1598
+ compact={isMobile}
1599
+ onToggleCollapse={() => setActivityOverlayCollapsed((prev) => !prev)}
1600
+ onExpandPanel={() => {
1601
+ setActivityPanelOpen(true);
1602
+ setSelectedNode(null);
1603
+ setAllCommentsPanelOpen(false);
1604
+ setHelpPanelOpen(false);
1605
+ }}
1606
+ onNodeClick={(nodeId) => {
1607
+ const node = data?.graphData.nodes.find((n) => n.id === nodeId);
1608
+ if (node) focusNode(node);
1609
+ }}
1610
+ />
1611
+ </div>
1612
+ )}
1613
+
1614
+ {/* Right-click context menu */}
1615
+ {contextMenu && (
1616
+ <ContextMenu
1617
+ node={contextMenu.node}
1618
+ x={contextMenu.x}
1619
+ y={contextMenu.y}
1620
+ onShowDescription={() => {
1621
+ setDescriptionModalNode(contextMenu.node);
1622
+ setContextMenu(null);
1623
+ }}
1624
+ onAddComment={() => {
1625
+ setCommentTooltipState({
1626
+ node: contextMenu.node,
1627
+ x: contextMenu.x,
1628
+ y: contextMenu.y,
1629
+ });
1630
+ setContextMenu(null);
1631
+ }}
1632
+ onClaimTask={
1633
+ isAuthenticated &&
1634
+ !claimedNodeAvatars.has(contextMenu.node.id)
1635
+ ? () => {
1636
+ handleClaimTask(contextMenu.node.id);
1637
+ setContextMenu(null);
1638
+ }
1639
+ : undefined
1640
+ }
1641
+ onUnclaimTask={(() => {
1642
+ if (!isAuthenticated) return undefined;
1643
+ const claim = claimedNodeAvatars.get(contextMenu.node.id);
1644
+ if (!claim) return undefined;
1645
+ // Show "Unclaim" if claim.did matches current user, or if no did (optimistic = mine)
1646
+ const isMyClaim = claim.did === session?.did || !claim.did;
1647
+ if (!isMyClaim) return undefined;
1648
+ return () => {
1649
+ handleUnclaimTask(contextMenu.node.id);
1650
+ setContextMenu(null);
1651
+ };
1652
+ })()}
1653
+ onCollapseEpic={
1654
+ contextMenu.node.issueType === "epic" &&
1655
+ !collapsedEpicIds.has(contextMenu.node.id)
1656
+ ? () => {
1657
+ handleToggleEpicCollapse(contextMenu.node.id);
1658
+ setContextMenu(null);
1659
+ }
1660
+ : undefined
1661
+ }
1662
+ onUncollapseEpic={
1663
+ contextMenu.node.issueType === "epic" &&
1664
+ collapsedEpicIds.has(contextMenu.node.id)
1665
+ ? () => {
1666
+ handleToggleEpicCollapse(contextMenu.node.id);
1667
+ setContextMenu(null);
1668
+ }
1669
+ : undefined
1670
+ }
1671
+ onFocusEpic={
1672
+ contextMenu.node.issueType === "epic" && !focusedEpicId
1673
+ ? () => {
1674
+ handleFocusEpic(contextMenu.node.id);
1675
+ setContextMenu(null);
1676
+ }
1677
+ : undefined
1678
+ }
1679
+ onExitFocusEpic={
1680
+ contextMenu.node.issueType === "epic" &&
1681
+ focusedEpicId === contextMenu.node.id
1682
+ ? () => {
1683
+ handleExitFocusedEpic();
1684
+ setContextMenu(null);
1685
+ }
1686
+ : undefined
1687
+ }
1688
+ onClose={() => setContextMenu(null)}
1689
+ />
1690
+ )}
1691
+
1692
+ {/* Mobile action sheet (double-tap on mobile) */}
1693
+ {mobileActionSheet && (
1694
+ <MobileActionSheet
1695
+ node={mobileActionSheet.node}
1696
+ onShowDescription={
1697
+ mobileActionSheet.node.description
1698
+ ? () => {
1699
+ setDescriptionModalNode(mobileActionSheet.node);
1700
+ setMobileActionSheet(null);
1701
+ }
1702
+ : undefined
1703
+ }
1704
+ onAddComment={() => {
1705
+ setCommentTooltipState({
1706
+ node: mobileActionSheet.node,
1707
+ x: window.innerWidth / 2,
1708
+ y: window.innerHeight / 2,
1709
+ });
1710
+ setMobileActionSheet(null);
1711
+ }}
1712
+ onClaimTask={
1713
+ isAuthenticated &&
1714
+ !claimedNodeAvatars.has(mobileActionSheet.node.id)
1715
+ ? () => {
1716
+ handleClaimTask(mobileActionSheet.node.id);
1717
+ setMobileActionSheet(null);
1718
+ }
1719
+ : undefined
1720
+ }
1721
+ onUnclaimTask={(() => {
1722
+ if (!isAuthenticated) return undefined;
1723
+ const claim = claimedNodeAvatars.get(mobileActionSheet.node.id);
1724
+ if (!claim) return undefined;
1725
+ const isMyClaim = claim.did === session?.did || !claim.did;
1726
+ if (!isMyClaim) return undefined;
1727
+ return () => {
1728
+ handleUnclaimTask(mobileActionSheet.node.id);
1729
+ setMobileActionSheet(null);
1730
+ };
1731
+ })()}
1732
+ onCollapseEpic={
1733
+ mobileActionSheet.node.issueType === "epic" &&
1734
+ !collapsedEpicIds.has(mobileActionSheet.node.id)
1735
+ ? () => {
1736
+ handleToggleEpicCollapse(mobileActionSheet.node.id);
1737
+ setMobileActionSheet(null);
1738
+ }
1739
+ : undefined
1740
+ }
1741
+ onUncollapseEpic={
1742
+ mobileActionSheet.node.issueType === "epic" &&
1743
+ collapsedEpicIds.has(mobileActionSheet.node.id)
1744
+ ? () => {
1745
+ handleToggleEpicCollapse(mobileActionSheet.node.id);
1746
+ setMobileActionSheet(null);
1747
+ }
1748
+ : undefined
1749
+ }
1750
+ onFocusEpic={
1751
+ mobileActionSheet.node.issueType === "epic" && !focusedEpicId
1752
+ ? () => {
1753
+ handleFocusEpic(mobileActionSheet.node.id);
1754
+ setMobileActionSheet(null);
1755
+ }
1756
+ : undefined
1757
+ }
1758
+ onExitFocusEpic={
1759
+ mobileActionSheet.node.issueType === "epic" &&
1760
+ focusedEpicId === mobileActionSheet.node.id
1761
+ ? () => {
1762
+ handleExitFocusedEpic();
1763
+ setMobileActionSheet(null);
1764
+ }
1765
+ : undefined
1766
+ }
1767
+ onClose={() => setMobileActionSheet(null)}
1768
+ />
1769
+ )}
1770
+
1771
+ {/* Comment tooltip (opened from context menu "Add comment") */}
1772
+ {commentTooltipState && (
1773
+ <CommentTooltip
1774
+ node={commentTooltipState.node}
1775
+ x={commentTooltipState.x}
1776
+ y={commentTooltipState.y}
1777
+ onClose={() => setCommentTooltipState(null)}
1778
+ onSubmit={async (text) => {
1779
+ await handlePostComment(commentTooltipState.node.id, text);
1780
+ setCommentTooltipState(null);
1781
+ }}
1782
+ isAuthenticated={isAuthenticated}
1783
+ existingComments={commentsByNode.get(
1784
+ commentTooltipState.node.id
1785
+ )}
1786
+ />
1787
+ )}
1788
+
1789
+ {/* Description modal (opened from context menu "Show description") */}
1790
+ {descriptionModalNode && (
1791
+ <DescriptionModal
1792
+ node={descriptionModalNode}
1793
+ onClose={() => setDescriptionModalNode(null)}
1794
+ repoUrl={repoUrls[descriptionModalNode.prefix]}
1795
+ onOpenSettings={() => setSettingsModalOpen(true)}
1796
+ />
1797
+ )}
1798
+
1799
+ {/* Settings modal */}
1800
+ <SettingsModal
1801
+ isOpen={settingsModalOpen}
1802
+ onClose={() => setSettingsModalOpen(false)}
1803
+ />
1804
+
1805
+ {/* Node hover tooltip */}
1806
+ {nodeTooltip && !avatarTooltip && !isMobile && (
1807
+ <BeadTooltip
1808
+ node={nodeTooltip.node}
1809
+ x={nodeTooltip.x}
1810
+ y={nodeTooltip.y}
1811
+ prefixColor={getCatppuccinPrefixColor(nodeTooltip.node.prefix)}
1812
+ allNodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
1813
+ />
1814
+ )}
1815
+
1816
+ {/* Avatar hover tooltip */}
1817
+ {avatarTooltip && !isMobile && (
1818
+ <div
1819
+ style={{
1820
+ position: "fixed",
1821
+ left: avatarTooltip.x + 12,
1822
+ top: avatarTooltip.y - 8,
1823
+ zIndex: 90,
1824
+ pointerEvents: "none",
1825
+ }}
1826
+ >
1827
+ <div className="flex items-center gap-2 bg-white border border-zinc-200 rounded-lg shadow-lg px-2.5 py-2">
1828
+ {avatarTooltip.avatar ? (
1829
+ /* eslint-disable-next-line @next/next/no-img-element */
1830
+ <img
1831
+ src={avatarTooltip.avatar}
1832
+ alt=""
1833
+ className="w-6 h-6 rounded-full shrink-0"
1834
+ />
1835
+ ) : (
1836
+ <div className="w-6 h-6 rounded-full bg-zinc-200 flex items-center justify-center text-[10px] font-medium text-zinc-500 shrink-0">
1837
+ {avatarTooltip.handle.charAt(0).toUpperCase()}
1838
+ </div>
1839
+ )}
1840
+ <div className="flex flex-col">
1841
+ <span className="text-xs text-zinc-700 whitespace-nowrap">
1842
+ {avatarTooltip.did ? (
1843
+ <a
1844
+ href={`https://www.impactindexer.org/data?did=${avatarTooltip.did}`}
1845
+ target="_blank"
1846
+ rel="noopener noreferrer"
1847
+ className="font-semibold text-zinc-800 hover:text-emerald-600 transition-colors"
1848
+ style={{ pointerEvents: "auto" }}
1849
+ >
1850
+ {avatarTooltip.handle}
1851
+ </a>
1852
+ ) : (
1853
+ <span className="font-semibold text-zinc-800">{avatarTooltip.handle}</span>
1854
+ )} claimed this task
1855
+ </span>
1856
+ <span className="text-[10px] text-zinc-400">
1857
+ {formatRelativeTime(avatarTooltip.claimedAt)}
1858
+ </span>
1859
+ </div>
1860
+ </div>
1861
+ </div>
1862
+ )}
1863
+
1864
+ </div>
1865
+
1866
+ {/* Desktop sidebar — slides in from right as an overlay when a node is selected */}
1867
+ <aside
1868
+ className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30 transform transition-transform duration-300 ease-out ${
1869
+ selectedNode ? "translate-x-0" : "translate-x-full"
1870
+ }`}
1871
+ >
1872
+ {/* Sidebar header with close button */}
1873
+ <div className="shrink-0 px-5 py-3 border-b border-zinc-100 flex items-center justify-between">
1874
+ <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
1875
+ Node Detail
1876
+ </h2>
1877
+ <button
1878
+ onClick={() => {
1879
+ setSelectedNode(null);
1880
+ }}
1881
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors rounded-full hover:bg-zinc-100"
1882
+ >
1883
+ <svg
1884
+ className="w-4 h-4"
1885
+ fill="none"
1886
+ stroke="currentColor"
1887
+ viewBox="0 0 24 24"
1888
+ strokeWidth={1.5}
1889
+ >
1890
+ <path
1891
+ strokeLinecap="round"
1892
+ strokeLinejoin="round"
1893
+ d="M6 18L18 6M6 6l12 12"
1894
+ />
1895
+ </svg>
1896
+ </button>
1897
+ </div>
1898
+
1899
+ {/* Sidebar content */}
1900
+ <div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
1901
+ {/* Selected node detail */}
1902
+ <NodeDetail
1903
+ node={selectedNode}
1904
+ allNodes={data.graphData.nodes}
1905
+ onNodeNavigate={handleNodeNavigate}
1906
+ comments={
1907
+ selectedNode
1908
+ ? commentsByNode.get(selectedNode.id)
1909
+ : undefined
1910
+ }
1911
+ onPostComment={
1912
+ selectedNode
1913
+ ? (text: string) =>
1914
+ handlePostComment(selectedNode.id, text)
1915
+ : undefined
1916
+ }
1917
+ onDeleteComment={handleDeleteComment}
1918
+ onLikeComment={handleLikeComment}
1919
+ onReplyComment={handleReplyComment}
1920
+ isAuthenticated={isAuthenticated}
1921
+ currentDid={session?.did}
1922
+ repoUrls={repoUrls}
1923
+ onOpenSettings={() => setSettingsModalOpen(true)}
1924
+ />
1925
+
1926
+ </div>
1927
+ </aside>
1928
+
1929
+ {/* Mobile bottom drawer — slides up when a node is selected */}
1930
+ <div
1931
+ className={`md:hidden fixed inset-x-0 bottom-0 z-20 transform transition-transform duration-300 ease-out ${
1932
+ selectedNode ? "translate-y-0" : "translate-y-full"
1933
+ }`}
1934
+ >
1935
+ <div className="bg-white rounded-t-2xl border-t border-zinc-200 shadow-lg max-h-[60vh] flex flex-col">
1936
+ {/* Drag handle + close */}
1937
+ <div className="shrink-0 flex items-center justify-between px-4 pt-3 pb-2">
1938
+ <div className="w-8 h-1 bg-zinc-300 rounded-full mx-auto" />
1939
+ <button
1940
+ onClick={() => setSelectedNode(null)}
1941
+ className="absolute right-3 top-3 p-1 text-zinc-400 hover:text-zinc-600"
1942
+ >
1943
+ <svg
1944
+ className="w-5 h-5"
1945
+ fill="none"
1946
+ stroke="currentColor"
1947
+ viewBox="0 0 24 24"
1948
+ strokeWidth={2}
1949
+ >
1950
+ <path
1951
+ strokeLinecap="round"
1952
+ strokeLinejoin="round"
1953
+ d="M6 18L18 6M6 6l12 12"
1954
+ />
1955
+ </svg>
1956
+ </button>
1957
+ </div>
1958
+
1959
+ {/* Drawer content */}
1960
+ <div className="flex-1 overflow-y-auto custom-scrollbar px-4 pb-6 space-y-4">
1961
+ <NodeDetail
1962
+ node={selectedNode}
1963
+ allNodes={data.graphData.nodes}
1964
+ onNodeNavigate={handleNodeNavigate}
1965
+ comments={
1966
+ selectedNode
1967
+ ? commentsByNode.get(selectedNode.id)
1968
+ : undefined
1969
+ }
1970
+ onPostComment={
1971
+ selectedNode
1972
+ ? (text: string) =>
1973
+ handlePostComment(selectedNode.id, text)
1974
+ : undefined
1975
+ }
1976
+ onDeleteComment={handleDeleteComment}
1977
+ onLikeComment={handleLikeComment}
1978
+ onReplyComment={handleReplyComment}
1979
+ isAuthenticated={isAuthenticated}
1980
+ currentDid={session?.did}
1981
+ repoUrls={repoUrls}
1982
+ onOpenSettings={() => setSettingsModalOpen(true)}
1983
+ />
1984
+ </div>
1985
+ </div>
1986
+ </div>
1987
+
1988
+ {/* All Comments panel */}
1989
+ <AllCommentsPanel
1990
+ isOpen={allCommentsPanelOpen}
1991
+ onClose={() => setAllCommentsPanelOpen(false)}
1992
+ allComments={allComments.filter((c) => localNodeIds.has(c.nodeId))}
1993
+ onNodeNavigate={(nodeId) => {
1994
+ handleNodeNavigate(nodeId);
1995
+ setAllCommentsPanelOpen(false);
1996
+ }}
1997
+ isAuthenticated={isAuthenticated}
1998
+ currentDid={session?.did}
1999
+ onLikeComment={handleLikeComment}
2000
+ onDeleteComment={handleDeleteComment}
2001
+ />
2002
+
2003
+ {/* Activity panel */}
2004
+ <ActivityPanel
2005
+ events={activityFeed}
2006
+ isOpen={activityPanelOpen}
2007
+ onClose={() => setActivityPanelOpen(false)}
2008
+ onNodeClick={(nodeId) => {
2009
+ const node = data?.graphData.nodes.find((n) => n.id === nodeId);
2010
+ if (node) {
2011
+ focusNode(node);
2012
+ setActivityPanelOpen(false);
2013
+ }
2014
+ }}
2015
+ />
2016
+
2017
+ {/* Help panel */}
2018
+ <HelpPanel
2019
+ isOpen={helpPanelOpen}
2020
+ onClose={() => {
2021
+ setHelpPanelOpen(false);
2022
+ setTutorialStep(null);
2023
+ }}
2024
+ tutorialStep={tutorialStep}
2025
+ onStartTutorial={handleStartTutorial}
2026
+ onNextStep={handleNextTutorialStep}
2027
+ onPrevStep={handlePrevTutorialStep}
2028
+ onEndTutorial={handleEndTutorial}
2029
+ />
2030
+ </div>
2031
+
2032
+ {/* Tutorial spotlight overlay */}
2033
+ <TutorialOverlay
2034
+ step={tutorialStep}
2035
+ onNext={handleNextTutorialStep}
2036
+ onPrev={handlePrevTutorialStep}
2037
+ onEnd={handleEndTutorial}
2038
+ />
2039
+ </div>
2040
+ );
2041
+ }