beads-map 0.3.1 → 0.3.4

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 (38) 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-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.body +1 -1
  12. package/.next/server/app/index.html +1 -1
  13. package/.next/server/app/index.rsc +2 -2
  14. package/.next/server/app/page.js +3 -3
  15. package/.next/server/app/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app-paths-manifest.json +1 -1
  17. package/.next/server/functions-config-manifest.json +1 -1
  18. package/.next/server/pages/404.html +1 -1
  19. package/.next/server/pages/500.html +1 -1
  20. package/.next/server/server-reference-manifest.json +1 -1
  21. package/.next/static/chunks/app/page-cf8e14cb4afc8112.js +1 -0
  22. package/.next/static/css/ade5301262971664.css +3 -0
  23. package/README.md +21 -11
  24. package/app/page.tsx +150 -7
  25. package/components/BeadTooltip.tsx +26 -2
  26. package/components/BeadsGraph.tsx +433 -243
  27. package/components/ContextMenu.tsx +51 -5
  28. package/components/DescriptionModal.tsx +48 -18
  29. package/components/HelpPanel.tsx +336 -0
  30. package/components/NodeDetail.tsx +33 -8
  31. package/components/TutorialOverlay.tsx +187 -0
  32. package/lib/types.ts +2 -1
  33. package/lib/utils.ts +23 -0
  34. package/package.json +1 -1
  35. package/.next/static/chunks/app/page-a0493d6741516b53.js +0 -1
  36. package/.next/static/css/4fded26534cb91e3.css +0 -3
  37. /package/.next/static/{_OvcD8YYgVPHv6Tomg-pB → JmL0suxsggbSwPxWcmUFV}/_buildManifest.js +0 -0
  38. /package/.next/static/{_OvcD8YYgVPHv6Tomg-pB → JmL0suxsggbSwPxWcmUFV}/_ssgManifest.js +0 -0
@@ -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
  );
@@ -1,16 +1,29 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { createPortal } from "react-dom";
4
5
  import ReactMarkdown from "react-markdown";
5
6
  import remarkGfm from "remark-gfm";
6
7
  import type { GraphNode } from "@/lib/types";
8
+ import { buildDescriptionCopyText } from "@/lib/utils";
7
9
 
8
10
  interface DescriptionModalProps {
9
11
  node: GraphNode;
10
12
  onClose: () => void;
13
+ repoUrl?: string;
11
14
  }
12
15
 
