beads-map 0.2.5 → 0.3.1

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 (40) 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/index.html +1 -1
  15. package/.next/server/app/index.rsc +2 -2
  16. package/.next/server/app/page.js +3 -3
  17. package/.next/server/app/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app-paths-manifest.json +4 -4
  19. package/.next/server/functions-config-manifest.json +1 -1
  20. package/.next/server/pages/404.html +1 -1
  21. package/.next/server/pages/500.html +1 -1
  22. package/.next/server/server-reference-manifest.json +1 -1
  23. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  24. package/.next/static/chunks/app/page-a0493d6741516b53.js +1 -0
  25. package/.next/static/css/4fded26534cb91e3.css +3 -0
  26. package/README.md +12 -4
  27. package/app/globals.css +12 -0
  28. package/app/page.tsx +115 -7
  29. package/components/BeadTooltip.tsx +222 -0
  30. package/components/BeadsGraph.tsx +421 -79
  31. package/components/ContextMenu.tsx +49 -3
  32. package/lib/parse-beads.ts +2 -0
  33. package/lib/types.ts +68 -0
  34. package/package.json +1 -1
  35. package/public/image.png +0 -0
  36. package/.next/static/chunks/666-fb778298a77f3754.js +0 -1
  37. package/.next/static/chunks/app/page-13ee27a84e4a0c70.js +0 -1
  38. package/.next/static/css/dbf588b653aa4019.css +0 -3
  39. /package/.next/static/{hVVu0gp79UGF1SPHv0aXk → _OvcD8YYgVPHv6Tomg-pB}/_buildManifest.js +0 -0
  40. /package/.next/static/{hVVu0gp79UGF1SPHv0aXk → _OvcD8YYgVPHv6Tomg-pB}/_ssgManifest.js +0 -0
package/app/page.tsx CHANGED
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState, useCallback, useMemo, useRef } from "react";
4
- import type { BeadsApiResponse, GraphNode, GraphLink } from "@/lib/types";
4
+ import type { BeadsApiResponse, GraphNode, GraphLink, ColorMode } from "@/lib/types";
5
+ import { getCatppuccinPrefixColor } 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,8 @@ 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());
192
+ const [colorMode, setColorMode] = useState<ColorMode>("status");
189
193
  const [projectName, setProjectName] = useState("Beads");
190
194
  const [repoCount, setRepoCount] = useState(0);
191
195
  const [repoUrls, setRepoUrls] = useState<Record<string, string>>({});
@@ -283,6 +287,13 @@ export default function Home() {
283
287
  y: number;
284
288
  } | null>(null);
285
289
 
290
+ // Node hover tooltip state
291
+ const [nodeTooltip, setNodeTooltip] = useState<{
292
+ node: GraphNode;
293
+ x: number;
294
+ y: number;
295
+ } | null>(null);
296
+
286
297
  // Search state
287
298
  const [searchOpen, setSearchOpen] = useState(false);
288
299
  const [searchQuery, setSearchQuery] = useState("");
@@ -516,8 +527,49 @@ export default function Home() {
516
527
  setActivityPanelOpen(false);
517
528
  }, []);
518
529
 
