@hanzlaa/rcode 4.1.2 → 4.3.1

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 (70) hide show
  1. package/cli/install.js +176 -13
  2. package/cli/lib/config.cjs +4 -2
  3. package/cli/lib/fsutil.cjs +13 -2
  4. package/cli/lib/homedir.cjs +21 -0
  5. package/cli/lib/schemas.cjs +6 -1
  6. package/cli/nuke.js +13 -8
  7. package/cli/postinstall.js +14 -4
  8. package/cli/rcode-slash-router.cjs +118 -0
  9. package/cli/uninstall.js +59 -1
  10. package/cli/update.js +10 -5
  11. package/dist/rcode.js +234 -230
  12. package/package.json +1 -1
  13. package/rcode/references/auto-init-guard.md +2 -2
  14. package/rcode/references/output-format.md +5 -5
  15. package/rcode/skills/actions/2-plan/rcode-create-milestone/steps/step-10-complete.md +1 -1
  16. package/server/dashboard.js +33 -13
  17. package/server/lib/api.js +62 -4
  18. package/server/lib/html/client/agents-data.js +22 -18
  19. package/server/lib/html/client/app.js +3 -0
  20. package/server/lib/html/client/components/AgentCard.js +127 -0
  21. package/server/lib/html/client/components/App.js +104 -39
  22. package/server/lib/html/client/components/CommandPalette.js +133 -0
  23. package/server/lib/html/client/components/FileReader.js +116 -0
  24. package/server/lib/html/client/components/FilterChips.js +94 -0
  25. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  26. package/server/lib/html/client/components/OrchPanel.js +80 -52
  27. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  28. package/server/lib/html/client/components/RejectDialog.js +78 -0
  29. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  30. package/server/lib/html/client/components/Sidebar.js +106 -61
  31. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  32. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  33. package/server/lib/html/client/components/Topbar.js +86 -39
  34. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  35. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  36. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  37. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  38. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  39. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  40. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  41. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  42. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  43. package/server/lib/html/client/components/shared.js +47 -11
  44. package/server/lib/html/client/filter-state.js +72 -0
  45. package/server/lib/html/client/icons-client.js +7 -0
  46. package/server/lib/html/client/notify.js +75 -0
  47. package/server/lib/html/client/orchestrator.js +168 -41
  48. package/server/lib/html/client/preact.js +13 -8
  49. package/server/lib/html/client/store.js +70 -6
  50. package/server/lib/html/client/util.js +78 -0
  51. package/server/lib/html/client/vendor/htm.js +1 -0
  52. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  53. package/server/lib/html/client/vendor/preact.js +2 -0
  54. package/server/lib/html/client/views/AgentsView.js +144 -51
  55. package/server/lib/html/client/views/FilesView.js +20 -103
  56. package/server/lib/html/client/views/KanbanView.js +40 -21
  57. package/server/lib/html/client/views/MemoryView.js +26 -9
  58. package/server/lib/html/client/views/MilestonesView.js +4 -4
  59. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  60. package/server/lib/html/client/views/OverviewView.js +47 -239
  61. package/server/lib/html/client/views/PhasesView.js +50 -6
  62. package/server/lib/html/client/views/RoadmapView.js +6 -3
  63. package/server/lib/html/client/views/SprintsView.js +50 -6
  64. package/server/lib/html/client/views/TasksView.js +4 -3
  65. package/server/lib/html/client.js +21 -4
  66. package/server/lib/html/css.js +2761 -8
  67. package/server/lib/html/icons.js +7 -0
  68. package/server/lib/html/shell.js +10 -3
  69. package/server/lib/scanner.js +376 -39
  70. package/server/orchestrator.js +346 -7
