@rsktash/beads-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -0,0 +1,200 @@
1
+ import { useMemo, useState, useRef, useEffect } from "react";
2
+ import { useSubscription } from "../hooks/use-subscription";
3
+ import { IssueCard } from "../components/IssueCard";
4
+ import type { SubscriptionType } from "../lib/types";
5
+
6
+ const COLUMN_PAGE_SIZE = 20;
7
+
8
+ const STATUS_COLORS: Record<string, string> = {
9
+ open: "var(--status-open)",
10
+ in_progress: "var(--status-in-progress)",
11
+ blocked: "var(--status-blocked)",
12
+ closed: "var(--status-closed)",
13
+ };
14
+
15
+ function Column({
16
+ title,
17
+ subscriptionType,
18
+ statusKey,
19
+ onCardClick,
20
+ isClosed = false,
21
+ }: {
22
+ title: string;
23
+ subscriptionType: SubscriptionType;
24
+ statusKey: string;
25
+ onCardClick: (id: string) => void;
26
+ isClosed?: boolean;
27
+ }) {
28
+ const [limit, setLimit] = useState(COLUMN_PAGE_SIZE);
29
+ const params = useMemo(() => ({ limit, offset: 0 }), [limit]);
30
+ const { issues, loading, total } = useSubscription(subscriptionType, params);
31
+ const scrollRef = useRef<HTMLDivElement>(null);
32
+ const [hasOverflow, setHasOverflow] = useState(false);
33
+
34
+ const hasMore = total > issues.length;
35
+ const remaining = total - issues.length;
36
+ const dotColor = STATUS_COLORS[statusKey] ?? "var(--text-tertiary)";
37
+
38
+ useEffect(() => {
39
+ const el = scrollRef.current;
40
+ if (!el) return;
41
+ const check = () => setHasOverflow(el.scrollHeight > el.clientHeight);
42
+ check();
43
+ const observer = new ResizeObserver(check);
44
+ observer.observe(el);
45
+ return () => observer.disconnect();
46
+ }, [issues.length]);
47
+
48
+ return (
49
+ <div className="flex-1 min-w-[280px] max-w-[360px] flex flex-col self-start">
50
+ {/* Column header */}
51
+ <div
52
+ className="flex items-center gap-2.5 px-1 pb-3 mb-3"
53
+ style={{ borderBottom: "1px solid var(--border-subtle)" }}
54
+ >
55
+ <div
56
+ className="rounded-full shrink-0"
57
+ style={{
58
+ width: "10px",
59
+ height: "10px",
60
+ backgroundColor: dotColor,
61
+ boxShadow: `0 0 0 3px ${dotColor}33`,
62
+ }}
63
+ />
64
+ <h2
65
+ className="text-sm font-semibold"
66
+ style={{ color: "var(--text-primary)" }}
67
+ >
68
+ {title}
69
+ </h2>
70
+ <span
71
+ className="text-xs font-medium px-2 py-0.5 rounded-full ml-auto"
72
+ style={{
73
+ backgroundColor: "rgba(0,0,0,0.05)",
74
+ color: "var(--text-tertiary)",
75
+ fontSize: "11px",
76
+ }}
77
+ >
78
+ {loading ? "\u2026" : isClosed && total > issues.length ? `${issues.length} / ${total}` : total}
79
+ </span>
80
+ </div>
81
+
82
+ {/* Card list */}
83
+ <div
84
+ ref={scrollRef}
85
+ className="space-y-2 overflow-y-auto flex-1 pr-1 relative"
86
+ style={{
87
+ maxHeight: "calc(100vh - 160px)",
88
+ maskImage: hasOverflow
89
+ ? "linear-gradient(to bottom, transparent 0px, black 8px, black calc(100% - 8px), transparent 100%)"
90
+ : undefined,
91
+ WebkitMaskImage: hasOverflow
92
+ ? "linear-gradient(to bottom, transparent 0px, black 8px, black calc(100% - 8px), transparent 100%)"
93
+ : undefined,
94
+ }}
95
+ >
96
+ {loading && issues.length === 0 && (
97
+ <div className="py-8 text-center">
98
+ <p className="text-xs" style={{ color: "var(--text-tertiary)" }}>
99
+ Loading&hellip;
100
+ </p>
101
+ </div>
102
+ )}
103
+
104
+ {!loading && issues.length === 0 && (
105
+ <div
106
+ className="py-8 text-center"
107
+ style={{
108
+ border: "2px dashed var(--border-default)",
109
+ borderRadius: "var(--radius-md)",
110
+ minHeight: "100px",
111
+ display: "flex",
112
+ alignItems: "center",
113
+ justifyContent: "center",
114
+ }}
115
+ >
116
+ <p className="text-xs" style={{ color: "var(--text-tertiary)" }}>
117
+ No items
118
+ </p>
119
+ </div>
120
+ )}
121
+
122
+ {issues.map((issue) => (
123
+ <IssueCard
124
+ key={issue.id}
125
+ issue={issue}
126
+ onClick={() => onCardClick(issue.id)}
127
+ dimmed={isClosed}
128
+ />
129
+ ))}
130
+
131
+ {hasMore && (
132
+ <button
133
+ onClick={() => setLimit((l) => l + COLUMN_PAGE_SIZE)}
134
+ className="w-full text-xs py-2"
135
+ style={{
136
+ color: "var(--text-secondary)",
137
+ transition: "color 120ms ease",
138
+ }}
139
+ onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; }}
140
+ onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-secondary)"; }}
141
+ >
142
+ Show {remaining} more&hellip;
143
+ </button>
144
+ )}
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ export function Board() {
151
+ const { total: totalIssues } = useSubscription("all-issues", { limit: 1, offset: 0 });
152
+ const { total: totalEpics } = useSubscription("epics", { limit: 1, offset: 0 });
153
+
154
+ const navigateToDetail = (id: string) => {
155
+ window.location.hash = `#/detail/${id}`;
156
+ };
157
+
158
+ return (
159
+ <div className="p-6" style={{ background: "var(--bg-base)" }}>
160
+ <div className="mb-6">
161
+ <h1
162
+ className="text-xl font-bold"
163
+ style={{ color: "var(--text-primary)" }}
164
+ >
165
+ Board
166
+ </h1>
167
+ <p className="text-sm mt-0.5" style={{ color: "var(--text-tertiary)" }}>
168
+ {totalIssues} issues across {totalEpics} epics
169
+ </p>
170
+ </div>
171
+ <div className="flex gap-4 overflow-x-auto items-start">
172
+ <Column
173
+ title="Open"
174
+ subscriptionType="ready-issues"
175
+ statusKey="open"
176
+ onCardClick={navigateToDetail}
177
+ />
178
+ <Column
179
+ title="In Progress"
180
+ subscriptionType="in-progress-issues"
181
+ statusKey="in_progress"
182
+ onCardClick={navigateToDetail}
183
+ />
184
+ <Column
185
+ title="Blocked"
186
+ subscriptionType="blocked-issues"
187
+ statusKey="blocked"
188
+ onCardClick={navigateToDetail}
189
+ />
190
+ <Column
191
+ title="Closed"
192
+ subscriptionType="closed-issues"
193
+ statusKey="closed"
194
+ onCardClick={navigateToDetail}
195
+ isClosed
196
+ />
197
+ </div>
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,398 @@
1
+ import { useState } from "react";
2
+ import { useSubscription } from "../hooks/use-subscription";
3
+ import { useMutation } from "../hooks/use-mutation";
4
+ import { StatusBadge } from "../components/StatusBadge";
5
+ import { PriorityBadge } from "../components/PriorityBadge";
6
+ import { TypeBadge } from "../components/TypeBadge";
7
+ import { SectionEditor } from "../components/SectionEditor";
8
+ import { getInitials, getAvatarColor } from "../lib/avatar";
9
+ import type { Issue } from "../lib/types";
10
+
11
+ function MetadataCard({ label, children }: { label: string; children: React.ReactNode }) {
12
+ return (
13
+ <div
14
+ className="px-3 py-2.5 rounded-md"
15
+ style={{ border: "1px solid var(--border-subtle)" }}
16
+ >
17
+ <h3
18
+ className="font-semibold uppercase tracking-wider mb-1.5"
19
+ style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
20
+ >
21
+ {label}
22
+ </h3>
23
+ {children}
24
+ </div>
25
+ );
26
+ }
27
+
28
+ function MetadataSidebar({
29
+ issue,
30
+ onUpdate,
31
+ }: {
32
+ issue: Issue;
33
+ onUpdate: ReturnType<typeof useMutation>;
34
+ }) {
35
+ const parentId = issue.parent_id || (issue as any).parent;
36
+ const parentTitle = issue.parent_title || (issue as any).parent_title;
37
+ const parentStatus = issue.parent_status || (issue as any).parent_status;
38
+ const ts = new Date(issue.updated_at).toLocaleDateString();
39
+
40
+ return (
41
+ <aside
42
+ className="shrink-0 p-4 space-y-3 overflow-y-auto"
43
+ style={{
44
+ width: "280px",
45
+ borderLeft: "1px solid var(--border-subtle)",
46
+ background: "var(--bg-base)",
47
+ }}
48
+ >
49
+ <MetadataCard label="Status">
50
+ <select
51
+ value={issue.status}
52
+ onChange={(e) =>
53
+ onUpdate.updateStatus(
54
+ issue.id,
55
+ e.target.value as "open" | "in_progress" | "closed",
56
+ )
57
+ }
58
+ className="w-full px-2 py-1 text-sm rounded-md outline-none"
59
+ style={{
60
+ border: "1px solid var(--border-default)",
61
+ background: "var(--bg-elevated)",
62
+ color: "var(--text-primary)",
63
+ }}
64
+ >
65
+ <option value="open">Open</option>
66
+ <option value="in_progress">In Progress</option>
67
+ <option value="closed">Closed</option>
68
+ </select>
69
+ </MetadataCard>
70
+
71
+ <MetadataCard label="Priority">
72
+ <select
73
+ value={issue.priority}
74
+ onChange={(e) =>
75
+ onUpdate.updatePriority(issue.id, Number(e.target.value))
76
+ }
77
+ className="w-full px-2 py-1 text-sm rounded-md outline-none"
78
+ style={{
79
+ border: "1px solid var(--border-default)",
80
+ background: "var(--bg-elevated)",
81
+ color: "var(--text-primary)",
82
+ }}
83
+ >
84
+ {[0, 1, 2, 3, 4].map((p) => (
85
+ <option key={p} value={p}>P{p}</option>
86
+ ))}
87
+ </select>
88
+ </MetadataCard>
89
+
90
+ <MetadataCard label="Type">
91
+ <TypeBadge type={issue.issue_type} />
92
+ </MetadataCard>
93
+
94
+ <MetadataCard label="Assignee">
95
+ {issue.assignee ? (
96
+ <div className="flex items-center gap-2">
97
+ <div
98
+ className="flex items-center justify-center rounded-full text-[10px] font-bold shrink-0"
99
+ style={{
100
+ width: "24px",
101
+ height: "24px",
102
+ backgroundColor: getAvatarColor(issue.assignee),
103
+ color: "var(--text-inverse)",
104
+ }}
105
+ >
106
+ {getInitials(issue.assignee)}
107
+ </div>
108
+ <span className="text-sm" style={{ color: "var(--text-primary)" }}>{issue.assignee}</span>
109
+ </div>
110
+ ) : (
111
+ <span className="text-sm" style={{ color: "var(--text-tertiary)" }}>Unassigned</span>
112
+ )}
113
+ </MetadataCard>
114
+
115
+ <MetadataCard label="Updated">
116
+ <span className="text-sm" style={{ color: "var(--text-secondary)" }}>{ts}</span>
117
+ </MetadataCard>
118
+
119
+ {issue.labels && issue.labels.length > 0 && (
120
+ <MetadataCard label="Labels">
121
+ <div className="flex flex-wrap gap-1">
122
+ {issue.labels.map((l) => (
123
+ <span
124
+ key={l}
125
+ className="px-2 py-0.5 text-xs rounded"
126
+ style={{ background: "var(--bg-hover)", color: "var(--text-secondary)" }}
127
+ >
128
+ {l}
129
+ </span>
130
+ ))}
131
+ </div>
132
+ </MetadataCard>
133
+ )}
134
+
135
+ {/* Parent */}
136
+ {parentId && (
137
+ <MetadataCard label="Parent">
138
+ <a
139
+ href={`#/detail/${parentId}`}
140
+ className="block rounded px-1 py-1 -mx-1 transition-colors"
141
+ style={{ color: "var(--text-primary)" }}
142
+ onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
143
+ onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
144
+ >
145
+ <div className="flex items-center gap-1.5 mb-0.5">
146
+ {parentStatus && <StatusBadge status={parentStatus} />}
147
+ <span className="font-mono text-xs" style={{ color: "var(--text-tertiary)" }}>{parentId}</span>
148
+ </div>
149
+ {parentTitle && (
150
+ <p className="text-xs leading-snug pl-0.5 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
151
+ {parentTitle}
152
+ </p>
153
+ )}
154
+ </a>
155
+ </MetadataCard>
156
+ )}
157
+
158
+ {/* Blocked By */}
159
+ {(() => {
160
+ const blocksDeps = (issue.dependencies || []).filter(
161
+ (dep: any) => dep.type === "blocks" && dep.issue_id === issue.id
162
+ );
163
+ if (blocksDeps.length === 0) return null;
164
+ return (
165
+ <MetadataCard label="Blocked By">
166
+ <div className="space-y-1">
167
+ {blocksDeps.map((dep: any) => (
168
+ <a
169
+ key={dep.depends_on_id}
170
+ href={`#/detail/${dep.depends_on_id}`}
171
+ className="block text-xs font-mono px-1 py-0.5 transition-colors"
172
+ style={{ color: "var(--accent)" }}
173
+ onMouseEnter={(e) => { e.currentTarget.style.textDecoration = "underline"; }}
174
+ onMouseLeave={(e) => { e.currentTarget.style.textDecoration = "none"; }}
175
+ >
176
+ {dep.depends_on_id}
177
+ </a>
178
+ ))}
179
+ </div>
180
+ </MetadataCard>
181
+ );
182
+ })()}
183
+
184
+ {/* Children / Dependents (sidebar) */}
185
+ {issue.dependents && issue.dependents.length > 0 && (
186
+ <MetadataCard label="Children / Dependents">
187
+ <div className="space-y-1">
188
+ {issue.dependents.map((dep) => (
189
+ <a
190
+ key={dep.id}
191
+ href={`#/detail/${dep.id}`}
192
+ className="block text-xs rounded px-1 py-1.5 -mx-1 transition-colors"
193
+ onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
194
+ onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
195
+ >
196
+ <div className="flex items-center gap-1.5 mb-0.5">
197
+ <StatusBadge status={dep.status} />
198
+ <span className="font-mono" style={{ color: "var(--text-tertiary)" }}>{dep.id}</span>
199
+ </div>
200
+ {dep.title && (
201
+ <p className="text-xs leading-snug pl-0.5 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
202
+ {dep.title}
203
+ </p>
204
+ )}
205
+ </a>
206
+ ))}
207
+ </div>
208
+ </MetadataCard>
209
+ )}
210
+ </aside>
211
+ );
212
+ }
213
+
214
+ function CommentsSection({ issueId }: { issueId: string }) {
215
+ const mutations = useMutation();
216
+ const [newComment, setNewComment] = useState("");
217
+
218
+ const handleAddComment = async () => {
219
+ if (!newComment.trim()) return;
220
+ await mutations.addComment(issueId, newComment.trim());
221
+ setNewComment("");
222
+ };
223
+
224
+ return (
225
+ <div
226
+ className="rounded-lg p-4"
227
+ style={{
228
+ background: "var(--bg-elevated)",
229
+ border: "1px solid var(--border-subtle)",
230
+ boxShadow: "var(--shadow-card)",
231
+ }}
232
+ >
233
+ <h3
234
+ className="font-semibold uppercase tracking-wider mb-3"
235
+ style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
236
+ >
237
+ Comments
238
+ </h3>
239
+ <div className="flex gap-2">
240
+ <textarea
241
+ value={newComment}
242
+ onChange={(e) => setNewComment(e.target.value)}
243
+ placeholder="Add a comment... (Ctrl+Enter to submit)"
244
+ rows={3}
245
+ className="flex-1 p-2 text-sm rounded-md resize-y outline-none"
246
+ style={{
247
+ border: "1px solid var(--border-default)",
248
+ background: "var(--bg-base)",
249
+ color: "var(--text-primary)",
250
+ }}
251
+ onFocus={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; }}
252
+ onBlur={(e) => { e.currentTarget.style.borderColor = "var(--border-default)"; }}
253
+ onKeyDown={(e) => {
254
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey))
255
+ handleAddComment();
256
+ }}
257
+ />
258
+ </div>
259
+ <button
260
+ onClick={handleAddComment}
261
+ className="mt-2 px-3 py-1.5 text-sm rounded-md font-medium transition-colors"
262
+ style={{
263
+ background: "var(--bg-hover)",
264
+ color: "var(--text-primary)",
265
+ border: "1px solid var(--border-default)",
266
+ }}
267
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; }}
268
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border-default)"; }}
269
+ >
270
+ Add Comment
271
+ </button>
272
+ </div>
273
+ );
274
+ }
275
+
276
+ export function Detail({ issueId }: { issueId: string }) {
277
+ const { issues, loading } = useSubscription("issue-detail", { id: issueId });
278
+ const mutations = useMutation();
279
+ const issue = issues[0];
280
+
281
+ if (loading) return <div className="p-6" style={{ color: "var(--text-tertiary)" }}>Loading...</div>;
282
+ if (!issue)
283
+ return <div className="p-6" style={{ color: "var(--text-tertiary)" }}>Issue not found</div>;
284
+
285
+ return (
286
+ <div className="flex h-full" style={{ background: "var(--bg-base)" }}>
287
+ <div className="flex-1 overflow-y-auto p-6 space-y-5">
288
+ {/* Breadcrumbs */}
289
+ <div className="flex items-center gap-2 text-sm" style={{ color: "var(--text-tertiary)" }}>
290
+ <a
291
+ href="#/list"
292
+ className="flex items-center gap-1 transition-colors"
293
+ style={{ color: "var(--text-tertiary)" }}
294
+ onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; }}
295
+ onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-tertiary)"; }}
296
+ >
297
+ &larr; List
298
+ </a>
299
+ <span>/</span>
300
+ <span className="font-mono">{issue.id}</span>
301
+ </div>
302
+
303
+ {/* Title */}
304
+ <h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>
305
+ {issue.title}
306
+ </h1>
307
+
308
+ {/* Description */}
309
+ <SectionEditor
310
+ label="Description"
311
+ value={issue.description || ""}
312
+ onSave={(v) =>
313
+ mutations.editText({
314
+ id: issue.id,
315
+ field: "description",
316
+ value: v,
317
+ })
318
+ }
319
+ />
320
+
321
+ {/* Acceptance Criteria */}
322
+ <SectionEditor
323
+ label="Acceptance Criteria"
324
+ value={issue.acceptance || ""}
325
+ placeholder="Add acceptance criteria..."
326
+ onSave={(v) =>
327
+ mutations.editText({
328
+ id: issue.id,
329
+ field: "acceptance",
330
+ value: v,
331
+ })
332
+ }
333
+ />
334
+
335
+ {/* Notes */}
336
+ <SectionEditor
337
+ label="Notes"
338
+ value={issue.notes || ""}
339
+ placeholder="Add notes..."
340
+ onSave={(v) =>
341
+ mutations.editText({ id: issue.id, field: "notes", value: v })
342
+ }
343
+ />
344
+
345
+ {/* Design */}
346
+ <SectionEditor
347
+ label="Design"
348
+ value={issue.design || ""}
349
+ placeholder="Add design notes..."
350
+ onSave={(v) =>
351
+ mutations.editText({ id: issue.id, field: "design", value: v })
352
+ }
353
+ />
354
+
355
+ {/* Children (epics only) */}
356
+ {issue.issue_type === "epic" && issue.dependents && issue.dependents.length > 0 && (
357
+ <div
358
+ className="rounded-lg p-4"
359
+ style={{
360
+ background: "var(--bg-elevated)",
361
+ border: "1px solid var(--border-subtle)",
362
+ boxShadow: "var(--shadow-card)",
363
+ }}
364
+ >
365
+ <h3
366
+ className="font-semibold uppercase tracking-wider mb-3"
367
+ style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
368
+ >
369
+ Children ({issue.closed_children ?? 0} / {issue.total_children ?? issue.dependents.length})
370
+ </h3>
371
+ <div className="space-y-0.5">
372
+ {issue.dependents.map((child) => (
373
+ <a
374
+ key={child.id}
375
+ href={`#/detail/${child.id}`}
376
+ className="flex items-center gap-2.5 px-2 py-2 rounded-md transition-colors"
377
+ style={{ opacity: child.status === "closed" ? 0.6 : 1 }}
378
+ onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
379
+ onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
380
+ >
381
+ <StatusBadge status={child.status} />
382
+ <span className="text-xs font-mono" style={{ color: "var(--text-tertiary)" }}>{child.id}</span>
383
+ <span className="text-sm truncate" style={{ color: "var(--text-primary)" }}>{child.title}</span>
384
+ </a>
385
+ ))}
386
+ </div>
387
+ </div>
388
+ )}
389
+
390
+ {/* Comments */}
391
+ <CommentsSection issueId={issue.id} />
392
+ </div>
393
+
394
+ {/* Metadata sidebar */}
395
+ <MetadataSidebar issue={issue} onUpdate={mutations} />
396
+ </div>
397
+ );
398
+ }