beads-map 0.2.4 → 0.3.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 (44) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +3 -3
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/next-minimal-server.js.nft.json +1 -1
  6. package/.next/next-server.js.nft.json +1 -1
  7. package/.next/prerender-manifest.json +1 -1
  8. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_not-found.html +1 -1
  10. package/.next/server/app/_not-found.rsc +1 -1
  11. package/.next/server/app/api/beads/route.js +2 -2
  12. package/.next/server/app/api/beads/stream/route.js +3 -3
  13. package/.next/server/app/api/beads.body +1 -1
  14. package/.next/server/app/api/config/route.js +2 -2
  15. package/.next/server/app/index.html +1 -1
  16. package/.next/server/app/index.rsc +2 -2
  17. package/.next/server/app/page.js +3 -3
  18. package/.next/server/app/page_client-reference-manifest.js +1 -1
  19. package/.next/server/app-paths-manifest.json +5 -5
  20. package/.next/server/functions-config-manifest.json +1 -1
  21. package/.next/server/pages/404.html +1 -1
  22. package/.next/server/pages/500.html +1 -1
  23. package/.next/server/server-reference-manifest.json +1 -1
  24. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  25. package/.next/static/chunks/app/page-68492e6aaf15a6dd.js +1 -0
  26. package/.next/static/css/c854bc2280bc4b27.css +3 -0
  27. package/README.md +12 -4
  28. package/app/api/config/route.ts +2 -0
  29. package/app/globals.css +12 -0
  30. package/app/page.tsx +111 -6
  31. package/components/BeadTooltip.tsx +222 -0
  32. package/components/BeadsGraph.tsx +264 -56
  33. package/components/ContextMenu.tsx +49 -3
  34. package/lib/parse-beads.ts +2 -0
  35. package/lib/types.ts +2 -0
  36. package/package.json +1 -1
  37. package/public/image.png +0 -0
  38. package/.next/server/app/api/config.body +0 -1
  39. package/.next/server/app/api/config.meta +0 -1
  40. package/.next/static/chunks/666-fb778298a77f3754.js +0 -1
  41. package/.next/static/chunks/app/page-13ee27a84e4a0c70.js +0 -1
  42. package/.next/static/css/dbf588b653aa4019.css +0 -3
  43. /package/.next/static/{5zW6ptqKxGc0tcnRau9j2 → ac0cLw5kGBDWoceTBnu21}/_buildManifest.js +0 -0
  44. /package/.next/static/{5zW6ptqKxGc0tcnRau9j2 → ac0cLw5kGBDWoceTBnu21}/_ssgManifest.js +0 -0
package/app/page.tsx CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useState, useCallback, useMemo, useRef } from "react";
4
4
  import type { BeadsApiResponse, GraphNode, GraphLink } from "@/lib/types";
5
+ import { getPrefixColor } from "@/lib/types";
5
6
  import { diffBeadsData, linkKey } from "@/lib/diff-beads";
6
7
  import type { BeadsDiff } from "@/lib/diff-beads";
7
8
  import BeadsGraph from "@/components/BeadsGraph";
@@ -13,6 +14,7 @@ import { BeadsLogo } from "@/components/BeadsLogo";
13
14
  import { CommentTooltip } from "@/components/CommentTooltip";
14
15
  import { ContextMenu } from "@/components/ContextMenu";
15
16
  import { DescriptionModal } from "@/components/DescriptionModal";
17
+ import { BeadTooltip } from "@/components/BeadTooltip";
16
18
  import AllCommentsPanel from "@/components/AllCommentsPanel";
17
19
  import { ActivityOverlay } from "@/components/ActivityOverlay";
18
20
  import { ActivityPanel } from "@/components/ActivityPanel";
@@ -186,6 +188,7 @@ export default function Home() {
186
188
  const [error, setError] = useState<string | null>(null);
187
189
  const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
188
190
  const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
191
+ const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set());
189
192
  const [projectName, setProjectName] = useState("Beads");
190
193
  const [repoCount, setRepoCount] = useState(0);
191
194
  const [repoUrls, setRepoUrls] = useState<Record<string, string>>({});
@@ -283,6 +286,13 @@ export default function Home() {
283
286
  y: number;
284
287
  } | null>(null);
285
288
 
289
+ // Node hover tooltip state
290
+ const [nodeTooltip, setNodeTooltip] = useState<{
291
+ node: GraphNode;
292
+ x: number;
293
+ y: number;
294
+ } | null>(null);
295
+
286
296
  // Search state
287
297
  const [searchOpen, setSearchOpen] = useState(false);
288
298
  const [searchQuery, setSearchQuery] = useState("");
@@ -516,8 +526,49 @@ export default function Home() {
516
526
  setActivityPanelOpen(false);
517
527
  }, []);
518
528
 