519
- const handleNodeHover = useCallback((node: GraphNode | null) => {
530
+ const handleNodeHover = useCallback((node: GraphNode | null, x: number, y: number) => {
520
531
  setHoveredNode(node);
532
+ setNodeTooltip(node ? { node, x, y } : null);
533
+ }, []);
534
+
535
+ const handleToggleEpicCollapse = useCallback((epicId: string) => {
536
+ setCollapsedEpicIds((prev) => {
537
+ const next = new Set(prev);
538
+ if (next.has(epicId)) next.delete(epicId);
539
+ else next.add(epicId);
540
+ return next;
541
+ });
542
+ }, []);
543
+
544
+ // Compute all epic IDs that have children (for collapse-all)
545
+ const allParentEpicIds = useMemo(() => {
546
+ if (!data) return new Set<string>();
547
+ const { nodes, links } = data.graphData;
548
+ const parentIds = new Set<string>();
549
+ // From parent-child links
550
+ for (const link of links) {
551
+ if (link.type === "parent-child") {
552
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
553
+ parentIds.add(src);
554
+ }
555
+ }
556
+ // From hierarchical IDs
557
+ const nodeIds = new Set(nodes.map((n) => n.id));
558
+ for (const node of nodes) {
559
+ if (node.id.includes(".")) {
560
+ const parentId = node.id.split(".")[0];
561
+ if (nodeIds.has(parentId)) parentIds.add(parentId);
562
+ }
563
+ }
564
+ return parentIds;
565
+ }, [data]);
566
+
567
+ const handleCollapseAll = useCallback(() => {
568
+ setCollapsedEpicIds(new Set(allParentEpicIds));
569
+ }, [allParentEpicIds]);
570
+
571
+ const handleExpandAll = useCallback(() => {
572
+ setCollapsedEpicIds(new Set());
521
573
  }, []);
522
574
 
523
575
  const handleBackgroundClick = useCallback(() => {
@@ -528,9 +580,10 @@ export default function Home() {
528
580
 
529
581
  const handleNodeRightClick = useCallback(
530
582
  (node: GraphNode, event: MouseEvent) => {
531
- // Dismiss any open comment tooltip from a previous interaction
583
+ // Dismiss any open comment tooltip and hover tooltip
532
584
  setCommentTooltipState(null);
533
- if (!node.description && !isAuthenticated) {
585
+ setNodeTooltip(null);
586
+ if (!node.description && !isAuthenticated && node.issueType !== "epic") {
534
587
  // No description and not logged in → only action is comment → skip menu
535
588
  setCommentTooltipState({
536
589
  node,
@@ -742,17 +795,38 @@ export default function Home() {
742
795
  [data]
743
796
  );
744
797
 
745
- // Search results - fuzzy match on id and title
798
+ // Build a map of nodeId -> commenter handles string for search
799
+ const commenterHandlesByNode = useMemo(() => {
800
+ const map = new Map<string, string>();
801
+ if (!allComments) return map;
802
+ const handlesMap = new Map<string, Set<string>>();
803
+ for (const comment of allComments) {
804
+ if (!handlesMap.has(comment.nodeId)) {
805
+ handlesMap.set(comment.nodeId, new Set());
806
+ }
807
+ handlesMap.get(comment.nodeId)!.add(comment.handle);
808
+ if (comment.displayName) {
809
+ handlesMap.get(comment.nodeId)!.add(comment.displayName);
810
+ }
811
+ }
812
+ for (const [nodeId, handles] of handlesMap) {
813
+ map.set(nodeId, Array.from(handles).join(" "));
814
+ }
815
+ return map;
816
+ }, [allComments]);
817
+
818
+ // Search results - fuzzy match on id, title, people, and commenter handles
746
819
  const searchResults = useMemo(() => {
747
820
  if (!data || !searchQuery.trim()) return [];
748
821
  const term = searchQuery.toLowerCase();
749
822
  return data.graphData.nodes
750
823
  .filter((n) => {
751
- const searchable = `${n.id} ${n.title} ${n.prefix}`.toLowerCase();
824
+ const commenters = commenterHandlesByNode.get(n.id) || "";
825
+ const searchable = `${n.id} ${n.title} ${n.prefix} ${n.owner || ""} ${n.assignee || ""} ${n.createdBy || ""} ${commenters}`.toLowerCase();
752
826
  return searchable.includes(term);
753
827
  })
754
828
  .slice(0, 8);
755
- }, [searchQuery, data]);
829
+ }, [searchQuery, data, commenterHandlesByNode]);
756
830
 
757
831
  // Reset highlight index when query changes
758
832
  useEffect(() => {
@@ -1172,6 +1246,11 @@ export default function Home() {
1172
1246
  timelineActive={timelineActive}
1173
1247
  stats={data.stats}
1174
1248
  sidebarOpen={!!selectedNode || allCommentsPanelOpen || activityPanelOpen}
1249
+ collapsedEpicIds={collapsedEpicIds}
1250
+ onCollapseAll={handleCollapseAll}
1251
+ onExpandAll={handleExpandAll}
1252
+ colorMode={colorMode}
1253
+ onColorModeChange={setColorMode}
1175
1254
  />
1176
1255
 
1177
1256
  {/* Timeline bar — replaces legend hint when active */}
@@ -1252,6 +1331,24 @@ export default function Home() {
1252
1331
  setContextMenu(null);
1253
1332
  };
1254
1333
  })()}
1334
+ onCollapseEpic={
1335
+ contextMenu.node.issueType === "epic" &&
1336
+ !collapsedEpicIds.has(contextMenu.node.id)
1337
+ ? () => {
1338
+ handleToggleEpicCollapse(contextMenu.node.id);
1339
+ setContextMenu(null);
1340
+ }
1341
+ : undefined
1342
+ }
1343
+ onUncollapseEpic={
1344
+ contextMenu.node.issueType === "epic" &&
1345
+ collapsedEpicIds.has(contextMenu.node.id)
1346
+ ? () => {
1347
+ handleToggleEpicCollapse(contextMenu.node.id);
1348
+ setContextMenu(null);
1349
+ }
1350
+ : undefined
1351
+ }
1255
1352
  onClose={() => setContextMenu(null)}
1256
1353
  />
1257
1354
  )}
@@ -1282,6 +1379,17 @@ export default function Home() {
1282
1379
  />
1283
1380
  )}
1284
1381
 
1382
+ {/* Node hover tooltip */}
1383
+ {nodeTooltip && !avatarTooltip && (
1384
+ <BeadTooltip
1385
+ node={nodeTooltip.node}
1386
+ x={nodeTooltip.x}
1387
+ y={nodeTooltip.y}
1388
+ prefixColor={getCatppuccinPrefixColor(nodeTooltip.node.prefix)}
1389
+ allNodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
1390
+ />
1391
+ )}
1392
+
1285
1393
  {/* Avatar hover tooltip */}
1286
1394
  {avatarTooltip && (
1287
1395
  <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
+ };