@polderlabs/bizar-dash 3.0.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 (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. package/vite.config.ts +24 -0
@@ -0,0 +1,667 @@
1
+ // src/views/Plans.tsx — list + new plan + canvas (pan/zoom).
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ Map,
5
+ RefreshCw,
6
+ Plus,
7
+ ExternalLink,
8
+ MessageCircle,
9
+ Maximize2,
10
+ } from 'lucide-react';
11
+ import { Button } from '../components/Button';
12
+ import { Card, CardTitle, CardMeta } from '../components/Card';
13
+ import { EmptyState } from '../components/EmptyState';
14
+ import { Spinner } from '../components/Spinner';
15
+ import { StatusBadge } from '../components/StatusBadge';
16
+ import { useToast } from '../components/Toast';
17
+ import { api } from '../lib/api';
18
+ import {
19
+ cn,
20
+ formatRelative,
21
+ truncate,
22
+ } from '../lib/utils';
23
+ import type {
24
+ Canvas,
25
+ CanvasComment,
26
+ CanvasConnection,
27
+ CanvasElement,
28
+ Plan,
29
+ Settings,
30
+ Snapshot,
31
+ } from '../lib/types';
32
+
33
+ function planStatusKind(status: string): 'success' | 'info' | 'error' | 'accent' | 'neutral' {
34
+ switch (status) {
35
+ case 'approved':
36
+ case 'done':
37
+ return 'success';
38
+ case 'in-progress':
39
+ case 'doing':
40
+ return 'info';
41
+ case 'rejected':
42
+ return 'error';
43
+ case 'draft':
44
+ case 'queued':
45
+ default:
46
+ return 'neutral';
47
+ }
48
+ }
49
+
50
+ type Props = {
51
+ snapshot: Snapshot;
52
+ settings: Settings;
53
+ activeTab: string;
54
+ setActiveTab: (id: string) => void;
55
+ refreshSnapshot: () => Promise<void>;
56
+ };
57
+
58
+ export function Plans({ snapshot }: Props) {
59
+ const toast = useToast();
60
+ const [plans, setPlans] = useState<Plan[]>(snapshot.plans || []);
61
+ const [loading, setLoading] = useState(!snapshot.plans);
62
+ const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
63
+ const [filter, setFilter] = useState<string>('');
64
+
65
+ useEffect(() => {
66
+ if (snapshot.plans?.length) {
67
+ setPlans(snapshot.plans);
68
+ setLoading(false);
69
+ return;
70
+ }
71
+ let cancelled = false;
72
+ api
73
+ .get<{ plans: Plan[] }>('/plans')
74
+ .then((d) => {
75
+ if (!cancelled) {
76
+ setPlans(d.plans || []);
77
+ setLoading(false);
78
+ }
79
+ })
80
+ .catch((err) => {
81
+ if (!cancelled) {
82
+ setLoading(false);
83
+ toast.error(`Could not load plans: ${(err as Error).message}`);
84
+ }
85
+ });
86
+ return () => {
87
+ cancelled = true;
88
+ };
89
+ }, [snapshot.plans, toast]);
90
+
91
+ const filtered = useMemo(
92
+ () =>
93
+ filter
94
+ ? plans.filter((p) =>
95
+ (p.status || '').toLowerCase().includes(filter.toLowerCase()),
96
+ )
97
+ : plans,
98
+ [plans, filter],
99
+ );
100
+
101
+ const refresh = async () => {
102
+ try {
103
+ const d = await api.get<{ plans: Plan[] }>('/plans');
104
+ setPlans(d.plans || []);
105
+ toast.info('Plans refreshed.', 1500);
106
+ } catch (err) {
107
+ toast.error(`Refresh failed: ${(err as Error).message}`);
108
+ }
109
+ };
110
+
111
+ const onCreate = async (slug: string, title?: string) => {
112
+ try {
113
+ await api.post('/plans', { slug, title });
114
+ toast.success(`Plan "${slug}" created.`);
115
+ setSelectedSlug(slug);
116
+ await refresh();
117
+ } catch (err) {
118
+ toast.error(`Create failed: ${(err as Error).message}`);
119
+ }
120
+ };
121
+
122
+ const statuses = useMemo(
123
+ () => Array.from(new Set(plans.map((p) => p.status || 'draft'))),
124
+ [plans],
125
+ );
126
+
127
+ return (
128
+ <div className="view view-plans">
129
+ <header className="view-header">
130
+ <div className="view-header-text">
131
+ <h2 className="view-title">
132
+ <Map size={18} /> Plans ({plans.length})
133
+ </h2>
134
+ <p className="view-subtitle">
135
+ Visual plans with elements, connections, and threaded comments.
136
+ </p>
137
+ </div>
138
+ <div className="view-actions">
139
+ <select
140
+ className="select select-sm"
141
+ value={filter}
142
+ onChange={(e) => setFilter(e.target.value)}
143
+ >
144
+ <option value="">All statuses</option>
145
+ {statuses.map((s) => (
146
+ <option key={s} value={s}>
147
+ {s}
148
+ </option>
149
+ ))}
150
+ </select>
151
+ <Button variant="secondary" size="sm" onClick={refresh}>
152
+ <RefreshCw size={14} /> Refresh
153
+ </Button>
154
+ </div>
155
+ </header>
156
+
157
+ <NewPlanForm onCreate={onCreate} />
158
+
159
+ {loading ? (
160
+ <div className="view-loading">
161
+ <Spinner size="lg" />
162
+ </div>
163
+ ) : plans.length === 0 ? (
164
+ <EmptyState
165
+ icon={<Map size={32} />}
166
+ title="No plans yet"
167
+ message="Create one above to get started."
168
+ />
169
+ ) : (
170
+ <div className="plans-layout">
171
+ <div className="plans-list-col">
172
+ {filtered.map((p) => (
173
+ <PlanCard
174
+ key={p.slug}
175
+ plan={p}
176
+ selected={p.slug === selectedSlug}
177
+ onSelect={() => setSelectedSlug(p.slug)}
178
+ />
179
+ ))}
180
+ {filtered.length === 0 && (
181
+ <p className="muted text-sm">No plans match that filter.</p>
182
+ )}
183
+ </div>
184
+ <div className="plans-canvas-col">
185
+ {selectedSlug ? (
186
+ <PlanCanvas slug={selectedSlug} />
187
+ ) : (
188
+ <EmptyState
189
+ icon={<Map size={28} />}
190
+ title="Select a plan"
191
+ message="Pick a plan on the left to view its canvas."
192
+ />
193
+ )}
194
+ </div>
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ function NewPlanForm({
202
+ onCreate,
203
+ }: {
204
+ onCreate: (slug: string, title?: string) => Promise<void> | void;
205
+ }) {
206
+ const [slug, setSlug] = useState('');
207
+ const [title, setTitle] = useState('');
208
+ return (
209
+ <Card className="new-plan">
210
+ <CardTitle>
211
+ <Plus size={14} /> New plan
212
+ </CardTitle>
213
+ <CardMeta>
214
+ Slug must be lowercase, may contain hyphens, 1–64 chars.
215
+ </CardMeta>
216
+ <form
217
+ className="new-plan-form"
218
+ onSubmit={(e) => {
219
+ e.preventDefault();
220
+ if (!slug.trim()) return;
221
+ onCreate(slug.trim(), title.trim() || undefined);
222
+ setSlug('');
223
+ setTitle('');
224
+ }}
225
+ >
226
+ <input
227
+ className="input"
228
+ type="text"
229
+ placeholder="slug (e.g. dashboard-v2.6)"
230
+ pattern="[a-z0-9][a-z0-9-]{0,63}"
231
+ required
232
+ value={slug}
233
+ onChange={(e) => setSlug(e.target.value)}
234
+ />
235
+ <input
236
+ className="input"
237
+ type="text"
238
+ placeholder="Title (optional)"
239
+ value={title}
240
+ onChange={(e) => setTitle(e.target.value)}
241
+ />
242
+ <Button variant="primary" type="submit">
243
+ Create
244
+ </Button>
245
+ </form>
246
+ </Card>
247
+ );
248
+ }
249
+
250
+ function PlanCard({
251
+ plan,
252
+ selected,
253
+ onSelect,
254
+ }: {
255
+ plan: Plan;
256
+ selected: boolean;
257
+ onSelect: () => void;
258
+ }) {
259
+ const kind = planStatusKind(plan.status || 'draft');
260
+ return (
261
+ <Card
262
+ variant={selected ? 'filled' : 'elevated'}
263
+ interactive
264
+ className={cn('plan-card', selected && 'plan-card-selected')}
265
+ onClick={onSelect}
266
+ >
267
+ <div className="plan-card-head">
268
+ <div className="plan-card-title">{plan.title || plan.slug}</div>
269
+ <StatusBadge kind={kind}>{plan.status || 'draft'}</StatusBadge>
270
+ </div>
271
+ <div className="plan-card-slug mono">
272
+ {plan.slug} · {plan.source}
273
+ </div>
274
+ <div className="plan-card-meta">
275
+ {plan.elementCount != null && (
276
+ <span>{plan.elementCount} elements</span>
277
+ )}
278
+ {plan.commentCount != null && (
279
+ <span> · {plan.commentCount} comments</span>
280
+ )}
281
+ <span> · edited {formatRelative(plan.mtime)}</span>
282
+ </div>
283
+ <div className="plan-card-actions">
284
+ <Button
285
+ variant="ghost"
286
+ size="sm"
287
+ onClick={(e) => {
288
+ e.stopPropagation();
289
+ onSelect();
290
+ }}
291
+ >
292
+ View
293
+ </Button>
294
+ <Button
295
+ variant="ghost"
296
+ size="sm"
297
+ onClick={(e) => {
298
+ e.stopPropagation();
299
+ // We don't have a CLI bridge here; just nudge the user.
300
+ // eslint-disable-next-line no-alert
301
+ alert(
302
+ `Open this plan in the CLI:\n\nbizar plan open ${plan.slug}\n\nThe CLI launches the full visual editor with pan, zoom, and edit capabilities.`,
303
+ );
304
+ }}
305
+ >
306
+ <ExternalLink size={12} /> Open
307
+ </Button>
308
+ </div>
309
+ </Card>
310
+ );
311
+ }
312
+
313
+ function PlanCanvas({ slug }: { slug: string }) {
314
+ const [canvas, setCanvas] = useState<Canvas | null>(null);
315
+ const [loading, setLoading] = useState(true);
316
+ const [selectedElId, setSelectedElId] = useState<string | null>(null);
317
+
318
+ const load = async () => {
319
+ setLoading(true);
320
+ try {
321
+ const d = await api.get<{ canvas: Canvas }>(
322
+ `/plans/${encodeURIComponent(slug)}/canvas`,
323
+ );
324
+ setCanvas(d.canvas);
325
+ } catch (err) {
326
+ // eslint-disable-next-line no-console
327
+ console.warn('canvas load failed', err);
328
+ setCanvas({
329
+ title: slug,
330
+ elements: [],
331
+ connections: [],
332
+ comments: [],
333
+ viewport: { x: 0, y: 0, zoom: 1 },
334
+ });
335
+ } finally {
336
+ setLoading(false);
337
+ }
338
+ };
339
+
340
+ useEffect(() => {
341
+ setSelectedElId(null);
342
+ load();
343
+ // eslint-disable-next-line react-hooks/exhaustive-deps
344
+ }, [slug]);
345
+
346
+ if (loading || !canvas) {
347
+ return (
348
+ <div className="view-loading">
349
+ <Spinner size="lg" />
350
+ <p>Loading canvas…</p>
351
+ </div>
352
+ );
353
+ }
354
+
355
+ const visibleComments = selectedElId
356
+ ? canvas.comments.filter(
357
+ (c) => c.elementId === selectedElId || c.id === selectedElId,
358
+ )
359
+ : canvas.comments;
360
+
361
+ return (
362
+ <div className="plan-canvas">
363
+ <header className="plan-canvas-header">
364
+ <div>
365
+ <div className="plan-canvas-title">{canvas.title || slug}</div>
366
+ <div className="plan-canvas-meta">
367
+ {canvas.elements.length} element
368
+ {canvas.elements.length !== 1 ? 's' : ''} ·{' '}
369
+ {canvas.comments.length} comment
370
+ {canvas.comments.length !== 1 ? 's' : ''}
371
+ </div>
372
+ </div>
373
+ <div className="plan-canvas-actions">
374
+ <Button variant="ghost" size="sm" onClick={load} title="Refresh canvas">
375
+ <RefreshCw size={14} />
376
+ </Button>
377
+ <Button
378
+ variant="ghost"
379
+ size="sm"
380
+ title="Reset zoom"
381
+ onClick={() => {
382
+ // Force remount via key trick
383
+ setCanvas({ ...canvas, viewport: { x: 0, y: 0, zoom: 1 } });
384
+ }}
385
+ >
386
+ <Maximize2 size={14} />
387
+ </Button>
388
+ </div>
389
+ </header>
390
+
391
+ <div className="plan-canvas-area">
392
+ {canvas.elements.length === 0 ? (
393
+ <div className="plan-canvas-empty">
394
+ No elements yet. Add one via the CLI: <code>/plan add {slug} &lt;type&gt;</code>
395
+ </div>
396
+ ) : (
397
+ <CanvasViewport canvas={canvas}>
398
+ {canvas.elements.map((el) => (
399
+ <CanvasElementView
400
+ key={el.id}
401
+ element={el}
402
+ selected={selectedElId === el.id}
403
+ onClick={() =>
404
+ setSelectedElId(selectedElId === el.id ? null : el.id)
405
+ }
406
+ />
407
+ ))}
408
+ <Connections
409
+ elements={canvas.elements}
410
+ connections={canvas.connections}
411
+ />
412
+ </CanvasViewport>
413
+ )}
414
+ </div>
415
+
416
+ <footer className="plan-canvas-comments">
417
+ <h4 className="plan-canvas-comments-title">
418
+ <MessageCircle size={14} /> Comments ({visibleComments.length}
419
+ {selectedElId ? ' for selected' : ''})
420
+ </h4>
421
+ {visibleComments.length === 0 ? (
422
+ <p className="muted text-sm">
423
+ {selectedElId
424
+ ? 'No comments on this element yet.'
425
+ : 'No comments yet.'}
426
+ </p>
427
+ ) : (
428
+ <ul className="comment-list">
429
+ {visibleComments.map((c) => (
430
+ <CommentItem key={c.id} comment={c} />
431
+ ))}
432
+ </ul>
433
+ )}
434
+ </footer>
435
+ </div>
436
+ );
437
+ }
438
+
439
+ function CanvasViewport({
440
+ canvas,
441
+ children,
442
+ }: {
443
+ canvas: Canvas;
444
+ children: React.ReactNode;
445
+ }) {
446
+ const rootRef = useRef<HTMLDivElement>(null);
447
+ const innerRef = useRef<HTMLDivElement>(null);
448
+ const stateRef = useRef({
449
+ panning: false,
450
+ startX: 0,
451
+ startY: 0,
452
+ ox: canvas.viewport.x || 0,
453
+ oy: canvas.viewport.y || 0,
454
+ scale: canvas.viewport.zoom || 1,
455
+ });
456
+
457
+ useEffect(() => {
458
+ const root = rootRef.current;
459
+ const inner = innerRef.current;
460
+ if (!root || !inner) return;
461
+ const apply = () => {
462
+ const s = stateRef.current;
463
+ inner.style.transform = `translate(${s.ox}px, ${s.oy}px) scale(${s.scale})`;
464
+ };
465
+ apply();
466
+
467
+ const onMouseDown = (e: MouseEvent) => {
468
+ const t = e.target as HTMLElement;
469
+ if (
470
+ t !== root &&
471
+ !t.classList.contains('canvas-grid-bg') &&
472
+ !t.classList.contains('canvas-inner')
473
+ )
474
+ return;
475
+ stateRef.current.panning = true;
476
+ stateRef.current.startX = e.clientX - stateRef.current.ox;
477
+ stateRef.current.startY = e.clientY - stateRef.current.oy;
478
+ root.style.cursor = 'grabbing';
479
+ };
480
+ const onMouseMove = (e: MouseEvent) => {
481
+ if (!stateRef.current.panning) return;
482
+ stateRef.current.ox = e.clientX - stateRef.current.startX;
483
+ stateRef.current.oy = e.clientY - stateRef.current.startY;
484
+ apply();
485
+ };
486
+ const onMouseUp = () => {
487
+ if (!stateRef.current.panning) return;
488
+ stateRef.current.panning = false;
489
+ root.style.cursor = '';
490
+ };
491
+ const onWheel = (e: WheelEvent) => {
492
+ e.preventDefault();
493
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
494
+ const next = Math.min(
495
+ Math.max(stateRef.current.scale * delta, 0.2),
496
+ 3,
497
+ );
498
+ stateRef.current.scale = next;
499
+ apply();
500
+ };
501
+ root.addEventListener('mousedown', onMouseDown);
502
+ window.addEventListener('mousemove', onMouseMove);
503
+ window.addEventListener('mouseup', onMouseUp);
504
+ root.addEventListener('wheel', onWheel, { passive: false });
505
+ return () => {
506
+ root.removeEventListener('mousedown', onMouseDown);
507
+ window.removeEventListener('mousemove', onMouseMove);
508
+ window.removeEventListener('mouseup', onMouseUp);
509
+ root.removeEventListener('wheel', onWheel);
510
+ };
511
+ }, [canvas.elements.length, canvas.connections.length]);
512
+
513
+ return (
514
+ <div className="canvas-root" ref={rootRef}>
515
+ <div className="canvas-grid-bg" />
516
+ <div className="canvas-inner" ref={innerRef}>
517
+ {children}
518
+ </div>
519
+ <div className="canvas-hint">
520
+ Pan: drag · Zoom: scroll
521
+ </div>
522
+ </div>
523
+ );
524
+ }
525
+
526
+ function CanvasElementView({
527
+ element,
528
+ selected,
529
+ onClick,
530
+ }: {
531
+ element: CanvasElement;
532
+ selected: boolean;
533
+ onClick: () => void;
534
+ }) {
535
+ const x = element.x || 0;
536
+ const y = element.y || 0;
537
+ const w = element.width || 240;
538
+ const h = element.height || 160;
539
+ return (
540
+ <div
541
+ className={cn('canvas-element', selected && 'canvas-element-selected')}
542
+ style={{
543
+ left: x,
544
+ top: y,
545
+ width: w,
546
+ minHeight: h,
547
+ }}
548
+ onClick={(e) => {
549
+ e.stopPropagation();
550
+ onClick();
551
+ }}
552
+ >
553
+ <div className="canvas-element-type">{element.type || 'text'}</div>
554
+ <div className="canvas-element-title">{element.title || 'Untitled'}</div>
555
+ {element.content && (
556
+ <div className="canvas-element-content">
557
+ {truncate(element.content, 200)}
558
+ </div>
559
+ )}
560
+ </div>
561
+ );
562
+ }
563
+
564
+ function Connections({
565
+ elements,
566
+ connections,
567
+ }: {
568
+ elements: CanvasElement[];
569
+ connections: CanvasConnection[];
570
+ }) {
571
+ if (!connections.length) return null;
572
+ // Compute world bounding box so the SVG covers all elements regardless of pan/zoom.
573
+ const bbox = elements.reduce(
574
+ (acc, el) => {
575
+ const ex = el.x || 0;
576
+ const ey = el.y || 0;
577
+ const ex2 = ex + (el.width || 240);
578
+ const ey2 = ey + (el.height || 160);
579
+ return {
580
+ minX: Math.min(acc.minX, ex),
581
+ minY: Math.min(acc.minY, ey),
582
+ maxX: Math.max(acc.maxX, ex2),
583
+ maxY: Math.max(acc.maxY, ey2),
584
+ };
585
+ },
586
+ { minX: 0, minY: 0, maxX: 0, maxY: 0 },
587
+ );
588
+ const pad = 80;
589
+ const w = bbox.maxX - bbox.minX + pad * 2;
590
+ const h = bbox.maxY - bbox.minY + pad * 2;
591
+ const ox = -bbox.minX + pad;
592
+ const oy = -bbox.minY + pad;
593
+ return (
594
+ <svg
595
+ className="canvas-connections"
596
+ width={w}
597
+ height={h}
598
+ style={{
599
+ left: bbox.minX - pad,
600
+ top: bbox.minY - pad,
601
+ }}
602
+ >
603
+ <defs>
604
+ <marker
605
+ id="arrow"
606
+ viewBox="0 0 10 10"
607
+ refX="9"
608
+ refY="5"
609
+ markerWidth="6"
610
+ markerHeight="6"
611
+ orient="auto-start-reverse"
612
+ >
613
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--accent)" />
614
+ </marker>
615
+ </defs>
616
+ {connections.map((c) => {
617
+ const from = elements.find(
618
+ (e) => e.id === c.fromElementId || e.id === c.from,
619
+ );
620
+ const to = elements.find(
621
+ (e) => e.id === c.toElementId || e.id === c.to,
622
+ );
623
+ if (!from || !to) return null;
624
+ const x1 = ox + (from.x || 0) + (from.width || 240) / 2;
625
+ const y1 = oy + (from.y || 0) + (from.height || 160);
626
+ const x2 = ox + (to.x || 0) + (to.width || 240) / 2;
627
+ const y2 = oy + (to.y || 0);
628
+ return (
629
+ <line
630
+ key={c.id}
631
+ x1={x1}
632
+ y1={y1}
633
+ x2={x2}
634
+ y2={y2}
635
+ stroke="var(--accent)"
636
+ strokeWidth={1.5}
637
+ markerEnd="url(#arrow)"
638
+ />
639
+ );
640
+ })}
641
+ </svg>
642
+ );
643
+ }
644
+
645
+ function CommentItem({ comment }: { comment: CanvasComment }) {
646
+ return (
647
+ <li className="comment-item">
648
+ <div className="comment-head">
649
+ <span className="comment-author mono">{comment.author}</span>
650
+ <span className="comment-time tabular-nums muted">
651
+ {formatRelative(comment.created)}
652
+ </span>
653
+ </div>
654
+ <div className="comment-text">{comment.text}</div>
655
+ {comment.thread && comment.thread.length > 0 && (
656
+ <div className="comment-thread">
657
+ {comment.thread.map((r, i) => (
658
+ <div key={i} className="comment-reply">
659
+ <span className="comment-author mono">{r.author}</span>
660
+ <span className="comment-reply-text">{r.text}</span>
661
+ </div>
662
+ ))}
663
+ </div>
664
+ )}
665
+ </li>
666
+ );
667
+ }