beads-map 0.3.3 → 0.3.5

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 (36) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +2 -2
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/next-server.js.nft.json +1 -1
  6. package/.next/prerender-manifest.json +1 -1
  7. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_not-found.html +1 -1
  9. package/.next/server/app/_not-found.rsc +1 -1
  10. package/.next/server/app/api/beads.body +1 -1
  11. package/.next/server/app/index.html +1 -1
  12. package/.next/server/app/index.rsc +2 -2
  13. package/.next/server/app/page.js +3 -3
  14. package/.next/server/app/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app-paths-manifest.json +5 -5
  16. package/.next/server/functions-config-manifest.json +1 -1
  17. package/.next/server/pages/404.html +1 -1
  18. package/.next/server/pages/500.html +1 -1
  19. package/.next/server/server-reference-manifest.json +1 -1
  20. package/.next/static/chunks/app/page-7a8908706a09b720.js +1 -0
  21. package/.next/static/css/a506e3d172da58ef.css +3 -0
  22. package/app/page.tsx +66 -3
  23. package/components/BeadsGraph.tsx +108 -10
  24. package/components/ContextMenu.tsx +51 -5
  25. package/components/DescriptionModal.tsx +313 -10
  26. package/components/HelpPanel.tsx +4 -1
  27. package/components/NodeDetail.tsx +3 -1
  28. package/components/SettingsModal.tsx +235 -0
  29. package/components/TutorialOverlay.tsx +3 -3
  30. package/lib/settings.ts +42 -0
  31. package/lib/tts.ts +137 -0
  32. package/package.json +1 -1
  33. package/.next/static/chunks/app/page-4a4f07fcb5bd4637.js +0 -1
  34. package/.next/static/css/df2737696baac0fa.css +0 -3
  35. /package/.next/static/{bsmkR-2y8Ra7VuoNZWLzB → e6v54SLUeGDtx1DXW7JjL}/_buildManifest.js +0 -0
  36. /package/.next/static/{bsmkR-2y8Ra7VuoNZWLzB → e6v54SLUeGDtx1DXW7JjL}/_ssgManifest.js +0 -0
package/app/page.tsx CHANGED
@@ -19,6 +19,7 @@ import AllCommentsPanel from "@/components/AllCommentsPanel";
19
19
  import { ActivityOverlay } from "@/components/ActivityOverlay";
20
20
  import { ActivityPanel } from "@/components/ActivityPanel";
21
21
  import { HelpPanel } from "@/components/HelpPanel";
22
+ import { SettingsModal } from "@/components/SettingsModal";
22
23
  import { TutorialOverlay, TUTORIAL_STEPS } from "@/components/TutorialOverlay";
23
24
  import { useBeadsComments } from "@/hooks/useBeadsComments";
24
25
  import type { BeadsComment } from "@/hooks/useBeadsComments";
@@ -191,6 +192,7 @@ export default function Home() {
191
192
  const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
192
193
  const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
193
194
  const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set());
195
+ const [focusedEpicId, setFocusedEpicId] = useState<string | null>(null);
194
196
  const [colorMode, setColorMode] = useState<ColorMode>("status");
195
197
  const [projectName, setProjectName] = useState("Beads");
196
198
  const [repoCount, setRepoCount] = useState(0);
@@ -252,16 +254,28 @@ export default function Home() {
252
254
  const [helpPanelOpen, setHelpPanelOpen] = useState(false);
253
255
  const [tutorialStep, setTutorialStep] = useState<number | null>(null);
254
256
 
257
+ // Set of node IDs in the local graph — used to filter global comments/activity
258
+ // to only events relevant to beads in this repo
259
+ const localNodeIds = useMemo(() => {
260
+ if (!data) return new Set<string>();
261
+ return new Set(data.graphData.nodes.map((n) => n.id));
262
+ }, [data]);
263
+
255
264
  // Rebuild historical feed when data or comments change
265
+ // Filter comments to only those targeting nodes in our graph, since the
266
+ // Hypergoat indexer returns comments globally across all repos using beads
256
267
  useEffect(() => {
257
268
  if (!data) return;
269
+ const localComments = allComments
270
+ ? allComments.filter((c) => localNodeIds.has(c.nodeId))
271
+ : null;
258
272
  const historical = buildHistoricalFeed(
259
273
  data.graphData.nodes,
260
274
  data.graphData.links,
261
- allComments
275
+ localComments
262
276
  );
263
277
  setActivityFeed((prev) => mergeFeedEvents(prev, historical));
264
- }, [data, allComments]);
278
+ }, [data, allComments, localNodeIds]);
265
279
 