13
- export function DescriptionModal({ node, onClose }: DescriptionModalProps) {
16
+ export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalProps) {
17
+ const [copied, setCopied] = useState(false);
18
+
19
+ const handleCopy = () => {
20
+ if (!node.description) return;
21
+ navigator.clipboard.writeText(buildDescriptionCopyText(node, repoUrl)).then(() => {
22
+ setCopied(true);
23
+ setTimeout(() => setCopied(false), 1500);
24
+ });
25
+ };
26
+
14
27
  if (!node.description) return null;
15
28
 
16
29
  return createPortal(
@@ -32,24 +45,41 @@ export function DescriptionModal({ node, onClose }: DescriptionModalProps) {
32
45
  {node.title}
33
46
  </span>
34
47
  </div>
35
- <button
36
- onClick={onClose}
37
- className="shrink-0 p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
38
- >
39
- <svg
40
- className="w-4 h-4"
41
- fill="none"
42
- stroke="currentColor"
43
- viewBox="0 0 24 24"
44
- strokeWidth={2}
48
+ <div className="flex items-center gap-1 shrink-0">
49
+ <button
50
+ onClick={handleCopy}
51
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
52
+ title="Copy description"
45
53
  >
46
- <path
47
- strokeLinecap="round"
48
- strokeLinejoin="round"
49
- d="M6 18L18 6M6 6l12 12"
50
- />
51
- </svg>
52
- </button>
54
+ {copied ? (
55
+ <svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
56
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
57
+ </svg>
58
+ ) : (
59
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
60
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
61
+ </svg>
62
+ )}
63
+ </button>
64
+ <button
65
+ onClick={onClose}
66
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
67
+ >
68
+ <svg
69
+ className="w-4 h-4"
70
+ fill="none"
71
+ stroke="currentColor"
72
+ viewBox="0 0 24 24"
73
+ strokeWidth={2}
74
+ >
75
+ <path
76
+ strokeLinecap="round"
77
+ strokeLinejoin="round"
78
+ d="M6 18L18 6M6 6l12 12"
79
+ />
80
+ </svg>
81
+ </button>
82
+ </div>
53
83
  </div>
54
84
  {/* Modal body */}
55
85
  <div className="flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed">
@@ -0,0 +1,336 @@
1
+ "use client";
2
+
3
+ import { TUTORIAL_STEPS } from "./TutorialOverlay";
4
+
5
+ // Catppuccin Latte accents
6
+ const CAT = {
7
+ red: "#d20f39",
8
+ teal: "#179299",
9
+ peach: "#fe640b",
10
+ blue: "#1e66f5",
11
+ green: "#40a02b",
12
+ mauve: "#8839ef",
13
+ sapphire: "#209fb5",
14
+ pink: "#ea76cb",
15
+ surface: "#dce0e8",
16
+ };
17
+
18
+ interface HelpPanelProps {
19
+ isOpen: boolean;
20
+ onClose: () => void;
21
+ tutorialStep?: number | null;
22
+ onStartTutorial?: () => void;
23
+ onNextStep?: () => void;
24
+ onPrevStep?: () => void;
25
+ onEndTutorial?: () => void;
26
+ }
27
+
28
+ export function HelpPanel({
29
+ isOpen,
30
+ onClose,
31
+ tutorialStep = null,
32
+ onStartTutorial,
33
+ onNextStep,
34
+ onPrevStep,
35
+ onEndTutorial,
36
+ }: HelpPanelProps) {
37
+ const isTutorialActive = tutorialStep !== null;
38
+ const headerText = isTutorialActive ? "Tutorial" : "Welcome to Heartbeads";
39
+
40
+ const content = isTutorialActive ? (
41
+ <TutorialContent
42
+ step={tutorialStep}
43
+ onNext={onNextStep!}
44
+ onPrev={onPrevStep!}
45
+ onEnd={onEndTutorial!}
46
+ />
47
+ ) : (
48
+ <HelpContent onStartTutorial={onStartTutorial} />
49
+ );
50
+
51
+ return (
52
+ <>
53
+ {/* Desktop: right sidebar — z-[60] during tutorial so it sits above the overlay */}
54
+ <aside
55
+ className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl transform transition-transform duration-300 ease-out ${
56
+ isOpen ? "translate-x-0" : "translate-x-full"
57
+ } ${isTutorialActive ? "z-[60]" : "z-30"}`}
58
+ >
59
+ <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-100 shrink-0">
60
+ <h2 className="text-sm font-semibold text-zinc-900">
61
+ {headerText}
62
+ </h2>
63
+ <button
64
+ onClick={onClose}
65
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
66
+ >
67
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
68
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
69
+ </svg>
70
+ </button>
71
+ </div>
72
+ <div className="flex-1 overflow-y-auto custom-scrollbar">
73
+ {content}
74
+ </div>
75
+ </aside>
76
+
77
+ {/* Mobile: bottom drawer */}
78
+ <div
79
+ className={`md:hidden fixed inset-x-0 bottom-0 transform transition-transform duration-300 ease-out ${
80
+ isOpen ? "translate-y-0" : "translate-y-full"
81
+ } ${isTutorialActive ? "z-[60]" : "z-20"}`}
82
+ >
83
+ <div className="bg-white rounded-t-2xl shadow-2xl border-t border-zinc-200 max-h-[70vh] flex flex-col">
84
+ <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-100 shrink-0">
85
+ <h2 className="text-sm font-semibold text-zinc-900">
86
+ {headerText}
87
+ </h2>
88
+ <button
89
+ onClick={onClose}
90
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
91
+ >
92
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
93
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
94
+ </svg>
95
+ </button>
96
+ </div>
97
+ <div className="flex-1 overflow-y-auto custom-scrollbar">
98
+ {content}
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </>
103
+ );
104
+ }
105
+
106
+ /* ------------------------------------------------------------------ */
107
+ /* Tutorial step-by-step content */
108
+ /* ------------------------------------------------------------------ */
109
+
110
+ function TutorialContent({
111
+ step,
112
+ onNext,
113
+ onPrev,
114
+ onEnd,
115
+ }: {
116
+ step: number;
117
+ onNext: () => void;
118
+ onPrev: () => void;
119
+ onEnd: () => void;
120
+ }) {
121
+ const currentStep = TUTORIAL_STEPS[step];
122
+ const totalSteps = TUTORIAL_STEPS.length;
123
+ const isFirst = step === 0;
124
+ const isLast = step === totalSteps - 1;
125
+
126
+ return (
127
+ <div className="px-5 py-4 flex flex-col min-h-[300px]">
128
+ {/* Step indicator */}
129
+ <div className="flex items-center justify-between mb-4">
130
+ <span className="text-xs font-medium" style={{ color: CAT.teal }}>
131
+ {step + 1} / {totalSteps}
132
+ </span>
133
+ <div className="flex gap-1">
134
+ {Array.from({ length: totalSteps }).map((_, i) => (
135
+ <div
136
+ key={i}
137
+ className="w-1.5 h-1.5 rounded-full transition-colors"
138
+ style={{
139
+ backgroundColor:
140
+ i === step ? CAT.green : i < step ? CAT.teal : CAT.surface,
141
+ }}
142
+ />
143
+ ))}
144
+ </div>
145
+ </div>
146
+
147
+ {/* Step title */}
148
+ <h3 className="text-base font-semibold text-zinc-900 mb-2">
149
+ {currentStep.title}
150
+ </h3>
151
+
152
+ {/* Step description */}
153
+ <p className="text-[13px] text-zinc-600 leading-relaxed mb-6">
154
+ {currentStep.description}
155
+ </p>
156
+
157
+ {/* Spacer */}
158
+ <div className="flex-1" />
159
+
160
+ {/* Navigation */}
161
+ <div className="flex items-center gap-2 pt-4 border-t border-zinc-100">
162
+ {isFirst ? (
163
+ <button
164
+ onClick={onEnd}
165
+ className="px-4 py-2 text-sm font-medium text-zinc-400 hover:text-zinc-600 rounded-lg hover:bg-zinc-50 transition-colors"
166
+ >
167
+ Skip
168
+ </button>
169
+ ) : (
170
+ <button
171
+ onClick={onPrev}
172
+ className="px-4 py-2 text-sm font-medium rounded-lg hover:bg-zinc-50 transition-colors"
173
+ style={{ color: CAT.blue }}
174
+ >
175
+ {"Back"}
176
+ </button>
177
+ )}
178
+ <div className="flex-1" />
179
+ {isLast ? (
180
+ <button
181
+ onClick={onEnd}
182
+ className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-emerald-500 hover:bg-emerald-600 transition-colors"
183
+ >
184
+ {"Done"}
185
+ </button>
186
+ ) : (
187
+ <button
188
+ onClick={onNext}
189
+ className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-emerald-500 hover:bg-emerald-600 transition-colors"
190
+ >
191
+ {"Next"}
192
+ </button>
193
+ )}
194
+ </div>
195
+ </div>
196
+ );
197
+ }
198
+
199
+ /* ------------------------------------------------------------------ */
200
+ /* Bullet with Catppuccin colored dot */
201
+ /* ------------------------------------------------------------------ */
202
+
203
+ function Bullet({ color, children }: { color: string; children: React.ReactNode }) {
204
+ return (
205
+ <li className="flex gap-2.5 items-start">
206
+ <span
207
+ className="w-1.5 h-1.5 rounded-full mt-[6px] shrink-0"
208
+ style={{ backgroundColor: color }}
209
+ />
210
+ <span>{children}</span>
211
+ </li>
212
+ );
213
+ }
214
+
215
+ function SectionTitle({ children, color }: { children: React.ReactNode; color: string }) {
216
+ return (
217
+ <h3
218
+ className="text-[11px] font-semibold uppercase tracking-widest mb-2 mt-5 first:mt-0"
219
+ style={{ color }}
220
+ >
221
+ {children}
222
+ </h3>
223
+ );
224
+ }
225
+
226
+ /* ------------------------------------------------------------------ */
227
+ /* Static help content */
228
+ /* ------------------------------------------------------------------ */
229
+
230
+ function HelpContent({ onStartTutorial }: { onStartTutorial?: () => void }) {
231
+ return (
232
+ <div className="px-5 py-4 text-[13px] text-zinc-600 leading-relaxed">
233
+ {/* Start Tutorial */}
234
+ {onStartTutorial && (
235
+ <button
236
+ onClick={onStartTutorial}
237
+ className="w-full flex items-center justify-center gap-2 px-4 py-2.5 mb-5 text-white text-sm font-medium rounded-lg bg-emerald-500 hover:bg-emerald-600 transition-colors"
238
+ >
239
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
240
+ <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" />
241
+ </svg>
242
+ Take the guided tour
243
+ </button>
244
+ )}
245
+
246
+ <p className="text-zinc-900 font-medium mb-1">
247
+ Your command center for AI coding tasks.
248
+ </p>
249
+ <p className="mb-4 text-zinc-500">
250
+ Heartbeads shows everything your AI agents are working on as a
251
+ live, interactive graph &mdash; tasks, dependencies, who&apos;s doing what, and
252
+ what&apos;s blocking progress.
253
+ </p>
254
+
255
+ <SectionTitle color={CAT.red}>The graph</SectionTitle>
256
+ <p className="mb-2">
257
+ Each <strong>circle</strong> is a task. <strong>Flowing particles</strong>{" "}
258
+ stream between them to show dependency direction.
259
+ </p>
260
+ <ul className="space-y-1.5 mb-3">
261
+ <Bullet color={CAT.red}><strong>Bigger circles</strong> &mdash; more connected, more important</Bullet>
262
+ <Bullet color={CAT.red}><strong>Solid lines + particles</strong> &mdash; &ldquo;blocks&rdquo; (A must finish before B)</Bullet>
263
+ <Bullet color={CAT.red}><strong>Dashed lines</strong> &mdash; parent-child grouping</Bullet>
264
+ <Bullet color={CAT.red}><strong>Colored ring</strong> &mdash; which project it belongs to</Bullet>
265
+ </ul>
266
+
267
+ <SectionTitle color={CAT.blue}>Navigation</SectionTitle>
268
+ <ul className="space-y-1.5 mb-3">
269
+ <Bullet color={CAT.blue}><strong>Click</strong> a node to open its details</Bullet>
270
+ <Bullet color={CAT.blue}><strong>Hover</strong> for a quick summary</Bullet>
271
+ <Bullet color={CAT.blue}><strong>Right-click</strong> for actions &mdash; descriptions, comments, claims, collapse</Bullet>
272
+ <Bullet color={CAT.blue}><strong>Scroll</strong> to zoom, <strong>drag</strong> to pan</Bullet>
273
+ <Bullet color={CAT.blue}><strong>Cmd/Ctrl+F</strong> to search by name, ID, owner, or commenter</Bullet>
274
+ </ul>
275
+
276
+ <SectionTitle color={CAT.teal}>Layouts</SectionTitle>
277
+ <p className="mb-2">
278
+ Top-left buttons rearrange the graph:
279
+ </p>
280
+ <ul className="space-y-1.5 mb-3">
281
+ <Bullet color={CAT.teal}><strong>Force</strong> &mdash; organic, physics-based</Bullet>
282
+ <Bullet color={CAT.teal}><strong>DAG</strong> &mdash; clean top-down tree</Bullet>
283
+ <Bullet color={CAT.teal}><strong>Radial</strong> &mdash; rings from center outward</Bullet>
284
+ <Bullet color={CAT.teal}><strong>Cluster</strong> &mdash; grouped by project</Bullet>
285
+ <Bullet color={CAT.teal}><strong>Spread</strong> &mdash; spaced out for screenshots</Bullet>
286
+ </ul>
287
+
288
+ <SectionTitle color={CAT.peach}>Color modes</SectionTitle>
289
+ <p className="mb-2">
290
+ Bottom-right panel &mdash; paint nodes by:
291
+ </p>
292
+ <ul className="space-y-1.5 mb-3">
293
+ <Bullet color={CAT.peach}><strong>Status</strong> &mdash; open, in progress, blocked, closed</Bullet>
294
+ <Bullet color={CAT.peach}><strong>Priority</strong> &mdash; P0 critical to P4 backlog</Bullet>
295
+ <Bullet color={CAT.peach}><strong>Owner</strong> &mdash; who created it</Bullet>
296
+ <Bullet color={CAT.peach}><strong>Assignee</strong> &mdash; who&apos;s working on it</Bullet>
297
+ <Bullet color={CAT.peach}><strong>Prefix</strong> &mdash; which project</Bullet>
298
+ </ul>
299
+
300
+ <SectionTitle color={CAT.mauve}>More</SectionTitle>
301
+ <ul className="space-y-1.5 mb-3">
302
+ <Bullet color={CAT.mauve}><strong>Collapse/Expand</strong> &mdash; tidy up epics into single nodes</Bullet>
303
+ <Bullet color={CAT.mauve}><strong>Clusters</strong> &mdash; dashed circles grouping projects when zoomed out</Bullet>
304
+ <Bullet color={CAT.mauve}><strong>Replay</strong> &mdash; step through your project&apos;s history</Bullet>
305
+ <Bullet color={CAT.mauve}><strong>Comments</strong> &mdash; leave notes on tasks (sign in first)</Bullet>
306
+ <Bullet color={CAT.mauve}><strong>Claim tasks</strong> &mdash; right-click to mark as yours</Bullet>
307
+ <Bullet color={CAT.mauve}><strong>Minimap</strong> &mdash; click to jump, drag edges to resize</Bullet>
308
+ <Bullet color={CAT.mauve}><strong>Auto-fit</strong> &mdash; top-left toggle to lock/unlock automatic camera reframing</Bullet>
309
+ <Bullet color={CAT.mauve}><strong>Copy</strong> &mdash; clipboard icon copies task with project info</Bullet>
310
+ </ul>
311
+
312
+ <div className="mt-5 pt-4 border-t border-zinc-100 text-xs text-zinc-400">
313
+ Built with{" "}
314
+ <a
315
+ href="https://github.com/GainForest/beads"
316
+ target="_blank"
317
+ rel="noopener noreferrer"
318
+ className="underline underline-offset-2 transition-colors"
319
+ style={{ color: CAT.green }}
320
+ >
321
+ beads
322
+ </a>{" "}
323
+ &mdash; open-source issue tracking for AI-native development. Heartbeads is built for the GainForest agentic workflow and open-sourced at{" "}
324
+ <a
325
+ href="https://github.com/daviddao/heartbeads"
326
+ target="_blank"
327
+ rel="noopener noreferrer"
328
+ className="underline underline-offset-2 transition-colors"
329
+ style={{ color: CAT.green }}
330
+ >
331
+ github.com/daviddao/heartbeads
332
+ </a>.
333
+ </div>
334
+ </div>
335
+ );
336
+ }
@@ -6,7 +6,7 @@ import remarkGfm from "remark-gfm";
6
6
  import type { GraphNode } from "@/lib/types";
7
7
  import { DescriptionModal } from "./DescriptionModal";
8
8
  import { HeartIcon } from "@/components/HeartIcon";
9
- import { formatRelativeTime } from "@/lib/utils";
9
+ import { formatRelativeTime, buildDescriptionCopyText } from "@/lib/utils";
10
10
  import {
11
11
  STATUS_LABELS,
12
12
  STATUS_COLORS,
@@ -50,6 +50,7 @@ export default function NodeDetail({
50
50
  const [replyText, setReplyText] = useState("");
51
51
  const [isSubmittingReply, setIsSubmittingReply] = useState(false);
52
52
  const [descriptionExpanded, setDescriptionExpanded] = useState(false);
53
+ const [descCopied, setDescCopied] = useState(false);
53
54
 
54
55
  const handleStartReply = (comment: BeadsComment) => {
55
56
  setReplyingToUri(comment.uri);
@@ -314,12 +315,36 @@ export default function NodeDetail({
314
315
  <h4 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
315
316
  Description
316
317
  </h4>
317
- <button
318
- onClick={() => setDescriptionExpanded(true)}
319
- className="text-[10px] text-zinc-400 hover:text-zinc-600 transition-colors"
320
- >
321
- View in window
322
- </button>
318
+ <div className="flex items-center gap-2">
319
+ <button
320
+ onClick={() => {
321
+ if (!node.description) return;
322
+ const repoUrl = repoUrls?.[node.prefix];
323
+ navigator.clipboard.writeText(buildDescriptionCopyText(node, repoUrl)).then(() => {
324
+ setDescCopied(true);
325
+ setTimeout(() => setDescCopied(false), 1500);
326
+ });
327
+ }}
328
+ className="text-zinc-400 hover:text-zinc-600 transition-colors"
329
+ title="Copy description"
330
+ >
331
+ {descCopied ? (
332
+ <svg className="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
333
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
334
+ </svg>
335
+ ) : (
336
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
337
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
338
+ </svg>
339
+ )}
340
+ </button>
341
+ <button
342
+ onClick={() => setDescriptionExpanded(true)}
343
+ className="text-[10px] text-zinc-400 hover:text-zinc-600 transition-colors"
344
+ >
345
+ View in window
346
+ </button>
347
+ </div>
323
348
  </div>
324
349
  <div className="text-xs text-zinc-600 leading-relaxed bg-zinc-50 rounded-lg p-3 max-h-40 overflow-y-auto custom-scrollbar border border-zinc-100 description-markdown">
325
350
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
@@ -331,7 +356,7 @@ export default function NodeDetail({
331
356
 
332
357
  {/* Description expanded modal */}
333
358
  {descriptionExpanded && node.description && (
334
- <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} />
359
+ <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} repoUrl={repoUrls?.[node.prefix]} />
335
360
  )}
336
361
 
337
362
  {/* Blocks (issues this blocks) */}