519
- const handleNodeHover = useCallback((node: GraphNode | null) => {
529
+ const handleNodeHover = useCallback((node: GraphNode | null, x: number, y: number) => {
520
530
  setHoveredNode(node);
531
+ setNodeTooltip(node ? { node, x, y } : null);
532
+ }, []);
533
+
534
+ const handleToggleEpicCollapse = useCallback((epicId: string) => {
535
+ setCollapsedEpicIds((prev) => {
536
+ const next = new Set(prev);
537
+ if (next.has(epicId)) next.delete(epicId);
538
+ else next.add(epicId);
539
+ return next;
540
+ });
541
+ }, []);
542
+
543
+ // Compute all epic IDs that have children (for collapse-all)
544
+ const allParentEpicIds = useMemo(() => {
545
+ if (!data) return new Set<string>();
546
+ const { nodes, links } = data.graphData;
547
+ const parentIds = new Set<string>();
548
+ // From parent-child links
549
+ for (const link of links) {
550
+ if (link.type === "parent-child") {
551
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
552
+ parentIds.add(src);
553
+ }
554
+ }
555
+ // From hierarchical IDs
556
+ const nodeIds = new Set(nodes.map((n) => n.id));
557
+ for (const node of nodes) {
558
+ if (node.id.includes(".")) {
559
+ const parentId = node.id.split(".")[0];
560
+ if (nodeIds.has(parentId)) parentIds.add(parentId);
561
+ }
562
+ }
563
+ return parentIds;
564
+ }, [data]);
565
+
566
+ const handleCollapseAll = useCallback(() => {
567
+ setCollapsedEpicIds(new Set(allParentEpicIds));
568
+ }, [allParentEpicIds]);
569
+
570
+ const handleExpandAll = useCallback(() => {
571
+ setCollapsedEpicIds(new Set());
521
572
  }, []);
522
573
 
523
574
  const handleBackgroundClick = useCallback(() => {
@@ -528,9 +579,10 @@ export default function Home() {
528
579
 
529
580
  const handleNodeRightClick = useCallback(
530
581
  (node: GraphNode, event: MouseEvent) => {
531
- // Dismiss any open comment tooltip from a previous interaction
582
+ // Dismiss any open comment tooltip and hover tooltip
532
583
  setCommentTooltipState(null);
533
- if (!node.description && !isAuthenticated) {
584
+ setNodeTooltip(null);
585
+ if (!node.description && !isAuthenticated && node.issueType !== "epic") {
534
586
  // No description and not logged in → only action is comment → skip menu
535
587
  setCommentTooltipState({
536
588
  node,
@@ -742,17 +794,38 @@ export default function Home() {
742
794
  [data]
743
795
  );
744
796
 
745
- // Search results - fuzzy match on id and title
797
+ // Build a map of nodeId -> commenter handles string for search
798
+ const commenterHandlesByNode = useMemo(() => {
799
+ const map = new Map<string, string>();
800
+ if (!allComments) return map;
801
+ const handlesMap = new Map<string, Set<string>>();
802
+ for (const comment of allComments) {
803
+ if (!handlesMap.has(comment.nodeId)) {
804
+ handlesMap.set(comment.nodeId, new Set());
805
+ }
806
+ handlesMap.get(comment.nodeId)!.add(comment.handle);
807
+ if (comment.displayName) {
808
+ handlesMap.get(comment.nodeId)!.add(comment.displayName);
809
+ }
810
+ }
811
+ for (const [nodeId, handles] of handlesMap) {
812
+ map.set(nodeId, Array.from(handles).join(" "));
813
+ }
814
+ return map;
815
+ }, [allComments]);
816
+
817
+ // Search results - fuzzy match on id, title, people, and commenter handles
746
818
  const searchResults = useMemo(() => {
747
819
  if (!data || !searchQuery.trim()) return [];
748
820
  const term = searchQuery.toLowerCase();
749
821
  return data.graphData.nodes
750
822
  .filter((n) => {
751
- const searchable = `${n.id} ${n.title} ${n.prefix}`.toLowerCase();
823
+ const commenters = commenterHandlesByNode.get(n.id) || "";
824
+ const searchable = `${n.id} ${n.title} ${n.prefix} ${n.owner || ""} ${n.assignee || ""} ${n.createdBy || ""} ${commenters}`.toLowerCase();
752
825
  return searchable.includes(term);
753
826
  })
754
827
  .slice(0, 8);
755
- }, [searchQuery, data]);
828
+ }, [searchQuery, data, commenterHandlesByNode]);
756
829
 
757
830
  // Reset highlight index when query changes
758
831
  useEffect(() => {
@@ -1172,6 +1245,9 @@ export default function Home() {
1172
1245
  timelineActive={timelineActive}
1173
1246
  stats={data.stats}
1174
1247
  sidebarOpen={!!selectedNode || allCommentsPanelOpen || activityPanelOpen}
1248
+ collapsedEpicIds={collapsedEpicIds}
1249
+ onCollapseAll={handleCollapseAll}
1250
+ onExpandAll={handleExpandAll}
1175
1251
  />
1176
1252
 
1177
1253
  {/* Timeline bar — replaces legend hint when active */}
@@ -1252,6 +1328,24 @@ export default function Home() {
1252
1328
  setContextMenu(null);
1253
1329
  };
1254
1330
  })()}
1331
+ onCollapseEpic={
1332
+ contextMenu.node.issueType === "epic" &&
1333
+ !collapsedEpicIds.has(contextMenu.node.id)
1334
+ ? () => {
1335
+ handleToggleEpicCollapse(contextMenu.node.id);
1336
+ setContextMenu(null);
1337
+ }
1338
+ : undefined
1339
+ }
1340
+ onUncollapseEpic={
1341
+ contextMenu.node.issueType === "epic" &&
1342
+ collapsedEpicIds.has(contextMenu.node.id)
1343
+ ? () => {
1344
+ handleToggleEpicCollapse(contextMenu.node.id);
1345
+ setContextMenu(null);
1346
+ }
1347
+ : undefined
1348
+ }
1255
1349
  onClose={() => setContextMenu(null)}
1256
1350
  />
1257
1351
  )}
@@ -1282,6 +1376,17 @@ export default function Home() {
1282
1376
  />
1283
1377
  )}
1284
1378
 
1379
+ {/* Node hover tooltip */}
1380
+ {nodeTooltip && !avatarTooltip && (
1381
+ <BeadTooltip
1382
+ node={nodeTooltip.node}
1383
+ x={nodeTooltip.x}
1384
+ y={nodeTooltip.y}
1385
+ prefixColor={getPrefixColor(nodeTooltip.node.prefix)}
1386
+ allNodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
1387
+ />
1388
+ )}
1389
+
1285
1390
  {/* Avatar hover tooltip */}
1286
1391
  {avatarTooltip && (
1287
1392
  <div
@@ -0,0 +1,222 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, useEffect } from "react";
4
+ import { GraphNode, PRIORITY_LABELS, PRIORITY_COLORS } from "@/lib/types";
5
+ import { formatRelativeTime } from "@/lib/utils";
6
+
7
+ interface BeadTooltipProps {
8
+ node: GraphNode;
9
+ x: number;
10
+ y: number;
11
+ prefixColor: string;
12
+ allNodes: GraphNode[];
13
+ }
14
+
15
+ const COLORS = {
16
+ bg: "#FFFFFF",
17
+ border: "#E5E7EB",
18
+ borderLight: "#F3F4F6",
19
+ shadow: "rgba(0,0,0,0.08)",
20
+ text: "#131316",
21
+ textMuted: "#737680",
22
+ textDim: "#7F818B",
23
+ };
24
+
25
+ export function BeadTooltip({ node, x, y, prefixColor, allNodes }: BeadTooltipProps) {
26
+ const ref = useRef<HTMLDivElement>(null);
27
+ const [pos, setPos] = useState({ x: 0, y: 0 });
28
+
29
+ useEffect(() => {
30
+ if (!ref.current) return;
31
+ const tt = ref.current.getBoundingClientRect();
32
+ const pad = 14;
33
+
34
+ // Prefer placing above and to the right of cursor
35
+ let nx = x + pad;
36
+ let ny = y - tt.height - pad;
37
+
38
+ // Clamp to viewport edges
39
+ if (nx + tt.width > window.innerWidth - 16) {
40
+ nx = x - tt.width - pad;
41
+ }
42
+ if (nx < 16) nx = 16;
43
+ if (ny < 16) {
44
+ ny = y + pad + 8;
45
+ }
46
+ if (ny + tt.height > window.innerHeight - 16) {
47
+ ny = window.innerHeight - tt.height - 16;
48
+ }
49
+
50
+ setPos({ x: nx, y: ny });
51
+ }, [x, y]);
52
+
53
+ // Resolve blocker IDs to short titles
54
+ const blockers = node.dependentIds
55
+ .map((id) => {
56
+ const blockerNode = allNodes.find((n) => n.id === id);
57
+ return blockerNode ? { id, title: blockerNode.title } : { id, title: id };
58
+ })
59
+ .filter(Boolean);
60
+
61
+ const priorityLabel = PRIORITY_LABELS[node.priority] || `P${node.priority}`;
62
+ const priorityColor = PRIORITY_COLORS[node.priority] || "#a1a1aa";
63
+
64
+ return (
65
+ <div
66
+ ref={ref}
67
+ className="bead-tooltip"
68
+ style={{
69
+ position: "fixed",
70
+ left: pos.x,
71
+ top: pos.y,
72
+ width: 280,
73
+ zIndex: 100,
74
+ pointerEvents: "none",
75
+ background: COLORS.bg,
76
+ border: `1px solid ${COLORS.border}`,
77
+ boxShadow: `0 8px 32px ${COLORS.shadow}, 0 2px 8px ${COLORS.shadow}`,
78
+ borderRadius: 8,
79
+ padding: "16px 18px",
80
+ animation: "beadTooltipFade 0.2s ease",
81
+ }}
82
+ >
83
+ {/* Accent bar */}
84
+ <div
85
+ style={{
86
+ width: 24,
87
+ height: 2,
88
+ background: prefixColor,
89
+ opacity: 0.5,
90
+ marginBottom: 10,
91
+ borderRadius: 1,
92
+ }}
93
+ />
94
+
95
+ {/* Title */}
96
+ <div
97
+ style={{
98
+ fontSize: 14,
99
+ fontWeight: 600,
100
+ color: COLORS.text,
101
+ lineHeight: 1.25,
102
+ marginBottom: 12,
103
+ overflow: "hidden",
104
+ display: "-webkit-box",
105
+ WebkitLineClamp: 2,
106
+ WebkitBoxOrient: "vertical",
107
+ }}
108
+ >
109
+ {node.title}
110
+ </div>
111
+
112
+ {/* Metadata rows */}
113
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
114
+ {/* Created */}
115
+ <div>
116
+ <div style={labelStyle}>Created</div>
117
+ <div style={valueStyle}>{formatRelativeTime(node.createdAt)}</div>
118
+ </div>
119
+
120
+ {/* Owner */}
121
+ {node.owner && (
122
+ <div>
123
+ <div style={labelStyle}>Owner</div>
124
+ <div style={valueStyle}>{node.owner}</div>
125
+ </div>
126
+ )}
127
+
128
+ {/* Assignee */}
129
+ {(node.assignee || node.createdBy) && (
130
+ <div>
131
+ {node.assignee && (
132
+ <>
133
+ <div style={labelStyle}>Assignee</div>
134
+ <div style={valueStyle}>{node.assignee}</div>
135
+ </>
136
+ )}
137
+ {node.createdBy && !node.assignee && (
138
+ <>
139
+ <div style={labelStyle}>Created by</div>
140
+ <div style={valueStyle}>{node.createdBy}</div>
141
+ </>
142
+ )}
143
+ {node.createdBy && node.assignee && node.createdBy !== node.assignee && (
144
+ <>
145
+ <div style={{ ...labelStyle, marginTop: 4 }}>Created by</div>
146
+ <div style={valueStyle}>{node.createdBy}</div>
147
+ </>
148
+ )}
149
+ </div>
150
+ )}
151
+
152
+ {/* Blocked by */}
153
+ <div>
154
+ <div style={labelStyle}>Blocked by</div>
155
+ {blockers.length === 0 ? (
156
+ <div style={{ ...valueStyle, color: "#10b981" }}>None</div>
157
+ ) : (
158
+ <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
159
+ {blockers.map((b) => (
160
+ <div key={b.id} style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
161
+ <span
162
+ style={{
163
+ fontSize: 10,
164
+ fontFamily: "monospace",
165
+ color: COLORS.textDim,
166
+ flexShrink: 0,
167
+ }}
168
+ >
169
+ {b.id}
170
+ </span>
171
+ <span
172
+ style={{
173
+ fontSize: 12,
174
+ color: COLORS.textMuted,
175
+ overflow: "hidden",
176
+ textOverflow: "ellipsis",
177
+ whiteSpace: "nowrap",
178
+ }}
179
+ >
180
+ {b.title}
181
+ </span>
182
+ </div>
183
+ ))}
184
+ </div>
185
+ )}
186
+ </div>
187
+
188
+ {/* Priority */}
189
+ <div style={{ borderTop: `1px solid ${COLORS.borderLight}`, paddingTop: 8 }}>
190
+ <div style={labelStyle}>Priority</div>
191
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
192
+ <div
193
+ style={{
194
+ width: 8,
195
+ height: 8,
196
+ borderRadius: "50%",
197
+ background: priorityColor,
198
+ flexShrink: 0,
199
+ }}
200
+ />
201
+ <span style={valueStyle}>{priorityLabel}</span>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ );
207
+ }
208
+
209
+ const labelStyle: React.CSSProperties = {
210
+ fontSize: 10,
211
+ fontWeight: 600,
212
+ letterSpacing: 1.5,
213
+ textTransform: "uppercase",
214
+ color: "#7F818B",
215
+ marginBottom: 3,
216
+ };
217
+
218
+ const valueStyle: React.CSSProperties = {
219
+ fontSize: 12,
220
+ color: "#737680",
221
+ lineHeight: 1.45,
222
+ };