@@ -0,0 +1,117 @@
1
+ /**
2
+ * NotifyCenter — blocked-session notification UI.
3
+ *
4
+ * Two components, both driven by store state written by notify.js:
5
+ *
6
+ * BlockedToasts — persistent corner toasts, one per blocked-session alert
7
+ * (store.blockedAlerts). Clicking a toast opens that session's terminal
8
+ * panel; the ✕ dismisses without opening. Mounted once in App.js.
9
+ *
10
+ * BlockedBell — topbar bell with a count of currently-blocked sessions and
11
+ * a dropdown listing them (click an entry → open its terminal). Mounted
12
+ * in Topbar.js. Renders nothing special when no session is blocked.
13
+ *
14
+ * No inline style= — all styling via .nb-* classes in css.js.
15
+ */
16
+
17
+ import { html, useState, useEffect, useRef } from '../preact.js';
18
+ import { useStore } from '../store.js';
19
+ import { openTermPanel } from '../orchestrator.js';
20
+ import { dismissBlockedAlert } from '../notify.js';
21
+ import { Icon } from '../icons-client.js';
22
+
23
+ // ── Persistent blocked toasts ─────────────────────────────────────────────────
24
+
25
+ export function BlockedToasts() {
26
+ const blockedAlerts = useStore(s => s.blockedAlerts);
27
+ const alerts = blockedAlerts || [];
28
+ if (alerts.length === 0) return null;
29
+
30
+ function open(storyId) {
31
+ dismissBlockedAlert(storyId);
32
+ openTermPanel(storyId, storyId);
33
+ }
34
+
35
+ return html`
36
+ <div class="nb-toasts" role="status" aria-live="polite">
37
+ ${alerts.map(a => html`
38
+ <div key=${a.storyId} class="nb-toast"
39
+ role="button" tabindex="0"
40
+ title=${'Open terminal for ' + a.storyId}
41
+ onClick=${() => open(a.storyId)}
42
+ onKeyDown=${e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(a.storyId); } }}>
43
+ <span class="nb-toast-dot" aria-hidden="true"></span>
44
+ <span class="nb-toast-text">
45
+ Agent waiting for input — ${a.storyId}
46
+ ${a.cmd ? html`<span class="nb-toast-cmd">${a.cmd}</span>` : null}
47
+ </span>
48
+ <button class="nb-toast-dismiss" aria-label="Dismiss"
49
+ onClick=${e => { e.stopPropagation(); dismissBlockedAlert(a.storyId); }}>
50
+ <${Icon} name="x" size=${12}/>
51
+ </button>
52
+ </div>
53
+ `)}
54
+ </div>
55
+ `;
56
+ }
57
+
58
+ // ── Topbar bell ───────────────────────────────────────────────────────────────
59
+
60
+ export function BlockedBell() {
61
+ const activeSessions = useStore(s => s.activeSessions);
62
+ const [open, setOpen] = useState(false);
63
+ const rootRef = useRef(null);
64
+ const blocked = (activeSessions || []).filter(s => s.status === 'blocked');
65
+
66
+ // Close the dropdown on any outside click.
67
+ useEffect(() => {
68
+ if (!open) return;
69
+ function onDocClick(e) {
70
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
71
+ }
72
+ document.addEventListener('mousedown', onDocClick);
73
+ return () => document.removeEventListener('mousedown', onDocClick);
74
+ }, [open]);
75
+
76
+ // Nothing blocked → drop a stale open dropdown.
77
+ useEffect(() => {
78
+ if (blocked.length === 0 && open) setOpen(false);
79
+ }, [blocked.length]);
80
+
81
+ function openSession(storyId) {
82
+ setOpen(false);
83
+ dismissBlockedAlert(storyId);
84
+ openTermPanel(storyId, storyId);
85
+ }
86
+
87
+ return html`
88
+ <div class="nb-bell-wrap" ref=${rootRef}>
89
+ <button
90
+ class=${'tb-btn tb-btn--icon nb-bell' + (blocked.length ? ' nb-bell--alert' : '')}
91
+ type="button"
92
+ title=${blocked.length
93
+ ? blocked.length + ' session' + (blocked.length === 1 ? '' : 's') + ' waiting for input'
94
+ : 'No sessions waiting for input'}
95
+ aria-label="Blocked session notifications"
96
+ aria-expanded=${open}
97
+ onClick=${() => { if (blocked.length) setOpen(o => !o); }}>
98
+ <${Icon} name="bell" size=${15}/>
99
+ ${blocked.length ? html`<span class="nb-bell-count">${blocked.length}</span>` : null}
100
+ </button>
101
+ ${open && blocked.length ? html`
102
+ <div class="nb-bell-dropdown" role="menu">
103
+ <div class="nb-bell-title">Waiting for input</div>
104
+ ${blocked.map(s => html`
105
+ <button key=${s.storyId} class="nb-bell-item" role="menuitem"
106
+ title=${'Open terminal for ' + s.storyId}
107
+ onClick=${() => openSession(s.storyId)}>
108
+ <span class="term-status-dot blocked" aria-hidden="true"></span>
109
+ <span class="nb-bell-item-id">${s.storyId}</span>
110
+ <span class="nb-bell-item-cmd">${s.cmd || ''}</span>
111
+ </button>
112
+ `)}
113
+ </div>
114
+ ` : null}
115
+ </div>
116
+ `;
117
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
17
17
  import { useStore, setState } from '../store.js';
18
- import { orchToken, stopSession, cleanSessions, ORCH_HTTP } from '../orchestrator.js';
18
+ import { orchToken, stopSession, cleanSessions, ORCH_WS } from '../orchestrator.js';
19
19
  import { showToast } from './shared.js';
20
20
  import { Icon } from '../icons-client.js';
21
21
 
@@ -25,7 +25,23 @@ function mkSession(title) {
25
25
  return { title: title || 'Session', lines: [], fileOps: [], status: 'starting' };
26
26
  }
27
27
 
28
- // ── SSE streams (module-scoped — one EventSource per storyId) ────────────────
28
+ /**
29
+ * Strip ANSI escape sequences (OSC, CSI, other ESC) and carriage returns so
30
+ * raw PTY output renders as readable plain-text log lines. The full-fidelity
31
+ * terminal lives in XtermPanel; this panel is a lightweight log view.
32
+ */
33
+ function stripAnsi(s) {
34
+ return String(s)
35
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
36
+ .replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, '')
37
+ .replace(/\x1b[@-_]/g, '')
38
+ .replace(/\r/g, '');
39
+ }
40
+
41
+ // ── Live streams (module-scoped — one WebSocket per storyId) ─────────────────
42
+ // The orchestrator's data plane is the PTY WebSocket at /ws/<storyId>
43
+ // (wire frames: {t:'o',d} output, {t:'hist',d} scrollback, {t:'s',s} status).
44
+ // The previous SSE endpoint (/api/stream/<id>) no longer exists on the server.
29
45
  const _streams = {};