266
280
  // Context menu state for right-click (phase 1: shows ContextMenu)
267
281
  const [contextMenu, setContextMenu] = useState<{
@@ -281,6 +295,9 @@ export default function Home() {
281
295
  const [descriptionModalNode, setDescriptionModalNode] =
282
296
  useState<GraphNode | null>(null);
283
297
 
298
+ // Settings modal state
299
+ const [settingsModalOpen, setSettingsModalOpen] = useState(false);
300
+
284
301
  // Avatar hover tooltip state
285
302
  const [avatarTooltip, setAvatarTooltip] = useState<{
286
303
  handle: string;
@@ -583,6 +600,14 @@ export default function Home() {
583
600
  setCollapsedEpicIds(new Set());
584
601
  }, []);
585
602
 
603
+ const handleFocusEpic = useCallback((epicId: string) => {
604
+ setFocusedEpicId(epicId);
605
+ }, []);
606
+
607
+ const handleExitFocusedEpic = useCallback(() => {
608
+ setFocusedEpicId(null);
609
+ }, []);
610
+
586
611
  // --- Tutorial callbacks ---
587
612
  const handleStartTutorial = useCallback(() => {
588
613
  setHelpPanelOpen(true);
@@ -1285,6 +1310,16 @@ export default function Home() {
1285
1310
  <span className="hidden sm:inline">Learn</span>
1286
1311
  </button>
1287
1312
  <div className="w-px h-5 bg-zinc-200 mx-2" />
1313
+ <button
1314
+ onClick={() => setSettingsModalOpen(true)}
1315
+ className="p-2 text-zinc-400 hover:text-zinc-600 hover:bg-zinc-50 rounded-full transition-colors"
1316
+ title="Settings"
1317
+ >
1318
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1319
+ <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" />
1320
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
1321
+ </svg>
1322
+ </button>
1288
1323
  <AuthButton />
1289
1324
  </div>
1290
1325
  </div>
@@ -1323,6 +1358,8 @@ export default function Home() {
1323
1358
  collapsedEpicIds={collapsedEpicIds}
1324
1359
  onCollapseAll={handleCollapseAll}
1325
1360
  onExpandAll={handleExpandAll}
1361
+ focusedEpicId={focusedEpicId}
1362
+ onExitFocusedEpic={handleExitFocusedEpic}
1326
1363
  colorMode={colorMode}
1327
1364
  onColorModeChange={setColorMode}
1328
1365
  autoFit={autoFit}
@@ -1429,6 +1466,23 @@ export default function Home() {
1429
1466
  }
1430
1467
  : undefined
1431
1468
  }
1469
+ onFocusEpic={
1470
+ contextMenu.node.issueType === "epic" && !focusedEpicId
1471
+ ? () => {
1472
+ handleFocusEpic(contextMenu.node.id);
1473
+ setContextMenu(null);
1474
+ }
1475
+ : undefined
1476
+ }
1477
+ onExitFocusEpic={
1478
+ contextMenu.node.issueType === "epic" &&
1479
+ focusedEpicId === contextMenu.node.id
1480
+ ? () => {
1481
+ handleExitFocusedEpic();
1482
+ setContextMenu(null);
1483
+ }
1484
+ : undefined
1485
+ }
1432
1486
  onClose={() => setContextMenu(null)}
1433
1487
  />
1434
1488
  )}
@@ -1457,9 +1511,16 @@ export default function Home() {
1457
1511
  node={descriptionModalNode}
1458
1512
  onClose={() => setDescriptionModalNode(null)}
1459
1513
  repoUrl={repoUrls[descriptionModalNode.prefix]}
1514
+ onOpenSettings={() => setSettingsModalOpen(true)}
1460
1515
  />
1461
1516
  )}
1462
1517
 
1518
+ {/* Settings modal */}
1519
+ <SettingsModal
1520
+ isOpen={settingsModalOpen}
1521
+ onClose={() => setSettingsModalOpen(false)}
1522
+ />
1523
+
1463
1524
  {/* Node hover tooltip */}
1464
1525
  {nodeTooltip && !avatarTooltip && (
1465
1526
  <BeadTooltip
@@ -1578,6 +1639,7 @@ export default function Home() {
1578
1639
  isAuthenticated={isAuthenticated}
1579
1640
  currentDid={session?.did}
1580
1641
  repoUrls={repoUrls}
1642
+ onOpenSettings={() => setSettingsModalOpen(true)}
1581
1643
  />
1582
1644
 
1583
1645
  </div>
@@ -1636,6 +1698,7 @@ export default function Home() {
1636
1698
  isAuthenticated={isAuthenticated}
1637
1699
  currentDid={session?.did}
1638
1700
  repoUrls={repoUrls}
1701
+ onOpenSettings={() => setSettingsModalOpen(true)}
1639
1702
  />
1640
1703
  </div>
1641
1704
  </div>
@@ -1645,7 +1708,7 @@ export default function Home() {
1645
1708
  <AllCommentsPanel
1646
1709
  isOpen={allCommentsPanelOpen}
1647
1710
  onClose={() => setAllCommentsPanelOpen(false)}
1648
- allComments={allComments}
1711
+ allComments={allComments.filter((c) => localNodeIds.has(c.nodeId))}
1649
1712
  onNodeNavigate={(nodeId) => {
1650
1713
  handleNodeNavigate(nodeId);
1651
1714
  setAllCommentsPanelOpen(false);
@@ -62,6 +62,10 @@ interface BeadsGraphProps {
62
62
  showPulse?: boolean;
63
63
  /** Callback to toggle pulse animation */
64
64
  onShowPulseToggle?: () => void;
65
+ /** When set, only show this epic and its connected subgraph */
66
+ focusedEpicId?: string | null;
67
+ /** Callback to exit focused epic mode */
68
+ onExitFocusedEpic?: () => void;
65
69
  }
66
70
 
67
71
  // Node size calculation
@@ -254,6 +258,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
254
258
  pulseNodeId,
255
259
  showPulse = true,
256
260
  onShowPulseToggle,
261
+ focusedEpicId,
262
+ onExitFocusedEpic,
257
263
  }, ref) {
258
264
  const graphRef = useRef<any>(null);
259
265
  const containerRef = useRef<HTMLDivElement>(null);
@@ -347,11 +353,64 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
347
353
  // then removes child nodes and remaps their links to the parent epic.
348
354
  // Must be declared BEFORE effects that reference viewNodes/viewLinks.
349
355
  const { viewNodes, viewLinks } = useMemo(() => {
350
- if (!collapsedEpicIds || collapsedEpicIds.size === 0) return { viewNodes: nodes, viewLinks: links };
356
+ let currentNodes = nodes;
357
+ let currentLinks = links;
358
+
359
+ // === PHASE 1: Epic focus mode ===
360
+ // When focused on an epic, filter to only the epic's subgraph
361
+ if (focusedEpicId) {
362
+ // Build child->parent map
363
+ const childToParent = new Map<string, string>();
364
+ for (const link of currentLinks) {
365
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
366
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
367
+ if (link.type === "parent-child") {
368
+ childToParent.set(tgt, src);
369
+ }
370
+ }
371
+ const nodeIdSet = new Set(currentNodes.map((n) => n.id));
372
+ for (const node of currentNodes) {
373
+ if (!childToParent.has(node.id) && node.id.includes(".")) {
374
+ const parentId = node.id.split(".")[0];
375
+ if (nodeIdSet.has(parentId)) {
376
+ childToParent.set(node.id, parentId);
377
+ }
378
+ }
379
+ }
380
+
381
+ // Collect epic + direct children
382
+ const subgraphIds = new Set<string>();
383
+ subgraphIds.add(focusedEpicId);
384
+ for (const [childId, parentId] of childToParent) {
385
+ if (parentId === focusedEpicId) {
386
+ subgraphIds.add(childId);
387
+ }
388
+ }
389
+
390
+ // Add 1-hop neighbors connected via blocks/relates_to links
391
+ for (const link of currentLinks) {
392
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
393
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
394
+ if (link.type !== "parent-child") {
395
+ if (subgraphIds.has(src) && nodeIdSet.has(tgt)) subgraphIds.add(tgt);
396
+ if (subgraphIds.has(tgt) && nodeIdSet.has(src)) subgraphIds.add(src);
397
+ }
398
+ }
399
+
400
+ currentNodes = currentNodes.filter((n) => subgraphIds.has(n.id));
401
+ currentLinks = currentLinks.filter((link) => {
402
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
403
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
404
+ return subgraphIds.has(src) && subgraphIds.has(tgt);
405
+ });
406
+ }
407
+
408
+ // === PHASE 2: Collapse mode ===
409
+ if (!collapsedEpicIds || collapsedEpicIds.size === 0) return { viewNodes: currentNodes, viewLinks: currentLinks };
351
410
 
352
411
  // Build child->parent map from parent-child links
353
412
  const childToParent = new Map<string, string>();
354
- for (const link of links) {
413
+ for (const link of currentLinks) {
355
414
  const src = typeof link.source === "object" ? (link.source as any).id : link.source;
356
415
  const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
357
416
  if (link.type === "parent-child") {
@@ -360,8 +419,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
360
419
  }
361
420
  }
362
421
  // Fallback: infer from hierarchical IDs (e.g., "beads-map-3r3.1" -> parent "beads-map-3r3")
363
- const nodeIds = new Set(nodes.map((n) => n.id));
364
- for (const node of nodes) {
422
+ const nodeIds = new Set(currentNodes.map((n) => n.id));
423
+ for (const node of currentNodes) {
365
424
  if (!childToParent.has(node.id) && node.id.includes(".")) {
366
425
  const parentId = node.id.split(".")[0];
367
426
  if (nodeIds.has(parentId)) {
@@ -378,7 +437,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
378
437
  }
379
438
  }
380
439
 
381
- if (childIds.size === 0) return { viewNodes: nodes, viewLinks: links };
440
+ if (childIds.size === 0) return { viewNodes: currentNodes, viewLinks: currentLinks };
382
441
 
383
442
  // Also build a filtered childToParent for only the collapsed children (for link remapping)
384
443
  const collapsedChildToParent = new Map<string, string>();
@@ -392,7 +451,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
392
451
  const extraDependentCount = new Map<string, number>();
393
452
  for (const [childId, parentId] of collapsedChildToParent) {
394
453
  collapsedCounts.set(parentId, (collapsedCounts.get(parentId) || 0) + 1);
395
- const child = nodes.find((n) => n.id === childId);
454
+ const child = currentNodes.find((n) => n.id === childId);
396
455
  if (child) {
397
456
  extraBlockerCount.set(parentId, (extraBlockerCount.get(parentId) || 0) + child.blockerCount);
398
457
  extraDependentCount.set(parentId, (extraDependentCount.get(parentId) || 0) + child.dependentCount);
@@ -400,7 +459,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
400
459
  }
401
460
 
402
461
  // Filter nodes: remove collapsed children, augment their parents
403
- const filteredNodes: GraphNode[] = nodes
462
+ const filteredNodes: GraphNode[] = currentNodes
404
463
  .filter((n) => !childIds.has(n.id))
405
464
  .map((n) => ({
406
465
  ...n,
@@ -412,7 +471,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
412
471
  // Remap links: replace collapsed child IDs with parent IDs, drop internal parent-child links
413
472
  const remappedLinks: GraphLink[] = [];
414
473
  const linkSeen = new Set<string>();
415
- for (const link of links) {
474
+ for (const link of currentLinks) {
416
475
  let src = typeof link.source === "object" ? (link.source as any).id : link.source;
417
476
  let tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
418
477
  // Drop parent-child links where the child is collapsed
@@ -428,7 +487,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
428
487
  }
429
488
 
430
489
  return { viewNodes: filteredNodes, viewLinks: remappedLinks };
431
- }, [nodes, links, collapsedEpicIds]);
490
+ }, [nodes, links, collapsedEpicIds, focusedEpicId]);
432
491
 
433
492
  // Keep viewNodesRef in sync for mousemove avatar hit-testing
434
493
  viewNodesRef.current = viewNodes;
@@ -897,6 +956,24 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
897
956
  }
898
957
  }, [nodes.length, timelineActive, autoFit]);
899
958
 
959
+ // Auto zoom-to-fit when entering/exiting epic focus mode (unconditional)
960
+ const prevFocusedEpicIdRef = useRef<string | null | undefined>(undefined);
961
+ useEffect(() => {
962
+ // Skip on mount (initial render)
963
+ if (prevFocusedEpicIdRef.current === undefined) {
964
+ prevFocusedEpicIdRef.current = focusedEpicId ?? null;
965
+ return;
966
+ }
967
+ prevFocusedEpicIdRef.current = focusedEpicId ?? null;
968
+ const graph = graphRef.current;
969
+ if (!graph) return;
970
+ // Small delay to let the force graph process the new node set
971
+ const timer = setTimeout(() => {
972
+ graph.zoomToFit(400, 60);
973
+ }, 100);
974
+ return () => clearTimeout(timer);
975
+ }, [focusedEpicId]);
976
+
900
977
  // Memoize graphData so the object reference stays stable across renders.
901
978
  // This prevents react-force-graph from treating it as "new data" and
902
979
  // re-heating the simulation on every hover/selection change.
@@ -1822,8 +1899,29 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1822
1899
 
1823
1900
  return (
1824
1901
  <div ref={containerRef} className="w-full h-full relative" data-tutorial="graph">
1825
- {/* Top-left controls — two rows */}
1902
+ {/* Top-left controls */}
1826
1903
  <div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex flex-col gap-1.5 sm:gap-2">
1904
+ {/* Focus mode banner */}
1905
+ {focusedEpicId && onExitFocusedEpic && (
1906
+ <div className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-emerald-50/90 backdrop-blur-sm rounded-lg border border-emerald-200 shadow-sm text-emerald-700">
1907
+ <svg className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1908
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5M16.5 20.25H18A2.25 2.25 0 0020.25 18v-1.5M7.5 20.25H6A2.25 2.25 0 013.75 18v-1.5" />
1909
+ </svg>
1910
+ <span className="truncate max-w-[180px]">
1911
+ Focused: <span className="font-semibold">{nodes.find((n) => n.id === focusedEpicId)?.title || focusedEpicId}</span>
1912
+ </span>
1913
+ <button
1914
+ onClick={onExitFocusedEpic}
1915
+ className="ml-auto p-0.5 rounded hover:bg-emerald-200/50 transition-colors flex-shrink-0"
1916
+ title="Show full graph"
1917
+ >
1918
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1919
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
1920
+ </svg>
1921
+ </button>
1922
+ </div>
1923
+ )}
1924
+
1827
1925
  {/* Row 1: Layout shape controls */}
1828
1926
  <div className="flex items-start gap-1.5 sm:gap-2">
1829
1927
  {/* Layout mode toggle */}
@@ -13,6 +13,8 @@ interface ContextMenuProps {
13
13
  onUnclaimTask?: () => void;
14
14
  onCollapseEpic?: () => void;
15
15
  onUncollapseEpic?: () => void;
16
+ onFocusEpic?: () => void;
17
+ onExitFocusEpic?: () => void;
16
18
  onClose: () => void;
17
19
  }
18
20
 
@@ -26,6 +28,8 @@ export function ContextMenu({
26
28
  onUnclaimTask,
27
29
  onCollapseEpic,
28
30
  onUncollapseEpic,
31
+ onFocusEpic,
32
+ onExitFocusEpic,
29
33
  onClose,
30
34
  }: ContextMenuProps) {
31
35
  const menuRef = useRef<HTMLDivElement>(null);
@@ -119,7 +123,7 @@ export function ContextMenu({
119
123
  )}
120
124
  <button
121
125
  onClick={onAddComment}
122
- className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onClaimTask || onUnclaimTask || onCollapseEpic || onUncollapseEpic ? " border-b border-zinc-100" : ""}`}
126
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onClaimTask || onUnclaimTask || onCollapseEpic || onUncollapseEpic || onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
123
127
  >
124
128
  <svg
125
129
  className="w-3.5 h-3.5 text-zinc-400"
@@ -139,7 +143,7 @@ export function ContextMenu({
139
143
  {onClaimTask && (
140
144
  <button
141
145
  onClick={onClaimTask}
142
- className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onCollapseEpic || onUncollapseEpic ? " border-b border-zinc-100" : ""}`}
146
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onCollapseEpic || onUncollapseEpic || onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
143
147
  >
144
148
  <svg
145
149
  className="w-3.5 h-3.5 text-zinc-400"
@@ -160,7 +164,7 @@ export function ContextMenu({
160
164
  {onUnclaimTask && (
161
165
  <button
162
166
  onClick={onUnclaimTask}
163
- className={`w-full px-3 py-2.5 text-xs text-red-500 hover:bg-red-50 flex items-center gap-2 transition-colors${onCollapseEpic || onUncollapseEpic ? " border-b border-zinc-100" : ""}`}
167
+ className={`w-full px-3 py-2.5 text-xs text-red-500 hover:bg-red-50 flex items-center gap-2 transition-colors${onCollapseEpic || onUncollapseEpic || onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
164
168
  >
165
169
  <svg
166
170
  className="w-3.5 h-3.5 text-red-400"
@@ -181,7 +185,7 @@ export function ContextMenu({
181
185
  {onCollapseEpic && (
182
186
  <button
183
187
  onClick={onCollapseEpic}
184
- className="w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors"
188
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
185
189
  >
186
190
  <svg
187
191
  className="w-3.5 h-3.5 text-zinc-400"
@@ -202,7 +206,7 @@ export function ContextMenu({
202
206
  {onUncollapseEpic && (
203
207
  <button
204
208
  onClick={onUncollapseEpic}
205
- className="w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors"
209
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
206
210
  >
207
211
  <svg
208
212
  className="w-3.5 h-3.5 text-zinc-400"
@@ -220,6 +224,48 @@ export function ContextMenu({
220
224
  Uncollapse epic
221
225
  </button>
222
226
  )}
227
+ {onFocusEpic && (
228
+ <button
229
+ onClick={onFocusEpic}
230
+ className="w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors"
231
+ >
232
+ <svg
233
+ className="w-3.5 h-3.5 text-zinc-400"
234
+ fill="none"
235
+ viewBox="0 0 24 24"
236
+ strokeWidth={1.5}
237
+ stroke="currentColor"
238
+ >
239
+ <path
240
+ strokeLinecap="round"
241
+ strokeLinejoin="round"
242
+ d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5M16.5 20.25H18A2.25 2.25 0 0020.25 18v-1.5M7.5 20.25H6A2.25 2.25 0 013.75 18v-1.5"
243
+ />
244
+ </svg>
245
+ Focus on epic
246
+ </button>
247
+ )}
248
+ {onExitFocusEpic && (
249
+ <button
250
+ onClick={onExitFocusEpic}
251
+ className="w-full px-3 py-2.5 text-xs text-emerald-600 hover:bg-emerald-50 flex items-center gap-2 transition-colors"
252
+ >
253
+ <svg
254
+ className="w-3.5 h-3.5 text-emerald-500"
255
+ fill="none"
256
+ viewBox="0 0 24 24"
257
+ strokeWidth={1.5}
258
+ stroke="currentColor"
259
+ >
260
+ <path
261
+ strokeLinecap="round"
262
+ strokeLinejoin="round"
263
+ d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
264
+ />
265
+ </svg>
266
+ Show full graph
267
+ </button>
268
+ )}
223
269
  </div>
224
270
  </div>
225
271
  );