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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +2 -2
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +2 -2
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/api/beads.body +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +2 -2
- package/.next/server/app/page.js +3 -3
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +5 -5
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/page-7a8908706a09b720.js +1 -0
- package/.next/static/css/a506e3d172da58ef.css +3 -0
- package/app/page.tsx +66 -3
- package/components/BeadsGraph.tsx +108 -10
- package/components/ContextMenu.tsx +51 -5
- package/components/DescriptionModal.tsx +313 -10
- package/components/HelpPanel.tsx +4 -1
- package/components/NodeDetail.tsx +3 -1
- package/components/SettingsModal.tsx +235 -0
- package/components/TutorialOverlay.tsx +3 -3
- package/lib/settings.ts +42 -0
- package/lib/tts.ts +137 -0
- package/package.json +1 -1
- package/.next/static/chunks/app/page-4a4f07fcb5bd4637.js +0 -1
- package/.next/static/css/df2737696baac0fa.css +0 -3
- /package/.next/static/{bsmkR-2y8Ra7VuoNZWLzB → e6v54SLUeGDtx1DXW7JjL}/_buildManifest.js +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
364
|
-
for (const node of
|
|
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:
|
|
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 =
|
|
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[] =
|
|
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
|
|
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
|
|
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=
|
|
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=
|
|
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
|
);
|