30
46
 
31
47
  function closeStream(storyId) {
@@ -35,9 +51,11 @@ function closeStream(storyId) {
35
51
  // ── Component ─────────────────────────────────────────────────────────────────
36
52
 
37
53
  export function OrchPanel() {
38
- const { orchPanel } = useStore();
54
+ const { orchPanel, activeSessions } = useStore();
39
55
  const open = !!(orchPanel && orchPanel.open);
40
56
  const reqStory = orchPanel && orchPanel.storyId;
57
+ // Sessions reported by the orchestrator API (4s poll → store.activeSessions).
58
+ const apiSessions = activeSessions || [];
41
59
 
42
60
  // sessionsMap: { [storyId]: { title, lines, fileOps, status } }
43
61
  const [sessionsMap, setSessionsMap] = useState({});
@@ -72,11 +90,15 @@ export function OrchPanel() {
72
90
 
73
91
  function connectStream(storyId) {
74
92
  const tok = orchToken();
75
- const es = new EventSource(
76
- ORCH_HTTP + '/api/stream/' + encodeURIComponent(storyId) +
77
- '?token=' + encodeURIComponent(tok || '')
93
+ if (!tok) {
94
+ showToast('No orchestrator token restart the dashboard');
95
+ return;
96
+ }
97
+ const ws = new WebSocket(
98
+ ORCH_WS + '/ws/' + encodeURIComponent(storyId) +
99
+ '?token=' + encodeURIComponent(tok)
78
100
  );
79
- _streams[storyId] = es;
101
+ _streams[storyId] = ws;
80
102
 
81
103
  function appendLine(storyId, line, cls) {
82
104
  setSessionsMap(prev => {
@@ -89,32 +111,24 @@ export function OrchPanel() {
89
111
  });
90
112
  }
91
113
 
114
+ // Append a multi-line output chunk: the first segment continues the last
115
+ // streamed line, each newline starts a fresh line element.
92
116
  function appendChunk(storyId, chunk) {
93
117
  setSessionsMap(prev => {
94
118
  const sess = prev[storyId];
95
119
  if (!sess) return prev;
96
- const lines = sess.lines;
120
+ const parts = chunk.split('\n');
121
+ const lines = [...sess.lines];
97
122
  const last = lines[lines.length - 1];
98
123
  if (last && last.cls === 'kt-stream') {
99
- const updated = [...lines];
100
- updated[updated.length - 1] = { ...last, text: last.text + chunk };
101
- return { ...prev, [storyId]: { ...sess, lines: updated } };
124
+ lines[lines.length - 1] = { ...last, text: last.text + parts[0] };
125
+ } else if (parts[0]) {
126
+ lines.push({ text: parts[0], cls: 'kt-stream' });
102
127
  }
103
- return {
104
- ...prev,
105
- [storyId]: { ...sess, lines: [...lines, { text: chunk, cls: 'kt-stream' }] },
106
- };
107
- });
108
- }
109
-
110
- function appendFileOp(storyId, fileOp) {
111
- setSessionsMap(prev => {
112
- const sess = prev[storyId];
113
- if (!sess) return prev;
114
- return {
115
- ...prev,
116
- [storyId]: { ...sess, fileOps: [...sess.fileOps, fileOp] },
117
- };
128
+ for (let i = 1; i < parts.length; i++) {
129
+ lines.push({ text: parts[i], cls: 'kt-stream' });
130
+ }
131
+ return { ...prev, [storyId]: { ...sess, lines } };
118
132
  });
119
133
  }
120
134
 
@@ -126,41 +140,39 @@ export function OrchPanel() {
126
140
  });
127
141
  }
128
142
 
129
- es.onmessage = e => {
130
- try {
131
- const d = JSON.parse(e.data);
132
- if (d.chunk) appendChunk(storyId, d.chunk);
133
- if (d.line) {
134
- let cls = 'kt-line';
135
- const l = d.line;
136
- if (l.startsWith('')) cls += ' tool';
137
- else if (l.startsWith('⚠')) cls += ' warn';
138
- else if (l.startsWith('')) cls += ' err';
139
- else if (l.startsWith('')) cls += ' done-line';
140
- else if (l.startsWith('') || l.startsWith('') || l.startsWith('■')) cls += ' meta';
141
- appendLine(storyId, l, cls);
142
- }
143
- if (d.fileOp) appendFileOp(storyId, d.fileOp);
144
- if (d.status) {
145
- setTabStatus(storyId, d.status);
146
- if (d.status === 'done') appendLine(storyId, '✅ Done', 'kt-line done-line');
147
- if (d.status === 'stopped') appendLine(storyId, '■ Stopped', 'kt-line meta');
148
- if (d.status !== 'running') { closeStream(storyId); }
149
- }
150
- } catch { /* ignore parse errors */ }
143
+ ws.onmessage = e => {
144
+ let d;
145
+ try { d = JSON.parse(e.data); } catch { return; }
146
+ if (!d) return;
147
+ if (d.t === 'o' || d.t === 'hist') {
148
+ const text = stripAnsi(d.d);
149
+ if (text) appendChunk(storyId, text);
150
+ } else if (d.t === 's') {
151
+ setTabStatus(storyId, d.s);
152
+ if (d.s === 'done') appendLine(storyId, '✅ Done', 'kt-line done-line');
153
+ if (d.s === 'stopped') appendLine(storyId, '■ Stopped', 'kt-line meta');
154
+ if (d.s !== 'running' && d.s !== 'starting') closeStream(storyId);
155
+ }
151
156
  };
152
- es.onerror = () => {
157
+ ws.onerror = () => {
153
158
  setTabStatus(storyId, 'error');
154
159
  closeStream(storyId);
155
160
  };
161
+ ws.onclose = () => {
162
+ if (_streams[storyId] === ws) delete _streams[storyId];
163
+ };
156
164
  }
157
165
 
158
166
  const handleClose = useCallback(() => {
159
167
  setState({ orchPanel: null });
160
168
  }, []);
161
169
 
170
+ // Open (or focus) a session tab and attach its live stream. Used both for
171
+ // locally-opened tabs and for sessions discovered via the orchestrator API.
162
172
  function handleTabClick(storyId) {
173
+ setSessionsMap(prev => prev[storyId] ? prev : { ...prev, [storyId]: mkSession(storyId) });
163
174
  setActiveTab(storyId);
175
+ if (!_streams[storyId]) connectStream(storyId);
164
176
  }
165
177
 
166
178
  function handleTabClose(e, storyId) {
@@ -197,9 +209,14 @@ export function OrchPanel() {
197
209
  }
198
210
 
199
211
  const tabs = Object.keys(sessionsMap);
212
+ // Sessions the orchestrator knows about that aren't open as tabs yet —
213
+ // rendered as clickable entries so the panel reflects the API, not just
214
+ // locally-opened tabs.
215
+ const apiOnly = apiSessions.filter(s => s.storyId && !sessionsMap[s.storyId]);
200
216
  const activeSess = activeTab ? sessionsMap[activeTab] : null;
201
217
  const hasStream = activeTab && !!_streams[activeTab];
202
- const runningCount = Object.keys(_streams).length;
218
+ // 'blocked' = live PTY waiting for input — still a live session.
219
+ const runningCount = apiSessions.filter(s => s.status === 'running' || s.status === 'blocked').length;
203
220
 
204
221
  const panelCls = 'orch-panel' + (open ? ' open' : '');
205
222
 
@@ -213,9 +230,9 @@ export function OrchPanel() {
213
230
  <button class="orch-panel-close" onClick=${handleClose} title="Close" aria-label="Close panel"><${Icon} name="x" size=${14}/></button>
214
231
  </div>
215
232
 
216
- <!-- Tab strip -->
233
+ <!-- Tab strip — open tabs first, then API-known sessions not yet opened -->
217
234
  <div class="orch-tabs">
218
- ${tabs.length === 0 ? html`
235
+ ${tabs.length === 0 && apiOnly.length === 0 ? html`
219
236
  <div class="orch-term-empty orch-empty-tab">
220
237
  No active sessions
221
238
  </div>
@@ -239,6 +256,17 @@ export function OrchPanel() {
239
256
  </button>
240
257
  `;
241
258
  })}
259
+ ${apiOnly.map(s => html`
260
+ <button
261
+ key=${s.storyId}
262
+ class="orch-tab"
263
+ onClick=${() => handleTabClick(s.storyId)}
264
+ title=${'Attach to ' + s.storyId}
265
+ >
266
+ <span class=${'tab-status-dot ' + (s.status || 'starting')}></span>
267
+ <span>${s.storyId.slice(0, 20)}</span>
268
+ </button>
269
+ `)}
242
270
  </div>
243
271
 
244
272
  <!-- Terminal body -->
@@ -0,0 +1,300 @@
1
+ /**
2
+ * PhaseGraph — phase dependency graph, hand-rolled inline SVG (no libs).
3
+ *
4
+ * Layout: layered DAG. Each phase gets a layer = 0 when it has no resolvable
5
+ * dependencies, else 1 + max(layer of each dependency) — roots on the left,
6
+ * dependents to the right. A layer with more than MAX_ROWS phases wraps into
7
+ * adjacent sub-columns so 34+ phase milestones stay readable instead of
8
+ * producing one mile-high column. Layering is iterative with a pass cap and
9
+ * monotonic updates, so a dependency cycle in bad data cannot loop forever.
10
+ *
11
+ * Honest states:
12
+ * - no phases → friendly empty message
13
+ * - no cross-phase deps → simple wrapped flow row of chips (no fake DAG)
14
+ * - real deps → layered DAG with curved edges + arrowheads
15
+ *
16
+ * Interactions: hover highlights a node's ancestors + descendants and dims
17
+ * the rest; click navigates to the phase; an SVG-rendered tooltip shows the
18
+ * full name, sprint count and dependency list. The SVG sits in a horizontal
19
+ * scroll container for wide graphs.
20
+ */
21
+
22
+ import { html, useState, useMemo } from '../preact.js';
23
+ import { Icon } from '../icons-client.js';
24
+
25
+ const NODE_W = 150, NODE_H = 44; // capped chip size — names truncate to fit
26
+ const COL_GAP = 56, ROW_GAP = 14;
27
+ const PAD = 16;
28
+ const MAX_ROWS = 8; // rows per column before a layer wraps
29
+
30
+ /** Collapse the many status spellings into the four visual kinds. */
31
+ export function statusKind(status) {
32
+ const s = String(status || '');
33
+ if (/blocked/i.test(s)) return 'blocked';
34
+ if (/complete|done/i.test(s)) return 'done';
35
+ if (/active|executing|in_progress|progress/i.test(s)) return 'active';
36
+ return 'todo';
37
+ }
38
+
39
+ /** Dependencies that resolve to a known phase (self-references dropped). */
40
+ function resolvedDeps(p, known) {
41
+ return (p.dependsOn || []).map(String)
42
+ .filter(d => known.has(d) && d !== String(p.id));
43
+ }
44
+
45
+ /**
46
+ * Topological layering. Returns Map(id → layer). Monotonic updates + a pass
47
+ * cap of phases.length guarantee termination even on cyclic input.
48
+ */
49
+ export function computeLayers(phases) {
50
+ const known = new Set(phases.map(p => String(p.id)));
51
+ const layers = new Map(phases.map(p => [String(p.id), 0]));
52
+ for (let pass = 0; pass < phases.length; pass++) {
53
+ let changed = false;
54
+ for (const p of phases) {
55
+ const deps = resolvedDeps(p, known);
56
+ if (!deps.length) continue;
57
+ const next = 1 + Math.max(...deps.map(d => layers.get(d) || 0));
58
+ if (next > (layers.get(String(p.id)) || 0)) {
59
+ layers.set(String(p.id), next);
60
+ changed = true;
61
+ }
62
+ }
63
+ if (!changed) break;
64
+ }
65
+ return layers;
66
+ }
67
+
68
+ /** parents/children adjacency over resolvable dependencies. */
69
+ function buildAdjacency(phases) {
70
+ const known = new Set(phases.map(p => String(p.id)));
71
+ const parents = new Map(), children = new Map();
72
+ for (const p of phases) {
73
+ const id = String(p.id);
74
+ const deps = resolvedDeps(p, known);
75
+ parents.set(id, deps);
76
+ for (const d of deps) {
77
+ if (!children.has(d)) children.set(d, []);
78
+ children.get(d).push(id);
79
+ }
80
+ }
81
+ return { parents, children };
82
+ }
83
+
84
+ /** BFS one direction (parents = ancestors, children = descendants). */
85
+ function reach(start, adj) {
86
+ const seen = new Set();
87
+ const queue = [start];
88
+ while (queue.length) {
89
+ const cur = queue.shift();
90
+ for (const next of adj.get(cur) || []) {
91
+ if (!seen.has(next)) { seen.add(next); queue.push(next); }
92
+ }
93
+ }
94
+ return seen;
95
+ }
96
+
97
+ /** Hovered node + every ancestor and descendant of it. */
98
+ function relatedSet(id, { parents, children }) {
99
+ const set = new Set([id]);
100
+ for (const a of reach(id, parents)) set.add(a);
101
+ for (const d of reach(id, children)) set.add(d);
102
+ return set;
103
+ }
104
+
105
+ function truncate(text, max) {
106
+ const s = String(text || '');
107
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
108
+ }
109
+
110
+ function goToPhase(id) { location.hash = 'phases/' + id; }
111
+
112
+ /**
113
+ * Pixel layout for the DAG mode. Layers become columns left→right; a layer
114
+ * larger than MAX_ROWS wraps into adjacent sub-columns. All values derive
115
+ * from integer counts, so positions are always finite — no NaN, no overlap.
116
+ */
117
+ function layout(phases, layers) {
118
+ const byLayer = new Map();
119
+ for (const p of phases) {
120
+ const l = layers.get(String(p.id)) || 0;
121
+ if (!byLayer.has(l)) byLayer.set(l, []);
122
+ byLayer.get(l).push(p);
123
+ }
124
+ const order = [...byLayer.keys()].sort((a, b) => a - b);
125
+
126
+ const pos = new Map();
127
+ let col = 0, maxRowsUsed = 1;
128
+ for (const l of order) {
129
+ const group = byLayer.get(l);
130
+ const subCols = Math.max(1, Math.ceil(group.length / MAX_ROWS));
131
+ const perCol = Math.ceil(group.length / subCols);
132
+ group.forEach((p, i) => {
133
+ const sub = Math.floor(i / perCol);
134
+ const row = i % perCol;
135
+ maxRowsUsed = Math.max(maxRowsUsed, row + 1);
136
+ pos.set(String(p.id), {
137
+ x: PAD + (col + sub) * (NODE_W + COL_GAP),
138
+ y: PAD + row * (NODE_H + ROW_GAP),
139
+ });
140
+ });
141
+ col += subCols;
142
+ }
143
+ const width = PAD * 2 + col * NODE_W + Math.max(0, col - 1) * COL_GAP;
144
+ const height = PAD * 2 + maxRowsUsed * NODE_H + (maxRowsUsed - 1) * ROW_GAP;
145
+ return { pos, width, height };
146
+ }
147
+
148
+ /** Tooltip box rendered inside the SVG, clamped to stay within the canvas. */
149
+ function Tooltip({ phase, nodePos, canvasW, canvasH }) {
150
+ const name = truncate(phase.name, 48);
151
+ const sprints = (phase.sprints || []).length;
152
+ const deps = (phase.dependsOn || []).map(String);
153
+ const lines = [
154
+ name,
155
+ sprints + (sprints === 1 ? ' sprint' : ' sprints'),
156
+ deps.length ? 'Needs: ' + deps.map(d => 'P' + d).join(', ') : 'No dependencies',
157
+ ];
158
+ const w = Math.min(330, Math.max(150, Math.max(...lines.map(l => l.length)) * 6.2 + 24));
159
+ const h = lines.length * 15 + 14;
160
+ const x = Math.max(4, Math.min(nodePos.x, canvasW - w - 4));
161
+ let y = nodePos.y + NODE_H + 8;
162
+ if (y + h > canvasH - 4) y = Math.max(4, nodePos.y - h - 8);
163
+ return html`
164
+ <g class="pg-tip">
165
+ <rect x=${x} y=${y} width=${w} height=${h} rx="6"/>
166
+ ${lines.map((line, i) => html`
167
+ <text key=${i} x=${x + 12} y=${y + 20 + i * 15}
168
+ class=${i === 0 ? 'pg-tip-title' : 'pg-tip-line'}>${line}</text>
169
+ `)}
170
+ </g>
171
+ `;
172
+ }
173
+
174
+ /** Wrapped flow row of chips — the honest no-dependencies presentation. */
175
+ function FlowRow({ phases }) {
176
+ return html`
177
+ <div class="pg-flow">
178
+ ${phases.map(p => {
179
+ const kind = statusKind(p.status);
180
+ const sprints = (p.sprints || []).length;
181
+ return html`
182
+ <button key=${p.id} type="button"
183
+ class=${'pg-chip pg-' + kind}
184
+ title=${(p.name || '') + ' — ' + sprints + (sprints === 1 ? ' sprint' : ' sprints')}
185
+ onClick=${() => goToPhase(p.id)}>
186
+ <span class="pg-chip-id">P${p.id}</span>
187
+ <span class="pg-chip-name">${truncate(p.name, 24)}</span>
188
+ </button>
189
+ `;
190
+ })}
191
+ </div>
192
+ <div class="pg-hint">No cross-phase dependencies declared — phases shown in roadmap order.</div>
193
+ `;
194
+ }
195
+
196
+ /** Layered DAG with curved edges, hover ancestry highlighting and tooltips. */
197
+ function Dag({ phases }) {
198
+ const [hoverId, setHoverId] = useState(null);
199
+
200
+ const model = useMemo(() => {
201
+ const layers = computeLayers(phases);
202
+ const { pos, width, height } = layout(phases, layers);
203
+ const adjacency = buildAdjacency(phases);
204
+ const known = new Set(phases.map(p => String(p.id)));
205
+ const edges = [];
206
+ for (const p of phases) {
207
+ for (const d of resolvedDeps(p, known)) {
208
+ const from = pos.get(d), to = pos.get(String(p.id));
209
+ if (!from || !to) continue;
210
+ edges.push({ from: d, to: String(p.id), x1: from.x + NODE_W, y1: from.y + NODE_H / 2, x2: to.x, y2: to.y + NODE_H / 2 });
211
+ }
212
+ }
213
+ return { pos, width, height, adjacency, edges };
214
+ }, [phases]);
215
+
216
+ const related = useMemo(
217
+ () => (hoverId ? relatedSet(hoverId, model.adjacency) : null),
218
+ [hoverId, model],
219
+ );
220
+
221
+ const hovered = hoverId ? phases.find(p => String(p.id) === hoverId) : null;
222
+
223
+ return html`
224
+ <div class="pg-scroll">
225
+ <svg class=${'pg-svg' + (hoverId ? ' pg-hovering' : '')}
226
+ width=${model.width} height=${model.height}
227
+ viewBox=${'0 0 ' + model.width + ' ' + model.height}
228
+ role="img" aria-label="Phase dependency graph">
229
+ <defs>
230
+ <marker id="pg-arrow" markerWidth="8" markerHeight="8"
231
+ refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
232
+ <path class="pg-arrow" d="M0,0 L8,4 L0,8 Z"/>
233
+ </marker>
234
+ </defs>
235
+ ${model.edges.map(e => {
236
+ const bend = Math.max(24, (e.x2 - e.x1) * 0.45);
237
+ const d = 'M' + e.x1 + ',' + e.y1
238
+ + ' C' + (e.x1 + bend) + ',' + e.y1
239
+ + ' ' + (e.x2 - bend) + ',' + e.y2
240
+ + ' ' + e.x2 + ',' + e.y2;
241
+ const on = related && related.has(e.from) && related.has(e.to);
242
+ return html`<path key=${e.from + '->' + e.to} d=${d}
243
+ class=${'pg-edge' + (on ? ' pg-related' : '')}
244
+ marker-end="url(#pg-arrow)"/>`;
245
+ })}
246
+ ${phases.map(p => {
247
+ const id = String(p.id);
248
+ const { x, y } = model.pos.get(id);
249
+ const kind = statusKind(p.status);
250
+ const on = related && related.has(id);
251
+ return html`
252
+ <g key=${id}
253
+ class=${'pg-node pg-' + kind + (on ? ' pg-related' : '')}
254
+ onClick=${() => goToPhase(p.id)}
255
+ onMouseEnter=${() => setHoverId(id)}
256
+ onMouseLeave=${() => setHoverId(null)}>
257
+ <rect x=${x} y=${y} width=${NODE_W} height=${NODE_H} rx="8"/>
258
+ <text x=${x + 10} y=${y + 18} class="pg-label">P${p.id}</text>
259
+ <text x=${x + 10} y=${y + 33} class="pg-sublabel">${truncate(p.name, 21)}</text>
260
+ </g>
261
+ `;
262
+ })}
263
+ ${hovered ? html`<${Tooltip} phase=${hovered}
264
+ nodePos=${model.pos.get(hoverId)}
265
+ canvasW=${model.width} canvasH=${model.height}/>` : null}
266
+ </svg>
267
+ </div>
268
+ `;
269
+ }
270
+
271
+ const LEGEND = [
272
+ ['done', 'Done'], ['active', 'Active'], ['todo', 'Todo'], ['blocked', 'Blocked'],
273
+ ];
274
+
275
+ export function PhaseGraph({ phases }) {
276
+ const list = Array.isArray(phases) ? phases : [];
277
+ const known = new Set(list.map(p => String(p.id)));
278
+ const hasDeps = list.some(p => resolvedDeps(p, known).length > 0);
279
+
280
+ return html`
281
+ <details class="pg-panel" open>
282
+ <summary>
283
+ <${Icon} name="layers" size=${14}/> Dependency Graph
284
+ <span class="pg-count">${list.length} ${list.length === 1 ? 'phase' : 'phases'}</span>
285
+ </summary>
286
+ <div class="pg-legend">
287
+ ${LEGEND.map(([kind, label]) => html`
288
+ <span key=${kind} class="pg-legend-item">
289
+ <span class=${'pg-swatch pg-' + kind}></span>${label}
290
+ </span>
291
+ `)}
292
+ </div>
293
+ ${!list.length
294
+ ? html`<div class="pg-empty">No phases yet — plan a milestone with <code>/rcode-new-milestone</code> and the graph will appear here.</div>`
295
+ : hasDeps
296
+ ? html`<${Dag} phases=${list}/>`
297
+ : html`<${FlowRow} phases=${list}/>`}
298
+ </details>
299
+ `;
300
+ }