@hanzlaa/rcode 3.5.0 → 3.6.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 (33) hide show
  1. package/package.json +7 -1
  2. package/server/dashboard.js +105 -3
  3. package/server/lib/html/client/agents-data.js +27 -0
  4. package/server/lib/html/client/app.js +15 -0
  5. package/server/lib/html/client/components/App.js +211 -0
  6. package/server/lib/html/client/components/OrchPanel.js +293 -0
  7. package/server/lib/html/client/components/Sidebar.js +73 -0
  8. package/server/lib/html/client/components/Topbar.js +53 -0
  9. package/server/lib/html/client/components/XtermPanel.js +220 -0
  10. package/server/lib/html/client/components/shared.js +330 -0
  11. package/server/lib/html/client/icons-client.js +85 -0
  12. package/server/lib/html/client/orchestrator.js +279 -0
  13. package/server/lib/html/client/preact.js +34 -0
  14. package/server/lib/html/client/store.js +91 -0
  15. package/server/lib/html/client/util.js +186 -0
  16. package/server/lib/html/client/views/AgentsView.js +83 -0
  17. package/server/lib/html/client/views/DecisionsView.js +102 -0
  18. package/server/lib/html/client/views/FilesView.js +223 -0
  19. package/server/lib/html/client/views/KanbanView.js +236 -0
  20. package/server/lib/html/client/views/MemoryView.js +157 -0
  21. package/server/lib/html/client/views/MilestonesView.js +136 -0
  22. package/server/lib/html/client/views/OrchestrationView.js +167 -0
  23. package/server/lib/html/client/views/OverviewView.js +221 -0
  24. package/server/lib/html/client/views/PhasesView.js +184 -0
  25. package/server/lib/html/client/views/RoadmapView.js +238 -0
  26. package/server/lib/html/client/views/SprintsView.js +178 -0
  27. package/server/lib/html/client/views/TasksView.js +148 -0
  28. package/server/lib/html/client.js +41 -1775
  29. package/server/lib/html/css.js +264 -44
  30. package/server/lib/html/icons.js +68 -0
  31. package/server/lib/html/shell.js +9 -296
  32. package/server/lib/scanner.js +76 -0
  33. package/server/orchestrator.js +237 -313
@@ -1,1784 +1,50 @@
1
1
  /**
2
- * Client-side JavaScript for the dashboard.
3
- * Handles routing, rendering, refresh, keyboard shortcuts, etc.
2
+ * Client JS loader.
3
+ *
4
+ * The dashboard's client-side code lives as plain static files under
5
+ * server/lib/html/client/ and is served verbatim at /js/<name>.js by
6
+ * dashboard.js. This module emits:
7
+ * 1. an inline <script> that injects server-scanned state (window.__S__)
8
+ * and the icon map (window.__ICONS__) for the Preact client
9
+ * 2. a module script tag loading /js/app.js (ESM, deferred until DOM ready)
10
+ *
11
+ * Sprint 31.4: legacy modules (client-render.js, client-kanban.js,
12
+ * client-main.js) deleted — the dashboard is 100% Preact.
4
13
  */
5
- function renderClientJs(state) {
6
- const clientData = JSON.stringify({
7
- phases: state.raw?.phases || [],
8
- milestone: state.raw?.milestone || '',
9
- currentPhase: state.raw?.current_phase || null,
10
- currentSprint: state.raw?.current_sprint|| null,
11
- decisions: state.raw?.decisions || [],
12
- blockers: state.raw?.blockers || [],
13
- council_sessions: state.raw?.council_sessions || [],
14
- last_session: state.raw?.last_session || null,
15
- chains: state.raw?.chains || [],
16
- workstreams: state.raw?.workstreams || [],
17
- // #12 — passthrough scanner-computed fields (absent values stay undefined,
18
- // both UI blocks below guard with `if (S.pendingHandoff)` / `if (S.memoryBank…)`).
19
- pendingHandoff: state.pendingHandoff || null,
20
- memoryBank: state.memoryBank || null,
21
- });
22
-
23
- return `<script>
24
- // ---- Embedded state ----
25
- window.__S__ = ${clientData};
26
- const S = window.__S__;
27
- const _phases = S.phases || [];
28
-
29
- // ---- Helpers ----
30
- function chip(s) {
31
- const c = (s === 'complete' || s === 'completed' || s === 'done') ? 'complete'
32
- : (s === 'active' || s === 'in_progress') ? 'active'
33
- : s === 'blocked' ? 'blocked'
34
- : s === 'planned' ? 'planned'
35
- : s === 'todo' ? 'todo' : 'other';
36
- return '<span class="status-chip ' + c + '">● ' + esc(s) + '</span>';
37
- }
38
- function tag(t) { return '<span class="tag">' + esc(t) + '</span>'; }
39
- function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
40
- function pct(d, t) { return t > 0 ? Math.round(d/t*100) + '%' : '—'; }
41
- function pctNum(d, t) { return t > 0 ? Math.round(d/t*100) : 0; }
42
- function dateStr(s) { return s ? String(s).slice(0,10) : null; }
43
- function humanDate(s) {
44
- if (!s) return null;
45
- try { const d = new Date(s); return d.toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'}); }
46
- catch { return dateStr(s); }
47
- }
48
- function allSprints() {
49
- return _phases.flatMap(p => (p.sprints||[]).map(s => Object.assign({}, s, {phaseId:p.id, phaseName:p.name})));
50
- }
51
- function allTasks() {
52
- return _phases.flatMap(p => (p.sprints||[]).flatMap(s =>
53
- (s.stories||[]).map(t => Object.assign({}, t, {sprintId:s.id, phaseId:p.id, phaseName:p.name}))
54
- ));
55
- }
56
- function attr(label, val) {
57
- return '<div class="attr-item"><span class="attr-label">' + label + '</span><span class="attr-value">' + (val||'—') + '</span></div>';
58
- }
59
- function breadcrumb(label, hash) {
60
- return '<div class="breadcrumb"><button class="back-btn" onclick="navTo(\\'' + hash + '\\')">← ' + label + '</button></div>';
61
- }
62
- function filterInput(listId) {
63
- return '<div class="filter-bar"><input class="filter-input" type="text" placeholder="Filter…" oninput="filterItems(this,\\'' + listId + '\\')"></div>';
64
- }
65
- function progressBar(done, total) {
66
- const p = pctNum(done, total);
67
- return '<div class="progress-bar"><div class="progress-bar-fill" style="width:' + p + '%;' +
68
- (p >= 100 ? 'background:var(--accent-green)' : p > 50 ? 'background:var(--accent-blue)' : 'background:var(--accent-amber)') +
69
- '"></div></div>';
70
- }
71
- function completionRing(done, total) {
72
- const p = pctNum(done, total);
73
- const r = 28, c = 2 * Math.PI * r, offset = c - (p / 100) * c;
74
- return '<div class="completion-ring"><svg width="64" height="64" viewBox="0 0 64 64">' +
75
- '<circle cx="32" cy="32" r="' + r + '" fill="none" stroke="var(--border)" stroke-width="4"/>' +
76
- '<circle cx="32" cy="32" r="' + r + '" fill="none" stroke="var(--accent-green)" stroke-width="4" ' +
77
- 'stroke-dasharray="' + c + '" stroke-dashoffset="' + offset + '" stroke-linecap="round"/>' +
78
- '</svg><span class="ring-text">' + p + '%</span></div>';
79
- }
80
- function showToast(msg) {
81
- const el = document.getElementById('toast');
82
- if (!el) return;
83
- el.textContent = msg; el.classList.add('show');
84
- setTimeout(() => el.classList.remove('show'), 2000);
85
- }
86
- function copyCmd(el) {
87
- const cmd = el.getAttribute('data-cmd');
88
- if (!cmd) return;
89
- navigator.clipboard.writeText(cmd).then(() => showToast('Copied: ' + cmd)).catch(() => {
90
- const ta = document.createElement('textarea'); ta.value = cmd;
91
- document.body.appendChild(ta); ta.select(); document.execCommand('copy');
92
- document.body.removeChild(ta); showToast('Copied: ' + cmd);
93
- });
94
- }
95
- function cmdHint(cmd, desc) {
96
- return '<div class="cmd-hint-item" data-cmd="' + esc(cmd) + '" onclick="copyCmd(this)">' +
97
- '<span class="cmd-text">' + esc(cmd) + '</span>' +
98
- '<span class="cmd-desc">' + esc(desc) + '</span>' +
99
- '<span class="cmd-copy">📋</span></div>';
100
- }
101
- function cmdAccordion(hints) {
102
- if (!hints.length) return '';
103
- return '<details class="cmd-hints"><summary>💡 Commands</summary>' +
104
- '<div class="cmd-hints-list">' + hints.join('') + '</div></details>';
105
- }
106
- function sprintHints(s) {
107
- const stories = Array.isArray(s.stories) ? s.stories : [];
108
- const st = s.status || 'planned';
109
- const sid = s.id || '';
110
- const pid = s.phaseId || '';
111
- const h = [];
112
- if (st === 'completed' || st === 'complete' || st === 'done') {
113
- h.push(cmdHint('/rihal-verify-work', 'Verify UAT for Sprint ' + sid));
114
- h.push(cmdHint('/rihal-audit', 'Audit completed Sprint ' + sid));
115
- h.push(cmdHint('/rihal-session-report', 'Generate session report'));
116
- h.push(cmdHint('/rihal-code-review', 'Review code from Sprint ' + sid));
117
- } else if (st === 'active' || st === 'in_progress') {
118
- h.push(cmdHint('/rihal-progress', 'Check Sprint ' + sid + ' progress'));
119
- h.push(cmdHint('/rihal-sprint-status', 'Status report for Sprint ' + sid));
120
- h.push(cmdHint('/rihal-pause-work', 'Pause and save context'));
121
- } else if (st === 'blocked') {
122
- h.push(cmdHint('/rihal-debug', 'Debug blocker in Sprint ' + sid));
123
- h.push(cmdHint('/rihal-correct-course', 'Course-correct Sprint ' + sid));
124
- } else {
125
- if (!stories.length) {
126
- h.push(cmdHint('/rihal-sprint-planning', 'Groom Sprint ' + sid + ' — add stories'));
127
- h.push(cmdHint('/rihal-create-story', 'Create a story for Sprint ' + sid));
128
- h.push(cmdHint('/rihal-discuss-phase', 'Discuss approach before planning'));
129
- } else {
130
- h.push(cmdHint('/rihal-execute', 'Execute Sprint ' + sid));
131
- h.push(cmdHint('/rihal-discuss-phase', 'Discuss before executing'));
132
- h.push(cmdHint('/rihal-sprint-planning', 'Refine Sprint ' + sid + ' plan'));
133
- }
134
- }
135
- return h;
136
- }
137
- function phaseHints(p) {
138
- const sps = Array.isArray(p.sprints) ? p.sprints : [];
139
- const st = p.status || 'planned';
140
- const pid = p.id || '';
141
- const h = [];
142
- if (st === 'completed' || st === 'complete' || st === 'done') {
143
- h.push(cmdHint('/rihal-validate-phase', 'Validate Phase ' + pid + ' deliverables'));
144
- h.push(cmdHint('/rihal-audit', 'Audit Phase ' + pid + ' completion'));
145
- h.push(cmdHint('/rihal-code-review', 'Review Phase ' + pid + ' code'));
146
- } else if (st === 'active' || st === 'in_progress') {
147
- h.push(cmdHint('/rihal-progress', 'Check Phase ' + pid + ' progress'));
148
- h.push(cmdHint('/rihal-sprint-status', 'Current sprint status'));
149
- h.push(cmdHint('/rihal-code-review', 'Review code in Phase ' + pid));
150
- } else {
151
- if (!sps.length) {
152
- h.push(cmdHint('/rihal-plan', 'Create sprint plan for Phase ' + pid));
153
- h.push(cmdHint('/rihal-discuss-phase', 'Discuss Phase ' + pid + ' approach'));
154
- h.push(cmdHint('/rihal-research-phase', 'Research Phase ' + pid + ' before planning'));
155
- } else {
156
- h.push(cmdHint('/rihal-execute', 'Start executing Phase ' + pid));
157
- h.push(cmdHint('/rihal-sprint-planning', 'Plan next sprint in Phase ' + pid));
158
- }
159
- }
160
- return h;
161
- }
162
-
163
- // ---- Entity cards ----
164
- function phaseCard(p) {
165
- const sps = p.sprints || [];
166
- const stories = sps.flatMap(s => s.stories || []);
167
- const done = stories.filter(t => t.status === 'done' || t.status === 'completed').length;
168
- const isCur = String(p.id) === String(S.currentPhase);
169
- return '<div class="item item-clickable" onclick="navTo(\\'phases/' + p.id + '\\')"' +
170
- (isCur ? ' style="border-left-color:var(--accent-amber)"' : '') + '>' +
171
- '<div class="item-title">Phase ' + esc(p.id) + ' — ' + esc(p.name) +
172
- (isCur ? tag('current') : '') + chip(p.status) + '</div>' +
173
- '<div class="item-meta">' + tag(sps.length + ' sprint' + (sps.length!==1?'s':'')) +
174
- tag(done + '/' + stories.length + ' tasks') +
175
- (stories.length > 0 ? tag(pct(done,stories.length) + ' done') : '') +
176
- (p.completed_at ? ' <span style="color:var(--text-muted);font-size:var(--text-xs);">Done ' + humanDate(p.completed_at) + '</span>' : '') +
177
- '</div>' +
178
- (stories.length > 0 ? '<div style="margin-top:6px;">' + progressBar(done, stories.length) + '</div>' : '') +
179
- (sps[0]?.goal ? '<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">' + esc(sps[0].goal) + '</div>' : '') +
180
- '</div>';
181
- }
182
-
183
- function sprintCard(s) {
184
- const stories = s.stories || [];
185
- const done = stories.filter(t => t.status === 'done' || t.status === 'completed').length;
186
- const isCur = s.id === S.currentSprint;
187
- const phaseId = s.phaseId || s.id || '';
188
- return '<div class="item item-clickable' + (isCur ? ' sprint-current' : '') + '" onclick="navTo(\\'sprints/' + s.id + '\\')"' +
189
- (isCur ? ' style="border-left-color:var(--accent-amber);background:rgba(245,158,11,0.04)"' : '') + '>' +
190
- '<div class="item-title">Sprint ' + esc(s.id) + ' — ' + esc(s.goal || 'No goal') +
191
- (isCur ? tag('current') : '') + chip(s.status) + '</div>' +
192
- '<div class="item-meta">' +
193
- (s.phaseId ? tag('Phase ' + s.phaseId) : '') +
194
- tag(done + '/' + stories.length + ' tasks') +
195
- (s.velocity_target != null ? tag('Target: ' + s.velocity_target + 'pts') : '') +
196
- (s.velocity_actual != null ? tag('Actual: ' + s.velocity_actual + 'pts') : '') + '</div>' +
197
- '<div style="margin-top:6px;">' + progressBar(done, stories.length) + '</div>' +
198
- (stories.length === 0 ? '<div class="empty-action" style="margin-top:var(--space-2);font-size:var(--text-xs);">No tasks — run <code>/rihal-plan ' + esc(phaseId) + '</code> to populate</div>' : '') +
199
- (s.started_at ? '<div style="color:var(--text-muted);font-size:var(--text-xs);margin-top:4px;">' +
200
- humanDate(s.started_at) + (s.completed_at ? ' → ' + humanDate(s.completed_at) : ' → ongoing') + '</div>' : '') +
201
- '</div>';
202
- }
203
-
204
- function taskCard(t) {
205
- const done = t.status === 'done' || t.status === 'completed';
206
- const tid = 'task-' + (t.id || t.title || '').replace(/[^a-zA-Z0-9]/g, '-').slice(0, 40) + '-' + Math.random().toString(36).slice(2, 6);
207
- // Build detail rows from all available context
208
- var rows = '';
209
- if (t.id) rows += '<div class="task-detail-row"><strong>ID:</strong> <code>' + esc(t.id) + '</code></div>';
210
- if (t.points) rows += '<div class="task-detail-row"><strong>Points:</strong> ' + t.points + '</div>';
211
- rows += '<div class="task-detail-row"><strong>Status:</strong> ' + chip(t.status || 'unknown') + '</div>';
212
- if (t.sprintId) rows += '<div class="task-detail-row"><strong>Sprint:</strong> ' + esc(t.sprintId) + '</div>';
213
- if (t.sprintGoal) rows += '<div class="task-detail-row"><strong>Sprint Goal:</strong> ' + esc(t.sprintGoal) + '</div>';
214
- if (t.phaseId) rows += '<div class="task-detail-row"><strong>Phase:</strong> P' + esc(t.phaseId) + (t.phaseName ? ' — ' + esc(t.phaseName) : '') + '</div>';
215
- if (t.acceptance) rows += '<div class="task-detail-row"><strong>Acceptance:</strong> ' + esc(t.acceptance) + '</div>';
216
- if (t.assignee) rows += '<div class="task-detail-row"><strong>Assignee:</strong> ' + esc(t.assignee) + '</div>';
217
- // Context-aware commands for this specific task
218
- var cmds = '';
219
- if (t.id) {
220
- var taskCmds = [];
221
- if (!done) {
222
- taskCmds.push(cmdHint('/rihal-dev-story ' + t.id, 'Implement this story'));
223
- taskCmds.push(cmdHint('/rihal-create-story ' + (t.sprintId || ''), 'Add related story'));
224
- } else {
225
- taskCmds.push(cmdHint('/rihal-verify-work ' + t.id, 'Verify this story'));
226
- taskCmds.push(cmdHint('/rihal-code-review ' + t.id, 'Review code for this story'));
227
- }
228
- if (t.sprintId) {
229
- taskCmds.push(cmdHint('/rihal-sprint-status ' + t.sprintId, 'Sprint ' + t.sprintId + ' status'));
230
- }
231
- cmds = '<div class="task-detail-cmds">' + taskCmds.join('') + '</div>';
232
- }
233
- return '<div class="item item-clickable" data-status="' + (t.status||'') + '" style="' + (done ? 'opacity:.65' : '') + '"' +
234
- ' onclick="toggleTaskDetail(\\'' + tid + '\\')">' +
235
- '<div class="item-title" style="' + (done ? 'text-decoration:line-through' : '') + '">' +
236
- (done ? '✓ ' : '') + esc(t.title) + chip(t.status) +
237
- '<span class="task-expand-icon" id="icon-' + tid + '">▶</span></div>' +
238
- '<div class="item-meta">' +
239
- (t.points ? tag(t.points + 'pts') : '') +
240
- (t.id ? tag(t.id) : '') +
241
- (t.sprintId ? tag('Sprint ' + t.sprintId) : '') +
242
- (t.phaseId ? tag('Phase ' + t.phaseId) : '') + '</div>' +
243
- '<div class="task-detail" id="' + tid + '" style="display:none;">' +
244
- rows + cmds + '</div>' +
245
- '</div>';
246
- }
247
- function toggleTaskDetail(id) {
248
- const el = document.getElementById(id);
249
- const icon = document.getElementById('icon-' + id);
250
- if (!el) return;
251
- const open = el.style.display !== 'none';
252
- el.style.display = open ? 'none' : 'block';
253
- if (icon) icon.textContent = open ? '▶' : '▼';
254
- }
255
-
256
- // ---- View renderers ----
257
- function renderOverview() {
258
- // #268: current sprint progress bar on overview
259
- const sprints = allSprints();
260
- const curSprint = sprints.find(s => s.id === S.currentSprint);
261
- let sprintProgressHtml = '';
262
- if (curSprint) {
263
- const sts = curSprint.stories || [];
264
- const d = sts.filter(t => t.status === 'done' || t.status === 'completed').length;
265
- sprintProgressHtml = '<section><h2>⚡ Current Sprint — ' + esc(curSprint.id) + '</h2><div class="body">' +
266
- '<div style="margin-bottom:8px;font-size:var(--text-sm);color:var(--text-secondary);">' + esc(curSprint.goal || '') + '</div>' +
267
- '<div style="display:flex;align-items:center;gap:var(--space-3);">' +
268
- '<div style="flex:1;">' + progressBar(d, sts.length) + '</div>' +
269
- '<span style="font-size:var(--text-sm);font-weight:600;">' + d + '/' + sts.length + ' (' + pct(d,sts.length) + ')</span>' +
270
- '</div></div></section>';
271
- }
272
-
273
- // #267: velocity sparkline
274
- let velocityHtml = '';
275
- const completedSprints = sprints.filter(s => s.velocity_actual != null);
276
- if (completedSprints.length > 1) {
277
- const vals = completedSprints.map(s => s.velocity_actual);
278
- const max = Math.max(...vals, 1);
279
- const w = 200, h = 40, step = w / (vals.length - 1);
280
- const points = vals.map((v, i) => (i * step) + ',' + (h - (v / max) * h));
281
- velocityHtml = '<div class="stat"><div class="label">Sprint Velocity</div>' +
282
- '<svg width="' + w + '" height="' + (h+4) + '" style="margin-top:8px;">' +
283
- '<polyline points="' + points.join(' ') + '" fill="none" stroke="var(--accent-blue)" stroke-width="2"/>' +
284
- '</svg><div class="sub">Last ' + vals.length + ' sprints</div></div>';
285
- }
286
-
287
- // #269: council sessions
288
- let councilHtml = '';
289
- if (Array.isArray(S.council_sessions) && S.council_sessions.length) {
290
- councilHtml = '<section><h2>🏛 Council Sessions</h2><div class="body"><div class="phase-list">' +
291
- S.council_sessions.slice(-5).reverse().map(cs =>
292
- '<div class="item"><div class="item-title">' + esc(cs.topic || cs.title || 'Session') + '</div>' +
293
- '<div class="item-meta">' + (cs.date ? humanDate(cs.date) : '') +
294
- (cs.participants ? ' · ' + esc(cs.participants.join(', ')) : '') + '</div></div>'
295
- ).join('') + '</div></div></section>';
296
- }
297
-
298
- // #271: last session
299
- let lastSessionHtml = '';
300
- if (S.last_session) {
301
- const ls = S.last_session;
302
- lastSessionHtml = '<span style="color:var(--text-muted);font-size:var(--text-xs);margin-left:var(--space-3);">' +
303
- 'Last session: ' + (humanDate(ls.date || ls.timestamp) || '—') + '</span>';
304
- }
305
-
306
- // #270: chains/workstreams
307
- let chainsHtml = '';
308
- const chains = S.chains || [];
309
- const workstreams = S.workstreams || [];
310
- if (chains.length || workstreams.length) {
311
- chainsHtml = '<section><h2>🔗 Chains & Workstreams</h2><div class="body">';
312
- if (chains.length) {
313
- chainsHtml += '<div style="margin-bottom:var(--space-4);"><strong>Chains</strong><div class="phase-list" style="margin-top:var(--space-2);">' +
314
- chains.map(c => '<div class="item"><div class="item-title">' + esc(c.name || c.id || 'Chain') + '</div></div>').join('') + '</div></div>';
315
- }
316
- if (workstreams.length) {
317
- chainsHtml += '<div><strong>Workstreams</strong><div class="phase-list" style="margin-top:var(--space-2);">' +
318
- workstreams.map(w => '<div class="item"><div class="item-title">' + esc(w.name || w.id || 'Workstream') + ' ' + chip(w.status || 'active') + '</div></div>').join('') + '</div></div>';
319
- }
320
- chainsHtml += '</div></section>';
321
- }
322
-
323
- // #12 — pending handoff banner (shown only when .rihal/HANDOFF.json present).
324
- // Read-only — the dashboard never resumes; user runs /rihal-resume-work.
325
- let handoffHtml = '';
326
- if (S.pendingHandoff) {
327
- const ho = S.pendingHandoff;
328
- const when = ho.ts ? humanDate(ho.ts) : '';
329
- const summary = ho.summary ? ' — ' + esc(ho.summary).slice(0, 120) : '';
330
- const where = ho.sprint ? ' [sprint ' + esc(ho.sprint) + ']' :
331
- ho.phase ? ' [phase ' + esc(ho.phase) + ']' : '';
332
- handoffHtml = '<section style="border-left:4px solid var(--accent-orange,#f59e0b);padding-left:var(--space-3);">' +
333
- '<h2>⚠ Pending Handoff</h2><div class="body">' +
334
- '<div>' + (when ? esc(when) : '') + where + summary + '</div>' +
335
- (ho.resume_hint ? '<div style="margin-top:var(--space-2);color:var(--text-secondary);font-size:var(--text-sm);">' + esc(ho.resume_hint) + '</div>' : '') +
336
- '<div style="margin-top:var(--space-3);font-size:var(--text-sm);"><code>/rihal-resume-work</code></div>' +
337
- '</div></section>';
338
- }
339
-
340
- // #12 — memory bank summary (shown only when .rihal/context/active.md present).
341
- let memoryHtml = '';
342
- if (S.memoryBank && S.memoryBank.active) {
343
- const m = S.memoryBank.active;
344
- memoryHtml = '<section><h2>🧠 Memory Bank</h2><div class="body">' +
345
- '<div class="attr-grid">' +
346
- attr('active.md', m.lines + ' lines · ' + Math.round(m.bytes / 1024 * 10) / 10 + ' KB') +
347
- attr('Updated', humanDate(m.updated)) +
348
- '</div></div></section>';
349
- }
350
-
351
- const el = document.getElementById('view-overview-dynamic');
352
- // Overview hints
353
- var oHints = [cmdHint('/rihal-next', 'What should I do next?'), cmdHint('/rihal-status', 'Quick project status'), cmdHint('/rihal-council', 'Ask the team a question')];
354
- if (curSprint) { oHints = sprintHints(curSprint).concat(oHints); }
355
- if (S.pendingHandoff) { oHints.unshift(cmdHint('/rihal-resume-work', 'Resume from the pending handoff')); }
356
- if (el) el.innerHTML = handoffHtml + sprintProgressHtml + memoryHtml + velocityHtml + councilHtml + chainsHtml + lastSessionHtml + cmdAccordion(oHints);
357
- }
358
-
359
- function renderRoadmap() {
360
- const ms = S.milestone || 'M1';
361
- const totalStories = allTasks();
362
- const doneStories = totalStories.filter(t => t.status === 'done' || t.status === 'completed');
363
- let h = '<div class="view-title">Roadmap</div>';
364
- // #273: filter
365
- h += '<div class="filter-bar"><input class="filter-input" type="text" placeholder="Filter roadmap…" id="roadmap-filter" oninput="filterRoadmap(this.value)"></div>';
366
- h += '<div class="tree-container" id="roadmap-tree">';
367
- h += '<div class="tree-node tree-ms"><div class="tree-row tree-header" onclick="toggleNode(this)">';
368
- h += '<span class="tree-chevron">▼</span><span class="tree-icon">🎯</span>';
369
- h += '<span class="tree-label">' + esc(ms) + '</span>';
370
- h += '<span class="tree-badge">' + _phases.length + ' phases · ' + doneStories.length + '/' + totalStories.length + ' tasks</span></div>';
371
- h += '<div class="tree-children">';
372
- for (const p of _phases) {
373
- const sps = p.sprints || [];
374
- const pStories = sps.flatMap(s => s.stories||[]);
375
- const pDone = pStories.filter(t => t.status==='done'||t.status==='completed').length;
376
- // #274: phase nodes navigate to phase detail
377
- h += '<div class="tree-node" data-filter-text="' + esc(p.name).toLowerCase() + '"><div class="tree-row" onclick="toggleNode(this)">';
378
- h += '<span class="tree-chevron">▶</span><span class="tree-icon">📋</span>';
379
- h += '<span class="tree-label" ondblclick="navTo(\\'phases/' + p.id + '\\');event.stopPropagation();">P' + esc(p.id) + ' — ' + esc(p.name) + '</span>' + chip(p.status);
380
- // #276: inline mini progress bar
381
- const pp = pctNum(pDone, pStories.length);
382
- h += '<span style="width:60px;display:inline-block;margin:0 8px;"><div class="progress-bar" style="height:4px;"><div class="progress-bar-fill" style="width:' + pp + '%;height:100%;"></div></div></span>';
383
- h += '<span class="tree-badge">' + sps.length + ' sprints · ' + pDone + '/' + pStories.length + '</span></div>';
384
- // #272: start collapsed
385
- h += '<div class="tree-children" style="display:none">';
386
- for (const s of sps) {
387
- const sts = s.stories || [];
388
- const sDone = sts.filter(t => t.status==='done'||t.status==='completed').length;
389
- // #275: sprint nodes link to file
390
- h += '<div class="tree-node"><div class="tree-row" onclick="toggleNode(this)">';
391
- h += '<span class="tree-chevron">▶</span><span class="tree-icon">⚡</span>';
392
- h += '<span class="tree-label">Sprint ' + esc(s.id) + ' — ' + esc(s.goal||'No goal') + '</span>' + chip(s.status);
393
- h += '<span class="tree-badge">' + sDone + '/' + sts.length + '</span></div>';
394
- h += '<div class="tree-children" style="display:none">';
395
- for (const t of sts) {
396
- const td = t.status==='done'||t.status==='completed';
397
- h += '<div class="tree-node task-leaf"><div class="tree-row">';
398
- h += '<span class="tree-icon">' + (td?'✓':'○') + '</span>';
399
- h += '<span class="tree-label" style="' + (td?'opacity:.6;text-decoration:line-through':'') + '">' + esc(t.title) + '</span>';
400
- h += chip(t.status) + (t.points ? '<span class="tree-badge">' + t.points + 'pts</span>' : '');
401
- h += '</div></div>';
402
- }
403
- if (!sts.length) h += '<div style="color:var(--text-muted);font-size:var(--text-xs);padding:var(--space-2) var(--space-6);">No tasks</div>';
404
- h += '</div></div>';
405
- }
406
- if (!sps.length) h += '<div style="color:var(--text-muted);font-size:var(--text-xs);padding:var(--space-2) var(--space-6);">No sprints</div>';
407
- h += '</div></div>';
408
- }
409
- h += '</div></div></div>';
410
- // Roadmap hints
411
- var rmHints = [cmdHint('/rihal-add-phase', 'Add a new phase'), cmdHint('/rihal-milestone-summary', 'View milestone summary'), cmdHint('/rihal-new-milestone', 'Start a new milestone')];
412
- var allPDone = _phases.length > 0 && _phases.every(ph => ph.status === 'complete' || ph.status === 'completed' || ph.status === 'done');
413
- if (allPDone) { rmHints.push(cmdHint('/rihal-audit-milestone', 'Audit milestone completion')); rmHints.push(cmdHint('/rihal-complete-milestone', 'Complete and archive milestone')); }
414
- document.getElementById('view-roadmap').innerHTML = h + cmdAccordion(rmHints);
415
- }
416
-
417
- function filterRoadmap(q) {
418
- q = q.toLowerCase().trim();
419
- document.querySelectorAll('#roadmap-tree .tree-node[data-filter-text]').forEach(n => {
420
- n.style.display = !q || n.dataset.filterText.includes(q) ? '' : 'none';
421
- });
422
- }
423
-
424
- function renderMilestones(subId) {
425
- const el = document.getElementById('view-milestones');
426
- const ms = S.milestone || 'M1';
427
- if (subId) {
428
- const doneP = _phases.filter(p => p.status==='complete'||p.status==='completed').length;
429
- const total = allTasks(), done = total.filter(t => t.status==='done'||t.status==='completed');
430
- // #278: velocity history
431
- const sprints = allSprints().filter(s => s.velocity_actual != null);
432
- let velocityHtml = '';
433
- if (sprints.length) {
434
- velocityHtml = '<div class="view-title" style="margin-top:var(--space-6)">Velocity History</div>';
435
- const maxV = Math.max(...sprints.map(s => Math.max(s.velocity_actual||0, s.velocity_target||0)), 1);
436
- velocityHtml += '<div style="max-width:600px;">' + sprints.map(s =>
437
- '<div class="velocity-bar">' +
438
- '<div class="velocity-bar-label">S' + esc(s.id) + '</div>' +
439
- '<div class="velocity-bar-track">' +
440
- '<div class="velocity-bar-fill" style="width:' + ((s.velocity_actual||0)/maxV*100) + '%;background:var(--accent-blue);"></div>' +
441
- '</div>' +
442
- '<div class="velocity-bar-val">' + (s.velocity_actual||0) + '/' + (s.velocity_target||'—') + '</div>' +
443
- '</div>'
444
- ).join('') + '</div>';
445
- }
446
- // #279: phase timeline
447
- let timelineHtml = '';
448
- const phasesWithDates = _phases.filter(p => (p.sprints||[]).some(s => s.started_at));
449
- if (phasesWithDates.length) {
450
- timelineHtml = '<div class="view-title" style="margin-top:var(--space-6)">Phase Timeline</div>' +
451
- '<div class="phase-list">' + phasesWithDates.map(p => {
452
- const sps = p.sprints || [];
453
- const startDates = sps.map(s => s.started_at).filter(Boolean).sort();
454
- const endDates = sps.map(s => s.completed_at).filter(Boolean).sort().reverse();
455
- return '<div class="item"><div class="item-title">P' + esc(p.id) + ' — ' + esc(p.name) + ' ' + chip(p.status) + '</div>' +
456
- '<div class="item-meta">' + (startDates[0] ? humanDate(startDates[0]) : '?') + ' → ' +
457
- (endDates[0] ? humanDate(endDates[0]) : 'ongoing') + '</div></div>';
458
- }).join('') + '</div>';
459
- }
460
- // #280: completion ring
461
- el.innerHTML = breadcrumb('Milestones','milestones') +
462
- '<div class="entity-header"><div style="display:flex;align-items:center;gap:var(--space-6);"><div>' +
463
- '<div class="entity-title">🎯 ' + esc(ms) + '</div></div>' +
464
- completionRing(done.length, total.length) + '</div>' +
465
- '<div class="attr-grid">' +
466
- attr('Total Phases', _phases.length) + attr('Completed Phases', doneP) +
467
- attr('Current Phase', S.currentPhase||'—') + attr('Current Sprint', S.currentSprint||'—') +
468
- attr('Tasks Done', done.length + '/' + total.length) +
469
- attr('Progress', pct(done.length, total.length)) + '</div></div>' +
470
- velocityHtml + timelineHtml +
471
- '<div class="view-title" style="margin-top:var(--space-6)">Phases under this milestone</div>' +
472
- '<div class="phase-list">' + _phases.map(phaseCard).join('') + '</div>';
473
- } else {
474
- const total = allTasks(), done = total.filter(t => t.status==='done'||t.status==='completed');
475
- el.innerHTML = '<div class="view-title">Milestones</div>' +
476
- '<div class="phase-list"><div class="item item-clickable" onclick="navTo(\\'milestones/M1\\')">' +
477
- '<div style="display:flex;align-items:center;gap:var(--space-4);">' +
478
- completionRing(done.length, total.length) +
479
- '<div><div class="item-title">🎯 ' + esc(ms) + '</div>' +
480
- '<div class="item-meta">' + tag(_phases.length + ' phases') + tag(allSprints().length + ' sprints') +
481
- tag(done.length + '/' + total.length + ' tasks done') + tag(pct(done.length,total.length) + ' complete') + '</div></div>' +
482
- '</div></div></div>';
483
- }
484
- }
485
-
486
- function renderPhases(subId) {
487
- const el = document.getElementById('view-phases');
488
- if (subId) {
489
- const p = _phases.find(ph => String(ph.id) === String(subId) || String(ph.number) === String(subId));
490
- // Fix #319: guard against missing sprints key
491
- if (!p) { el.innerHTML = breadcrumb('Phases','phases') + '<div class="empty">Phase not found.</div>'; return; }
492
- const sps = Array.isArray(p.sprints) ? p.sprints : [];
493
- const stories = sps.flatMap(s => Array.isArray(s.stories) ? s.stories : []);
494
- const done = stories.filter(t => t.status==='done'||t.status==='completed').length;
495
- // #284: velocity bars
496
- let velocityHtml = '';
497
- const sprintsWithVel = sps.filter(s => s.velocity_actual != null || s.velocity_target != null);
498
- if (sprintsWithVel.length) {
499
- const maxV = Math.max(...sprintsWithVel.map(s => Math.max(s.velocity_actual||0, s.velocity_target||0)), 1);
500
- velocityHtml = '<div class="view-title" style="margin-top:var(--space-6)">Sprint Velocity</div>' +
501
- '<div style="max-width:600px;">' + sprintsWithVel.map(s =>
502
- '<div class="velocity-bar">' +
503
- '<div class="velocity-bar-label">S' + esc(s.id) + '</div>' +
504
- '<div class="velocity-bar-track">' +
505
- '<div class="velocity-bar-fill" style="width:' + ((s.velocity_actual||0)/maxV*100) + '%;"></div>' +
506
- '</div>' +
507
- '<div class="velocity-bar-val">' + (s.velocity_actual||0) + '/' + (s.velocity_target||'—') + '</div></div>'
508
- ).join('') + '</div>';
509
- }
510
- el.innerHTML = breadcrumb('All Phases','phases') +
511
- '<div class="entity-header"><div class="entity-title">📋 Phase ' + esc(p.id) + ' — ' + esc(p.name) + '</div>' +
512
- '<div class="attr-grid">' +
513
- attr('Status', chip(p.status)) + attr('Sprints', sps.length) +
514
- attr('Tasks Done', done + '/' + stories.length) + attr('Progress', pct(done,stories.length)) +
515
- // #282: completed_at date
516
- (p.completed_at ? attr('Completed', humanDate(p.completed_at)) : '') + '</div></div>' +
517
- '<div style="margin-bottom:var(--space-4);">' + progressBar(done, stories.length) + '</div>' +
518
- '<div class="term-action-bar">' +
519
- '<button class="term-run-btn" onclick="runAndOpenTerm(\\'phase-' + esc(p.id) + '\\',\\'/rihal-execute\\',\\'Phase ' + esc(p.id) + '\\')">▶ Run Phase</button>' +
520
- '<button class="term-run-btn outline" onclick="openTermPanel(\\'phase-' + esc(p.id) + '\\',\\'Phase ' + esc(p.id) + '\\')">📟 Terminal</button>' +
521
- '<button class="back-btn" onclick="viewPlanFile(\\\'' + esc(p.id) + '\\\')">📄 View plan file →</button>' +
522
- '</div>' +
523
- velocityHtml +
524
- '<div class="view-title" style="margin-top:var(--space-6)">Sprints</div>' +
525
- '<div class="phase-list">' + (sps.length ? sps.map(s => sprintCard(Object.assign({},s,{phaseId:p.id,phaseName:p.name}))).join('') :
526
- '<div class="empty">No sprints in this phase yet.<div class="empty-action">Run /rihal-plan to create sprints</div></div>') + '</div>' +
527
- cmdAccordion(phaseHints(p));
528
- } else {
529
- var plHints = [cmdHint('/rihal-add-phase', 'Add a new phase'), cmdHint('/rihal-stats', 'Project statistics'), cmdHint('/rihal-progress', 'Overall progress')];
530
- var allComplete = _phases.length > 0 && _phases.every(ph => ph.status === 'complete' || ph.status === 'completed' || ph.status === 'done');
531
- if (allComplete) { plHints.push(cmdHint('/rihal-audit-milestone', 'Audit milestone completion')); plHints.push(cmdHint('/rihal-complete-milestone', 'Complete and archive milestone')); plHints.push(cmdHint('/rihal-ship', 'Create PR and ship')); }
532
- el.innerHTML = '<div class="view-title">Phases</div>' + filterInput('phases-inner') +
533
- '<div id="phases-inner" class="phase-list">' +
534
- (_phases.length ? _phases.map(phaseCard).join('') : '<div class="empty">No phases yet.<div class="empty-action">Run /rihal-new-project to start</div></div>') + '</div>' + cmdAccordion(plHints);
535
- }
536
- }
537
-
538
- function renderSprints(subId) {
539
- const el = document.getElementById('view-sprints');
540
- const sprints = allSprints();
541
- if (subId) {
542
- const s = sprints.find(sp => String(sp.id) === String(subId));
543
- if (!s) { el.innerHTML = breadcrumb('All Sprints','sprints') + '<div class="empty">Sprint not found.</div>'; return; }
544
- const rawStories = Array.isArray(s.stories) ? s.stories : [];
545
- const stories = rawStories.map(function(t) { return Object.assign({}, t, {sprintId: s.id, sprintGoal: s.goal || '', phaseId: s.phaseId, phaseName: s.phaseName}); });
546
- const done = stories.filter(t => t.status==='done'||t.status==='completed').length;
547
- // #290: acceptance criteria
548
- let acHtml = '';
549
- const storiesWithAc = stories.filter(t => t.acceptance);
550
- if (storiesWithAc.length) {
551
- acHtml = '<div class="view-title" style="margin-top:var(--space-6)">Acceptance Criteria</div>' +
552
- '<div class="phase-list">' + storiesWithAc.map(t =>
553
- '<div class="item"><div class="item-title">' + esc(t.title) + '</div>' +
554
- '<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">✓ ' + esc(t.acceptance) + '</div></div>'
555
- ).join('') + '</div>';
556
- }
557
- // #292: full breadcrumb path
558
- el.innerHTML = '<div class="breadcrumb"><button class="back-btn" onclick="navTo(\\'sprints\\')">← All Sprints</button> ' +
559
- (s.phaseId ? '<button class="back-btn" onclick="navTo(\\'phases/' + s.phaseId + '\\')">← Phase ' + esc(s.phaseId) + '</button>' : '') + '</div>' +
560
- '<div class="entity-header"><div class="entity-title">⚡ Sprint ' + esc(s.id) + '</div>' +
561
- '<div class="attr-grid">' +
562
- attr('Goal', esc(s.goal||'—')) + attr('Status', chip(s.status)) +
563
- attr('Phase', 'P' + s.phaseId + ' — ' + esc(s.phaseName)) +
564
- attr('Velocity', (s.velocity_actual!=null?s.velocity_actual:'—') + ' / ' + (s.velocity_target!=null?s.velocity_target:'—') + ' pts') +
565
- attr('Tasks Done', done + '/' + stories.length) + attr('Progress', pct(done,stories.length)) +
566
- // #293: human-readable dates
567
- (s.started_at ? attr('Started', humanDate(s.started_at)) : '') +
568
- (s.completed_at ? attr('Completed', humanDate(s.completed_at)) : '') + '</div></div>' +
569
- // #289: progress bar
570
- '<div style="margin-bottom:var(--space-4);">' + progressBar(done, stories.length) + '</div>' +
571
- '<div class="term-action-bar">' +
572
- '<button class="term-run-btn" onclick="runAndOpenTerm(\\'sprint-' + esc(s.id) + '\\',\\'/rihal-execute-sprint ' + esc(s.id) + '\\',\\'Sprint ' + esc(s.id) + '\\')">▶ Run Sprint</button>' +
573
- '<button class="term-run-btn outline" onclick="openTermPanel(\\'sprint-' + esc(s.id) + '\\',\\'Sprint ' + esc(s.id) + '\\')">📟 Terminal</button>' +
574
- '</div>' +
575
- '<div class="view-title" style="margin-top:var(--space-4)">Tasks</div>' +
576
- '<div class="phase-list">' + (stories.length ? stories.map(taskCard).join('') :
577
- '<div class="empty">No tasks in this sprint yet.<div class="empty-action">Run /rihal-create-story to add tasks</div></div>') + '</div>' +
578
- acHtml + cmdAccordion(sprintHints(s));
579
- } else {
580
- var slHints = [cmdHint('/rihal-sprint-planning', 'Plan a new sprint'), cmdHint('/rihal-stats', 'Project statistics')];
581
- var curSp = sprints.find(sp => sp.id === S.currentSprint);
582
- if (curSp) { slHints.push(cmdHint('/rihal-execute', 'Execute current sprint ' + curSp.id)); slHints.push(cmdHint('/rihal-sprint-status', 'Status of Sprint ' + curSp.id)); }
583
- el.innerHTML = '<div class="view-title">Sprints</div>' + filterInput('sprints-inner') +
584
- '<div id="sprints-inner" class="phase-list">' +
585
- (sprints.length ? sprints.map(sprintCard).join('') :
586
- '<div class="empty">No sprints yet.<div class="empty-action">Run /rihal-plan to create sprints</div></div>') + '</div>' + cmdAccordion(slHints);
587
- }
588
- }
589
-
590
- function renderTasks() {
591
- const el = document.getElementById('view-tasks');
592
- const tasks = allTasks();
593
- // #295: aggregate points
594
- const totalPts = tasks.reduce((sum, t) => sum + (t.points || 0), 0);
595
- const donePts = tasks.filter(t => t.status === 'done' || t.status === 'completed').reduce((sum, t) => sum + (t.points || 0), 0);
596
- // #296: filter by status + #297: sort options
597
- el.innerHTML = '<div class="view-title">Tasks</div>' +
598
- '<div class="filter-bar">' +
599
- '<input class="filter-input" type="text" placeholder="Filter…" oninput="filterItems(this,\\'tasks-inner\\')">' +
600
- '<select class="filter-select" id="task-status-filter" onchange="filterTasksByStatus()">' +
601
- '<option value="">All statuses</option><option value="todo">Todo</option>' +
602
- '<option value="in_progress">In Progress</option><option value="done">Done</option>' +
603
- '<option value="blocked">Blocked</option></select>' +
604
- '<select class="filter-select" id="task-sort" onchange="sortTasks()">' +
605
- '<option value="default">Default order</option><option value="status">By status</option>' +
606
- '<option value="points-desc">Points ↓</option><option value="points-asc">Points ↑</option></select>' +
607
- '</div>' +
608
- (totalPts > 0 ? '<div style="color:var(--text-muted);font-size:var(--text-sm);margin-bottom:var(--space-4);">' +
609
- donePts + '/' + totalPts + ' points completed</div>' : '') +
610
- // #294: group by sprint
611
- '<div id="tasks-inner" class="phase-list">' +
612
- renderTasksGrouped(tasks) + '</div>';
613
- // Task hints accordion
614
- var tHints = [cmdHint('/rihal-create-story', 'Add a new story/task'), cmdHint('/rihal-sprint-planning', 'Plan the next sprint')];
615
- var allDone = tasks.length > 0 && tasks.every(t => t.status === 'done' || t.status === 'completed');
616
- var hasBlocked = tasks.some(t => t.status === 'blocked');
617
- if (allDone) { tHints.push(cmdHint('/rihal-verify-work', 'Verify all tasks pass UAT')); tHints.push(cmdHint('/rihal-audit-uat', 'Audit UAT coverage')); }
618
- if (hasBlocked) { tHints.push(cmdHint('/rihal-debug', 'Debug blocked tasks')); tHints.push(cmdHint('/rihal-correct-course', 'Course-correct blockers')); }
619
- el.innerHTML += cmdAccordion(tHints);
620
- }
621
-
622
- function renderTasksGrouped(tasks) {
623
- if (!tasks.length) {
624
- var phaseHint = S.currentPhase ? ' ' + S.currentPhase : '';
625
- return '<div class="empty">No tasks yet.' +
626
- '<div class="empty-action">Run <code>/rihal-plan' + phaseHint + '</code> to generate tasks for this project.</div></div>';
627
- }
628
- const groups = {};
629
- for (const t of tasks) {
630
- const key = t.sprintId || 'unassigned';
631
- if (!groups[key]) groups[key] = [];
632
- groups[key].push(t);
633
- }
634
- let h = '';
635
- for (const [sprintId, items] of Object.entries(groups)) {
636
- h += '<div style="margin-bottom:var(--space-4);"><div style="font-size:var(--text-sm);font-weight:600;color:var(--text-muted);margin-bottom:var(--space-2);">Sprint ' + esc(sprintId) + '</div>';
637
- h += items.map(taskCard).join('');
638
- h += '</div>';
639
- }
640
- return h;
641
- }
642
-
643
- function filterTasksByStatus() {
644
- const status = document.getElementById('task-status-filter')?.value || '';
645
- const el = document.getElementById('tasks-inner');
646
- if (!el) return;
647
- el.querySelectorAll('.item').forEach(item => {
648
- if (!status) { item.style.display = ''; return; }
649
- const s = item.dataset.status || '';
650
- const match = status === 'done' ? (s === 'done' || s === 'completed') : s === status;
651
- item.style.display = match ? '' : 'none';
652
- });
653
- }
654
-
655
- function sortTasks() {
656
- const sort = document.getElementById('task-sort')?.value || 'default';
657
- const tasks = allTasks();
658
- if (sort === 'status') tasks.sort((a,b) => (a.status||'').localeCompare(b.status||''));
659
- else if (sort === 'points-desc') tasks.sort((a,b) => (b.points||0) - (a.points||0));
660
- else if (sort === 'points-asc') tasks.sort((a,b) => (a.points||0) - (b.points||0));
661
- const el = document.getElementById('tasks-inner');
662
- if (el) el.innerHTML = sort === 'default' ? renderTasksGrouped(tasks) : tasks.map(taskCard).join('');
663
- }
664
-
665
- // ── Kanban + Orchestrator ────────────────────────────────────────────────────
666
- // Run/Stop talk to orchestrator on :7718.
667
- // Terminals render in the side panel (#orch-panel), not inline in cards.
668
- var ORCH = 'http://localhost:7718';
669
- var _orchStreams = {}; // Map<storyId, EventSource>
670
- var _panelActive = null; // currently active storyId in panel
671
- var _sessions = {}; // Map<storyId, { title, termEl, fileOpBuf[] }>
672
-
673
- function kanbanCol(status) {
674
- if (status === 'done' || status === 'completed') return 'done';
675
- if (status === 'in_progress' || status === 'active' || status === 'running') return 'in_progress';
676
- if (status === 'blocked') return 'blocked';
677
- return 'todo';
678
- }
679
-
680
- // ── Kanban render ────────────────────────────────────────────────
681
- function renderKanban() {
682
- const el = document.getElementById('view-kanban');
683
- if (!el) return;
684
- const tasks = allTasks();
685
- const cols = [
686
- { id: 'todo', label: 'Todo', cssClass: 'col-todo' },
687
- { id: 'in_progress', label: 'In Progress', cssClass: 'col-prog' },
688
- { id: 'blocked', label: 'Blocked', cssClass: 'col-blocked' },
689
- { id: 'done', label: 'Done', cssClass: 'col-done' },
690
- ];
691
- const buckets = { todo: [], in_progress: [], blocked: [], done: [] };
692
- for (const t of tasks) buckets[kanbanCol(t.status)].push(t);
693
-
694
- // Topbar
695
- let h = '<div class="kanban-topbar">' +
696
- '<div class="kanban-topbar-title">' +
697
- '<span class="orch-status-dot" id="orch-dot"></span>' +
698
- 'Kanban' +
699
- '</div>' +
700
- '<div class="kanban-topbar-actions">' +
701
- '<button class="kanban-refresh-btn" onclick="refreshOrchestratorStatus()">⟳ Sync</button>' +
702
- '<button class="kanban-refresh-btn" onclick="openOrchPanel(null)" style="margin-left:4px;">⊞ Sessions</button>' +
703
- '</div>' +
704
- '</div>';
705
-
706
- if (!tasks.length) {
707
- el.innerHTML = h + '<div class="empty" style="margin:24px;">' +
708
- 'No stories yet.<div class="empty-action">/rihal-plan to generate tasks</div></div>';
709
- return;
710
- }
711
-
712
- h += '<div class="kanban-board">';
713
- for (const col of cols) {
714
- const items = buckets[col.id];
715
- h += '<div class="kanban-col ' + col.cssClass + '" data-col="' + col.id + '">' +
716
- '<div class="kanban-col-head">' +
717
- '<span class="col-label"><span class="col-status-dot"></span>' + esc(col.label) + '</span>' +
718
- '<span class="kanban-count">' + items.length + '</span>' +
719
- '</div>' +
720
- '<div class="kanban-col-body">';
721
- for (const t of items) {
722
- const c = kanbanCol(t.status);
723
- const sid = esc(t.id || '');
724
- const canRun = c === 'todo' || c === 'blocked';
725
- const isRunning = c === 'in_progress';
726
- const pts = t.points ? t.points + 'p' : null;
727
- const phase = t.phaseId ? 'P' + t.phaseId : null;
728
- const sprintMeta = [pts, phase].filter(Boolean).join(' · ');
729
- const actionBtn = sid
730
- ? (canRun
731
- ? '<button class="kanban-run-btn" data-action="run">▶ Run</button>'
732
- : isRunning
733
- ? '<button class="kanban-stop-btn" data-action="stop">■ Stop</button>' +
734
- '<button class="kanban-view-btn" data-action="view">↗ View</button>'
735
- : '<button class="kanban-view-btn" data-action="view">↗ Logs</button>')
736
- : '';
737
- h += '<div class="kanban-card s-' + c + (isRunning ? ' running' : '') +
738
- '" data-story-id="' + sid + '" draggable="true">' +
739
- '<div class="kanban-card-header">' +
740
- '<div class="kanban-card-title">' + esc(t.title || t.id || 'Untitled') + '</div>' +
741
- (sid ? '<div class="kanban-card-id">' + sid.slice(0, 8) + '</div>' : '') +
742
- '</div>' +
743
- (sprintMeta ? '<div class="kanban-card-meta">' +
744
- '<span class="kanban-card-sprint">' + esc(sprintMeta) + '</span>' +
745
- '<span class="kanban-card-status">' + esc(col.label) + '</span>' +
746
- '</div>' : '') +
747
- (isRunning ? '<div class="card-run-indicator" id="run-ind-' + sid + '">' +
748
- '<span class="run-pulse"></span>running' +
749
- '</div>' : '') +
750
- (actionBtn ? '<div class="kanban-card-actions">' + actionBtn + '</div>' : '') +
751
- '</div>';
752
- }
753
- h += '</div></div>'; // .kanban-col-body + .kanban-col
754
- }
755
- h += '</div>'; // .kanban-board
756
-
757
- el.innerHTML = h;
758
- wireKanbanDnd();
759
- refreshOrchestratorStatus();
760
- }
761
-
762
- function getCard(sid) { return document.querySelector('[data-story-id="' + sid + '"]'); }
763
-
764
- // ── Orchestrator panel ───────────────────────────────────────────
765
-
766
- function _ensureSession(storyId, title) {
767
- if (_sessions[storyId]) return _sessions[storyId];
768
- var termEl = document.createElement('div');
769
- termEl.style.cssText = 'padding:16px 20px;font-family:var(--font-mono);font-size:12px;line-height:1.6;min-height:100%;';
770
- _sessions[storyId] = { title: title || storyId, termEl, fileOpBuf: [] };
771
- return _sessions[storyId];
772
- }
773
-
774
- function createPanelTab(storyId, title) {
775
- _ensureSession(storyId, title);
776
- var tabs = document.getElementById('orch-tabs');
777
- if (!tabs) return;
778
- // Remove placeholder
779
- var ph = tabs.querySelector('.orch-term-empty');
780
- if (ph) ph.remove();
781
- if (document.getElementById('orch-tab-' + storyId)) return;
782
- var tab = document.createElement('button');
783
- tab.className = 'orch-tab';
784
- tab.id = 'orch-tab-' + storyId;
785
- tab.dataset.storyId = storyId;
786
- var shortTitle = (title || storyId).slice(0, 20);
787
- tab.innerHTML =
788
- '<span class="tab-status-dot starting" id="tdot-' + storyId + '"></span>' +
789
- '<span>' + esc(shortTitle) + '</span>' +
790
- '<button class="orch-tab-close" data-sid="' + storyId + '" title="Close">✕</button>';
791
- tab.onclick = function(e) {
792
- if (e.target.dataset.sid) { closePanelTab(e.target.dataset.sid); return; }
793
- activatePanelTab(storyId);
794
- };
795
- tabs.appendChild(tab);
796
- }
797
-
798
- function activatePanelTab(storyId) {
799
- document.querySelectorAll('.orch-tab').forEach(function(t) { t.classList.remove('active'); });
800
- var tab = document.getElementById('orch-tab-' + storyId);
801
- if (tab) tab.classList.add('active');
802
- _panelActive = storyId;
803
-
804
- var body = document.getElementById('orch-term-body');
805
- var sess = _sessions[storyId];
806
- if (body) {
807
- body.innerHTML = '';
808
- if (sess) {
809
- body.appendChild(sess.termEl);
810
- body.scrollTop = body.scrollHeight;
811
- }
812
- }
813
-
814
- var filesEl = document.getElementById('orch-files');
815
- if (filesEl && sess) {
816
- filesEl.style.display = sess.fileOpBuf.length ? '' : 'none';
817
- while (filesEl.children.length > 1) filesEl.removeChild(filesEl.lastChild);
818
- sess.fileOpBuf.forEach(function(fo) { filesEl.appendChild(_renderFileOpEl(fo)); });
819
- }
820
-
821
- var stopBtn = document.getElementById('orch-stop-btn');
822
- if (stopBtn) stopBtn.style.display = _orchStreams[storyId] ? '' : 'none';
823
-
824
- var statusEl = document.getElementById('orch-session-status');
825
- if (statusEl) {
826
- var running = Object.keys(_orchStreams).length;
827
- statusEl.textContent = running > 0 ? running + ' running' : '';
828
- }
829
- }
830
-
831
- function setTabStatus(storyId, status) {
832
- var dot = document.getElementById('tdot-' + storyId);
833
- if (dot) dot.className = 'tab-status-dot ' + (status || 'starting');
834
- }
835
-
836
- function openOrchPanel(storyId) {
837
- var panel = document.getElementById('orch-panel');
838
- if (panel) panel.classList.add('open');
839
- if (storyId && _sessions[storyId]) activatePanelTab(storyId);
840
- else if (storyId) {
841
- // Show empty state if no session yet
842
- var body = document.getElementById('orch-term-body');
843
- if (body) body.innerHTML = '<div class="orch-term-empty"><div>No output yet for ' + esc(storyId) + '</div></div>';
844
- }
845
- }
846
-
847
- function closeOrchPanel() {
848
- var panel = document.getElementById('orch-panel');
849
- if (panel) panel.classList.remove('open');
850
- _panelActive = null;
851
- }
852
-
853
- function closePanelTab(storyId) {
854
- var tab = document.getElementById('orch-tab-' + storyId);
855
- if (tab) tab.remove();
856
- delete _sessions[storyId];
857
- if (_orchStreams[storyId]) { _orchStreams[storyId].close(); delete _orchStreams[storyId]; }
858
- if (_panelActive === storyId) {
859
- var remaining = document.querySelectorAll('.orch-tab');
860
- if (remaining.length > 0) {
861
- activatePanelTab(remaining[0].dataset.storyId);
862
- } else {
863
- var tabs = document.getElementById('orch-tabs');
864
- if (tabs) tabs.innerHTML = '<div class="orch-term-empty" style="padding:6px 8px;font-size:11px;">No active sessions</div>';
865
- var body = document.getElementById('orch-term-body');
866
- if (body) body.innerHTML = '<div class="orch-term-empty"><div>Select a session or run a story card</div></div>';
867
- closeOrchPanel();
868
- }
869
- }
870
- }
871
-
872
- function stopActiveSession() {
873
- if (_panelActive) stopStory(_panelActive);
874
- }
875
-
876
- function clearActiveTerminal() {
877
- if (!_panelActive || !_sessions[_panelActive]) return;
878
- _sessions[_panelActive].termEl.innerHTML = '';
879
- _sessions[_panelActive].fileOpBuf = [];
880
- var filesEl = document.getElementById('orch-files');
881
- if (filesEl) {
882
- filesEl.style.display = 'none';
883
- while (filesEl.children.length > 1) filesEl.removeChild(filesEl.lastChild);
884
- }
885
- }
886
-
887
- function openCleanSessions() {
888
- fetch(ORCH + '/api/clean-sessions', { method: 'POST',
889
- headers: { 'Content-Type': 'application/json' },
890
- body: JSON.stringify({ olderThanDays: 7 }) })
891
- .then(function(r) { return r.json(); })
892
- .then(function(d) { showToast('Cleaned ' + (d.removed || 0) + ' sessions'); })
893
- .catch(function() { showToast('Clean sessions: start orchestrator first'); });
894
- }
895
-
896
- function _renderFileOpEl(fileOp) {
897
- var div = document.createElement('div');
898
- div.className = 'kt-file';
899
- var opClass = fileOp.op === 'write' ? 'op-w' : fileOp.op === 'bash' ? 'op-b' : 'op-r';
900
- var opLabel = fileOp.op === 'write' ? '✎' : fileOp.op === 'bash' ? '$' : '👁';
901
- var label = fileOp.path || fileOp.cmd || fileOp.tool;
902
- div.innerHTML = '<span class="op-icon ' + opClass + '">' + opLabel + '</span> ' + esc(String(label));
903
- return div;
904
- }
905
-
906
- // ── Terminal append (redirect to panel) ─────────────────────────
907
-
908
- function appendCardChunk(storyId, chunk) {
909
- var sess = _sessions[storyId];
910
- if (!sess) return;
911
- var last = sess.termEl.lastElementChild;
912
- if (!last || !last.classList.contains('kt-stream')) {
913
- last = document.createElement('div');
914
- last.className = 'kt-stream';
915
- sess.termEl.appendChild(last);
916
- }
917
- last.textContent += chunk;
918
- if (_panelActive === storyId) {
919
- var body = document.getElementById('orch-term-body');
920
- if (body) body.scrollTop = body.scrollHeight;
921
- }
922
- }
923
-
924
- function appendCardLog(storyId, line) {
925
- var sess = _sessions[storyId];
926
- if (!sess) return;
927
- var div = document.createElement('div');
928
- var cls = 'kt-line';
929
- if (line.startsWith('⚙')) cls += ' tool';
930
- else if (line.startsWith('⚠')) cls += ' warn';
931
- else if (line.startsWith('✗')) cls += ' err';
932
- else if (line.startsWith('✅')) cls += ' done-line';
933
- else if (line.startsWith('▶') || line.startsWith('◉') || line.startsWith('■')) cls += ' meta';
934
- div.className = cls;
935
- div.textContent = line;
936
- sess.termEl.appendChild(div);
937
- if (_panelActive === storyId) {
938
- var body = document.getElementById('orch-term-body');
939
- if (body) body.scrollTop = body.scrollHeight;
940
- }
941
- }
942
-
943
- function appendCardFileOp(storyId, fileOp) {
944
- var sess = _sessions[storyId];
945
- if (!sess) return;
946
- sess.fileOpBuf.push(fileOp);
947
- if (_panelActive === storyId) {
948
- var filesEl = document.getElementById('orch-files');
949
- if (filesEl) { filesEl.style.display = ''; filesEl.appendChild(_renderFileOpEl(fileOp)); }
950
- }
951
- }
952
-
953
- // ── Run / stop ───────────────────────────────────────────────────
954
-
955
- function runStory(storyId) {
956
- if (!storyId) return;
957
- var card = getCard(storyId);
958
- var title = card ? (card.querySelector('.kanban-card-title') || {}).textContent : storyId;
959
- createPanelTab(storyId, title || storyId);
960
- openOrchPanel(storyId);
961
- appendCardLog(storyId, '▶ Starting: ' + storyId);
962
-
963
- fetch(ORCH + '/api/run', {
964
- method: 'POST',
965
- headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (window.__ORCH_TOKEN__ || '') },
966
- body: JSON.stringify({ storyId }),
967
- })
968
- .then(function(r) { return r.json(); })
969
- .then(function(data) {
970
- if (data.error) { appendCardLog(storyId, '✗ ' + data.error); setTabStatus(storyId, 'error'); return; }
971
- appendCardLog(storyId, '▶ pid ' + data.pid);
972
- setTabStatus(storyId, 'running');
973
- moveKanbanCard(storyId, 'in_progress');
974
- connectOrchestratorStream(storyId);
975
- })
976
- .catch(function(err) {
977
- appendCardLog(storyId, '✗ Orchestrator unreachable — ' + err.message);
978
- appendCardLog(storyId, ' Start with: node server/dashboard.js');
979
- setTabStatus(storyId, 'error');
980
- });
981
- }
982
-
983
- function stopStory(storyId) {
984
- appendCardLog(storyId, '■ Stopping…');
985
- fetch(ORCH + '/api/stop', {
986
- method: 'POST',
987
- headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (window.__ORCH_TOKEN__ || '') },
988
- body: JSON.stringify({ storyId }),
989
- }).catch(function() {});
990
- }
991
-
992
- // ── SSE stream ───────────────────────────────────────────────────
993
-
994
- function connectOrchestratorStream(storyId) {
995
- if (_orchStreams[storyId]) _orchStreams[storyId].close();
996
- var es = new EventSource(ORCH + '/api/stream/' + encodeURIComponent(storyId));
997
- _orchStreams[storyId] = es;
998
-
999
- es.onmessage = function(e) {
1000
- try {
1001
- var d = JSON.parse(e.data);
1002
- if (d.chunk) appendCardChunk(storyId, d.chunk);
1003
- if (d.line) appendCardLog(storyId, d.line);
1004
- if (d.fileOp) appendCardFileOp(storyId, d.fileOp);
1005
- if (d.status) {
1006
- var st = d.status;
1007
- setTabStatus(storyId, st);
1008
- if (st === 'done') { appendCardLog(storyId, '✅ Done'); moveKanbanCard(storyId, 'done'); }
1009
- if (st === 'error') moveKanbanCard(storyId, 'blocked');
1010
- if (st === 'stopped') appendCardLog(storyId, '■ Stopped');
1011
- if (st !== 'running') {
1012
- es.close(); delete _orchStreams[storyId];
1013
- var stopBtn = document.getElementById('orch-stop-btn');
1014
- if (stopBtn && _panelActive === storyId) stopBtn.style.display = 'none';
1015
- _updateOrchDot();
1016
- }
1017
- }
1018
- } catch {}
1019
- };
1020
- es.onerror = function() {
1021
- es.close(); delete _orchStreams[storyId];
1022
- setTabStatus(storyId, 'error');
1023
- _updateOrchDot();
1024
- };
1025
- _updateOrchDot();
1026
- }
1027
-
1028
- function _updateOrchDot() {
1029
- var running = Object.keys(_orchStreams).length;
1030
- // Global orch status dot in kanban topbar
1031
- var dot = document.getElementById('orch-dot');
1032
- if (dot) {
1033
- dot.className = 'orch-status-dot' + (running > 0 ? ' up' : '');
1034
- }
1035
- // Panel orch dot
1036
- var pdot = document.getElementById('orch-panel-orch-dot');
1037
- if (pdot) {
1038
- pdot.className = 'orch-status-dot' + (running > 0 ? ' up' : '');
1039
- }
1040
- }
1041
-
1042
- function refreshOrchestratorStatus() {
1043
- fetch(ORCH + '/api/status', { headers: { 'Authorization': 'Bearer ' + (window.__ORCH_TOKEN__ || '') } })
1044
- .then(function(r) { return r.json(); })
1045
- .then(function(status) {
1046
- // Mark orch as reachable
1047
- var dot = document.getElementById('orch-dot');
1048
- if (dot && !Object.keys(_orchStreams).length) dot.className = 'orch-status-dot';
1049
- for (var sid in status) {
1050
- var info = status[sid];
1051
- if (info.status === 'running') {
1052
- moveKanbanCard(sid, 'in_progress');
1053
- if (!_orchStreams[sid]) {
1054
- var card = getCard(sid);
1055
- var title = card ? (card.querySelector('.kanban-card-title') || {}).textContent : sid;
1056
- createPanelTab(sid, title);
1057
- connectOrchestratorStream(sid);
1058
- }
1059
- } else if (info.status === 'done') {
1060
- moveKanbanCard(sid, 'done');
1061
- }
1062
- // Restore last session logs from /api/status if available
1063
- if (info.logs && info.logs.length && !_sessions[sid]) {
1064
- _ensureSession(sid, sid);
1065
- createPanelTab(sid, sid);
1066
- info.logs.forEach(function(line) { appendCardLog(sid, line); });
1067
- setTabStatus(sid, info.status);
1068
- }
1069
- }
1070
- })
1071
- .catch(function() {
1072
- var dot = document.getElementById('orch-dot');
1073
- if (dot) dot.className = 'orch-status-dot down';
1074
- });
1075
- }
1076
-
1077
- // ── Move card between columns ────────────────────────────────────
1078
-
1079
- function moveKanbanCard(storyId, colId) {
1080
- var card = getCard(storyId);
1081
- var colBody = document.querySelector('.kanban-col[data-col="' + colId + '"] .kanban-col-body');
1082
- if (!card || !colBody) return;
1083
- colBody.appendChild(card);
1084
-
1085
- // Update card class
1086
- card.className = card.className.replace(/\bs-\w+\b/g, '').replace(/\brunning\b/g, '').trim();
1087
- card.classList.add('s-' + colId);
1088
- if (colId === 'in_progress') card.classList.add('running');
1089
-
1090
- // Swap action button
1091
- var actions = card.querySelector('.kanban-card-actions');
1092
- if (actions) {
1093
- if (colId === 'in_progress') {
1094
- actions.innerHTML =
1095
- '<button class="kanban-stop-btn" data-action="stop">■ Stop</button>' +
1096
- '<button class="kanban-view-btn" data-action="view">↗ View</button>';
1097
- } else if (colId === 'done') {
1098
- actions.innerHTML = '<button class="kanban-view-btn" data-action="view">↗ Logs</button>';
1099
- } else {
1100
- actions.innerHTML = '<button class="kanban-run-btn" data-action="run">▶ Run</button>';
1101
- }
1102
- wireKanbanCardButtons(card);
1103
- }
1104
-
1105
- // Running indicator
1106
- var ind = card.querySelector('.card-run-indicator');
1107
- if (colId === 'in_progress' && !ind) {
1108
- var indEl = document.createElement('div');
1109
- indEl.className = 'card-run-indicator';
1110
- indEl.id = 'run-ind-' + storyId;
1111
- indEl.innerHTML = '<span class="run-pulse"></span>running';
1112
- card.insertBefore(indEl, actions);
1113
- } else if (colId !== 'in_progress' && ind) {
1114
- ind.remove();
1115
- }
1116
-
1117
- refreshKanbanCounts();
1118
- }
1119
-
1120
- function wireKanbanLogButtons() {} // compat shim
1121
-
1122
- function wireKanbanCardButtons(card) {
1123
- var sid = card.dataset.storyId;
1124
- if (!sid) return;
1125
- card.querySelectorAll('[data-action="run"]').forEach(function(btn) {
1126
- btn.onclick = function(e) { e.stopPropagation(); runStory(sid); };
1127
- });
1128
- card.querySelectorAll('[data-action="stop"]').forEach(function(btn) {
1129
- btn.onclick = function(e) { e.stopPropagation(); stopStory(sid); };
1130
- });
1131
- card.querySelectorAll('[data-action="view"]').forEach(function(btn) {
1132
- btn.onclick = function(e) { e.stopPropagation(); openOrchPanel(sid); };
1133
- });
1134
- }
1135
-
1136
- function wireKanbanDnd() {
1137
- let dragged = null;
1138
- document.querySelectorAll('.kanban-card').forEach(card => {
1139
- wireKanbanCardButtons(card);
1140
- card.addEventListener('dragstart', e => {
1141
- if (e.target.tagName === 'BUTTON') { e.preventDefault(); return; }
1142
- dragged = card; card.style.opacity = '0.5';
1143
- });
1144
- card.addEventListener('dragend', () => {
1145
- dragged = null; if (card) card.style.opacity = '';
1146
- });
1147
- });
1148
- document.querySelectorAll('.kanban-col-body').forEach(body => {
1149
- body.addEventListener('dragover', e => { e.preventDefault(); body.classList.add('drag-target'); });
1150
- body.addEventListener('dragleave', () => body.classList.remove('drag-target'));
1151
- body.addEventListener('drop', e => {
1152
- e.preventDefault();
1153
- body.classList.remove('drag-target');
1154
- if (!dragged) return;
1155
- body.appendChild(dragged);
1156
- dragged.style.opacity = '';
1157
- refreshKanbanCounts();
1158
- showToast('Moved (visual only — not persisted)');
1159
- });
1160
- });
1161
- }
1162
-
1163
- function refreshKanbanCounts() {
1164
- document.querySelectorAll('.kanban-col').forEach(col => {
1165
- const n = col.querySelectorAll('.kanban-card').length;
1166
- const badge = col.querySelector('.kanban-count');
1167
- if (badge) badge.textContent = n;
1168
- });
1169
- }
1170
-
1171
- // #283: view plan file
1172
- async function viewPlanFile(phaseId) {
1173
- // Try to find the plan file via the file tree
1174
- const padded = String(phaseId).padStart(2, '0');
1175
- const items = document.querySelectorAll('.file-tree-item');
1176
- for (const item of items) {
1177
- const p = item.dataset.path || '';
1178
- if (p.includes('phases') && p.includes(padded) && (p.includes('PLAN') || p.includes('SPRINT'))) {
1179
- item.click();
1180
- return;
1181
- }
1182
- }
1183
- // Fallback: navigate to files view
1184
- navTo('files');
1185
- }
1186
-
1187
- // ---- Tree toggle (with #311 animation) ----
1188
- function toggleNode(row) {
1189
- const children = row.nextElementSibling;
1190
- const chevron = row.querySelector('.tree-chevron');
1191
- if (!children) return;
1192
- const open = children.style.display !== 'none';
1193
- children.style.display = open ? 'none' : 'block';
1194
- if (chevron) chevron.textContent = open ? '▶' : '▼';
1195
- }
1196
-
1197
- // #277: collapse/expand all roadmap nodes
1198
- function toggleAllRoadmap(expand) {
1199
- document.querySelectorAll('#roadmap-tree .tree-children').forEach(c => {
1200
- c.style.display = expand ? 'block' : 'none';
1201
- });
1202
- document.querySelectorAll('#roadmap-tree .tree-chevron').forEach(ch => {
1203
- ch.textContent = expand ? '▼' : '▶';
1204
- });
1205
- // Keep root open
1206
- const root = document.querySelector('#roadmap-tree .tree-ms > .tree-row + .tree-children');
1207
- if (root) root.style.display = 'block';
1208
- const rootChev = document.querySelector('#roadmap-tree .tree-ms > .tree-row .tree-chevron');
1209
- if (rootChev) rootChev.textContent = '▼';
1210
- }
1211
-
1212
- // ---- Hash router ----
1213
- function navTo(hash) { location.hash = hash; }
1214
-
1215
- function route() {
1216
- const raw = location.hash.slice(1) || 'overview';
1217
- const slash = raw.indexOf('/');
1218
- const view = slash === -1 ? raw : raw.slice(0, slash);
1219
- const subId = slash === -1 ? null : raw.slice(slash + 1);
1220
-
1221
- // Fix #264: highlight active nav on reload
1222
- document.querySelectorAll('.nav-link[data-view]').forEach(l =>
1223
- l.classList.toggle('active', l.dataset.view === view));
1224
- document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
1225
- const el = document.getElementById('view-' + view);
1226
- if (el) {
1227
- el.classList.add('active');
1228
- } else {
1229
- // Fix #263: unknown hash routes show overview instead of blank
1230
- document.getElementById('view-overview')?.classList.add('active');
1231
- document.querySelector('.nav-link[data-view="overview"]')?.classList.add('active');
1232
- }
1233
-
1234
- // #310: scroll to top on view switch
1235
- document.querySelector('.content-area')?.scrollTo(0, 0);
1236
-
1237
- // Close orchestrator panel when leaving kanban — it's fixed-position and overlaps other views
1238
- if (view !== 'kanban') closeOrchPanel();
1239
-
1240
- if (view === 'overview') renderOverview();
1241
- else if (view === 'roadmap') renderRoadmap();
1242
- else if (view === 'milestones') renderMilestones(subId);
1243
- else if (view === 'phases') renderPhases(subId);
1244
- else if (view === 'sprints') renderSprints(subId);
1245
- else if (view === 'tasks') renderTasks();
1246
- else if (view === 'kanban') renderKanban();
1247
- else if (view === 'decisions') renderDecisions();
1248
- else if (view === 'memory') renderMemory();
1249
- }
1250
-
1251
- function renderMemory() {
1252
- const el = document.getElementById('view-memory-content');
1253
- if (!el) return;
1254
- el.innerHTML = '<div class="view-title">🧠 Memory Bank</div><div class="empty">Loading…</div>';
1255
- fetch('/api/memory').then(r => r.json()).then(m => {
1256
- if (!m.exists) {
1257
- el.innerHTML = '<div class="view-title">🧠 Memory Bank</div>' +
1258
- '<div class="empty"><h3 style="color:var(--rihal-gold);">Not initialised</h3>' +
1259
- '<p>The Memory Bank is rcode\\'s structured project context.</p>' +
1260
- '<div class="empty-action">Run <code>/rcode:memory-init</code> to bootstrap</div></div>';
1261
- return;
1262
- }
1263
- let h = '<div class="view-title">🧠 Memory Bank</div>';
1264
- if (!m.initialised) {
1265
- h += '<div class="empty"><p>Directory exists but INDEX.md is missing — re-run <code>/rcode:memory-init</code></p></div>';
1266
- el.innerHTML = h;
1267
- return;
1268
- }
1269
- const sections = m.sections || {};
1270
- h += '<div class="filter-bar"><span style="color:var(--text-muted);font-size:var(--text-sm);">Last scanned: ' + esc(m.lastScanned) + '</span></div>';
1271
- h += '<div id="memory-sections">';
1272
- for (const [section, files] of Object.entries(sections)) {
1273
- h += '<div class="memory-group-header">' + esc(section) + '</div>';
1274
- h += '<div class="decision-list">';
1275
- for (const f of files) {
1276
- const status = f.exists ? (f.populated ? '✓' : '○') : '✗';
1277
- const meta = f.exists ? (f.populated ? 'populated' : 'template only') : 'missing';
1278
- h += '<div class="item">' +
1279
- '<div class="item-title">' + status + ' ' + esc(f.name) + '</div>' +
1280
- '<div class="item-meta">' + esc(meta) + ' · ' + (f.bytes || 0) + ' bytes</div>' +
1281
- '</div>';
1282
- }
1283
- h += '</div>';
1284
- }
1285
- function listGroup(label, items) {
1286
- if (!items || !items.length) return '';
1287
- let g = '<div class="memory-group-header">' + esc(label) + ' (' + items.length + ')</div>';
1288
- g += '<div class="decision-list">';
1289
- for (const f of items) {
1290
- g += '<div class="item">' +
1291
- '<div class="item-title">' + esc(f.name) + '</div></div>';
1292
- }
1293
- g += '</div>';
1294
- return g;
1295
- }
1296
- h += listGroup('Distillates', m.distillates);
1297
- h += listGroup('Change Records', m.changeRecords);
1298
- h += listGroup('Milestone Archive', m.archive);
1299
- h += listGroup('Post-mortems', m.postMortems);
1300
- h += '</div>';
1301
- h += cmdAccordion([
1302
- cmdHint('/rcode:memory-init', 'Bootstrap the Memory Bank'),
1303
- cmdHint('/rcode:memory-update', 'Append a decision, issue, or stakeholder entry'),
1304
- cmdHint('/rcode:memory-distill', 'Regenerate fast-load distillates'),
1305
- cmdHint('/rcode:memory-audit', 'Find stale entries and gaps')
1306
- ]);
1307
- el.innerHTML = h;
1308
- }).catch(err => {
1309
- el.innerHTML = '<div class="view-title">🧠 Memory Bank</div><div class="empty">Failed to load /api/memory: ' + esc(String(err)) + '</div>';
1310
- });
1311
- }
1312
-
1313
- function renderDecisions() {
1314
- const el = document.getElementById('view-decisions');
1315
- if (!el) return;
1316
- const decisions = S.decisions || [];
1317
- if (!decisions.length) {
1318
- el.innerHTML = '<div class="view-title">Decisions (ADRs)</div>' +
1319
- '<div class="empty">No decisions recorded yet.<div class="empty-action">Decisions made during /rihal-council appear here</div></div>';
1320
- return;
1321
- }
1322
- // #307: group by phase
1323
- const grouped = {};
1324
- for (const d of decisions) {
1325
- const phase = (typeof d === 'object' ? d.phase : null) || 'General';
1326
- if (!grouped[phase]) grouped[phase] = [];
1327
- grouped[phase].push(d);
1328
- }
1329
- let h = '<div class="view-title">Decisions (ADRs)</div>' +
1330
- '<div class="filter-bar"><input class="filter-input" type="text" placeholder="Filter…" oninput="filterItems(this,\\'decisions-inner\\')"></div>' +
1331
- '<div id="decisions-inner">';
1332
- for (const [phase, decs] of Object.entries(grouped)) {
1333
- h += '<div class="memory-group-header">' + esc(phase) + '</div>';
1334
- h += '<div class="decision-list">';
1335
- for (const d of decs) {
1336
- const title = typeof d === 'string' ? d : (d.title || d.summary || d.decision || JSON.stringify(d).slice(0, 80));
1337
- const filterText = String(title).toLowerCase();
1338
- // #306: date and phase context
1339
- const dateInfo = (typeof d === 'object' && d.date) ? '<span style="color:var(--text-muted);font-size:var(--text-xs);margin-left:8px;">' + humanDate(d.date) + '</span>' : '';
1340
- const phaseInfo = (typeof d === 'object' && d.phase) ? tag('Phase ' + d.phase) : '';
1341
- h += '<div class="item" data-filter-text="' + esc(filterText) + '">' +
1342
- '<div class="item-title">' + esc(title) + dateInfo + '</div>' +
1343
- '<div class="item-meta">' + phaseInfo + '</div>' +
1344
- // #308: rationale
1345
- (typeof d === 'object' && d.rationale ? '<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">' + esc(d.rationale) + '</div>' : '') +
1346
- '</div>';
1347
- }
1348
- h += '</div>';
1349
- }
1350
- h += '</div>';
1351
- el.innerHTML = h + cmdAccordion([
1352
- cmdHint('/rihal-council', 'Convene the council for a new decision'),
1353
- cmdHint('/rihal-discuss [agent] \"topic\"', 'Discuss with a specific expert'),
1354
- cmdHint('/rihal-decisions', 'View decision log')
1355
- ]);
1356
- }
1357
-
1358
- window.addEventListener('hashchange', route);
1359
- document.querySelectorAll('.nav-link[data-view]').forEach(l =>
1360
- l.addEventListener('click', () => navTo(l.dataset.view)));
1361
-
1362
- // ---- Inline filter ----
1363
- function filterItems(input, listId) {
1364
- const q = input.value.toLowerCase().trim();
1365
- const el = document.getElementById(listId);
1366
- if (!el) return;
1367
- // Target both list items and agent cards
1368
- el.querySelectorAll('.item, .agent-card').forEach(item => {
1369
- item.style.display = !q || item.textContent.toLowerCase().includes(q) ? '' : 'none';
1370
- });
1371
- }
1372
-
1373
- // ---- Shared file-list fetch (single request for all consumers) ----
1374
- const _filesPromise = fetch('/api/files').then(function(r) { return r.json(); }).catch(function() { return []; });
1375
-
1376
- // Inline file list inside Files view
1377
- (async function() {
1378
- let groups = [];
1379
- try { groups = await _filesPromise; } catch { return; }
1380
- const el = document.getElementById('file-list-inline');
1381
- if (!el) return;
1382
- let h = '<div class="filter-bar"><input class="filter-input" type="text" placeholder="Search files…" oninput="filterInlineFiles(this.value)"></div>';
1383
- h += '<div id="inline-file-items" class="phase-list">';
1384
-
1385
- function renderFileItem(f, extraFilterText) {
1386
- var filterText = esc(f.label + ' ' + f.path + (extraFilterText ? ' ' + extraFilterText : '')).toLowerCase();
1387
- return '<div class="item item-clickable inline-file-entry" data-path="' + esc(f.path) + '" data-filter-text="' + filterText + '" onclick="loadInlineFile(this)" style="padding:var(--space-2) var(--space-3);font-family:\\'SF Mono\\',Monaco,Consolas,monospace;font-size:var(--text-xs);">' + esc(f.label) + '</div>';
1388
- }
1389
14
 
1390
- groups.forEach(function(g) {
1391
- h += '<div class="inline-file-group" style="margin-bottom:var(--space-3);">';
1392
- h += '<div style="font-size:var(--text-xs);font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.07em;padding:var(--space-1) var(--space-3);">' + esc(g.group) + '</div>';
1393
- if (g.subGroups) {
1394
- // Render expandable sub-groups (e.g. per-phase)
1395
- g.subGroups.forEach(function(sg) {
1396
- h += '<details class="inline-subgroup" open style="margin-left:var(--space-2);margin-bottom:var(--space-1);">';
1397
- h += '<summary style="font-size:var(--text-xs);font-weight:500;color:var(--text-secondary);cursor:pointer;padding:var(--space-1) var(--space-3);user-select:none;">' + esc(sg.subGroup) + ' <span style="color:var(--text-muted);font-weight:400;">(' + sg.files.length + ')</span></summary>';
1398
- sg.files.forEach(function(f) {
1399
- h += renderFileItem(f, sg.subGroup);
1400
- });
1401
- h += '</details>';
1402
- });
1403
- } else if (g.files) {
1404
- g.files.forEach(function(f) {
1405
- h += renderFileItem(f, '');
1406
- });
1407
- }
1408
- h += '</div>';
1409
- });
1410
- h += '</div>';
1411
- el.innerHTML = h;
1412
- })();
1413
- function filterInlineFiles(q) {
1414
- q = q.toLowerCase().trim();
1415
- document.querySelectorAll('#inline-file-items .inline-file-entry').forEach(function(item) {
1416
- item.style.display = !q || (item.dataset.filterText || '').includes(q) ? '' : 'none';
1417
- });
1418
- }
1419
- async function loadInlineFile(el) {
1420
- var fv = document.getElementById('file-view');
1421
- if (!fv) return;
1422
- fv.innerHTML = '<div class="skeleton"></div><div class="skeleton" style="height:200px;"></div>';
1423
- // Scroll file content into view immediately
1424
- fv.scrollIntoView({ behavior: 'smooth', block: 'start' });
1425
- document.querySelectorAll('.inline-file-entry').forEach(function(e) { e.style.borderLeftColor = ''; });
1426
- el.style.borderLeftColor = 'var(--accent-blue)';
1427
- // Also sync sidebar selection
1428
- document.querySelectorAll('.file-tree-item').forEach(function(e) {
1429
- e.classList.toggle('selected', e.dataset.path === el.dataset.path);
1430
- });
1431
- try {
1432
- var resp = await fetch('/api/file?path=' + encodeURIComponent(el.dataset.path));
1433
- if (!resp.ok) { fv.innerHTML = '<div style="color:var(--accent-red);padding:16px;">Failed to load file.</div>'; return; }
1434
- var text = await resp.text();
1435
- fv.innerHTML = '<div class="file-path-header"><span>' + esc(el.dataset.path) + '</span>' +
1436
- '<button class="copy-btn" onclick="navigator.clipboard.writeText(\\'' + el.dataset.path.replace(/'/g, "\\\\'") + '\\');showToast(\\'Path copied!\\')">📋 Copy</button></div>' +
1437
- '<div class="md-render">' + renderMd(text) + '</div>';
1438
- } catch { fv.innerHTML = '<div style="color:var(--accent-red);padding:16px;">Network error.</div>'; }
1439
- }
1440
-
1441
- // ---- Markdown + frontmatter ----
1442
- function stripFrontmatter(md) {
1443
- if (!md.startsWith('---')) return md;
1444
- var end = md.indexOf('\\n---', 3);
1445
- return end === -1 ? md : md.slice(end + 4).trimStart();
1446
- }
1447
- function renderMd(md) {
1448
- var clean = stripFrontmatter(md);
1449
- return (typeof marked !== 'undefined') ? marked.parse(clean) : '<pre>' + clean.replace(/</g,'&lt;') + '</pre>';
1450
- }
1451
-
1452
- // ---- Open file from phase card ----
1453
- async function openFile(filePath) {
1454
- navTo('files');
1455
- document.querySelectorAll('.file-tree-item').forEach(function(el) {
1456
- el.classList.toggle('selected', el.dataset.path === filePath);
1457
- });
1458
- var fv = document.getElementById('file-view');
1459
- if (!fv) return;
1460
- fv.innerHTML = '<div class="skeleton"></div><div class="skeleton" style="height:200px;"></div>';
1461
- try {
1462
- var resp = await fetch('/api/file?path=' + encodeURIComponent(filePath));
1463
- if (!resp.ok) { fv.innerHTML = '<div style="color:var(--accent-red);padding:var(--space-8);">Failed.</div>'; return; }
1464
- var text = await resp.text();
1465
- fv.innerHTML = '<div class="file-path-header"><span>' + esc(filePath) + '</span></div>' +
1466
- '<div class="md-render">' + renderMd(text) + '</div>';
1467
- } catch { fv.innerHTML = '<div style="color:var(--accent-red);padding:var(--space-8);">Network error.</div>'; }
1468
- }
1469
-
1470
- // ---- Refresh ----
1471
- var _lastScanned = ${JSON.stringify(state.lastScanned)};
1472
- var _scanTime = Date.now();
1473
- function renderUpdatedAgo() {
1474
- var s = Math.floor((Date.now() - _scanTime) / 1000);
1475
- var el = document.getElementById('updated-ago');
1476
- if (el) el.textContent = s < 5 ? 'just now' : s < 60 ? s + 's ago' : Math.floor(s/60) + 'm ago';
1477
- }
1478
- setInterval(renderUpdatedAgo, 1000);
1479
-
1480
- // #262: hot-swap without full page reload
1481
- async function fetchAndRerender() {
1482
- var btn = document.getElementById('refresh-btn');
1483
- if (btn) btn.textContent = '↺ …';
1484
- try {
1485
- var r = await fetch('/api/state');
1486
- var newState = await r.json();
1487
- _lastScanned = newState.lastScanned;
1488
- _scanTime = Date.now();
1489
- renderUpdatedAgo();
1490
- // Update embedded data
1491
- if (newState.raw) {
1492
- S.phases = newState.raw.phases || [];
1493
- S.milestone = newState.raw.milestone || '';
1494
- S.currentPhase = newState.raw.current_phase || null;
1495
- S.currentSprint = newState.raw.current_sprint || null;
1496
- S.decisions = newState.raw.decisions || [];
1497
- S.blockers = newState.raw.blockers || [];
1498
- S.council_sessions = newState.raw.council_sessions || [];
1499
- S.last_session = newState.raw.last_session || null;
1500
- _phases.length = 0;
1501
- _phases.push(...S.phases);
1502
- }
1503
- // #261: re-render active view
1504
- route();
1505
- } catch {}
1506
- if (btn) btn.textContent = '↺ Refresh';
1507
- }
1508
- setInterval(async function() {
1509
- try {
1510
- var r = await fetch('/api/state');
1511
- var s = await r.json();
1512
- if (s.lastScanned !== _lastScanned) fetchAndRerender();
1513
- } catch {}
1514
- }, 30000);
1515
- function manualRefresh() { fetchAndRerender(); }
1516
-
1517
- // ---- Blocker banner ----
1518
- (function() {
1519
- // #317: allow re-show via custom event
1520
- if (sessionStorage.getItem('blockers-dismissed') === '1') {
1521
- var b = document.getElementById('blocker-banner');
1522
- if (b) b.style.display = 'none';
1523
- }
1524
- })();
1525
- function dismissBlockers() {
1526
- sessionStorage.setItem('blockers-dismissed','1');
1527
- var b = document.getElementById('blocker-banner');
1528
- if (b) b.style.display = 'none';
1529
- }
1530
- function showBlockers() {
1531
- sessionStorage.removeItem('blockers-dismissed');
1532
- var b = document.getElementById('blocker-banner');
1533
- if (b) b.style.display = '';
1534
- }
1535
-
1536
- // #309: keyboard shortcuts
1537
- document.addEventListener('keydown', function(e) {
1538
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
1539
- var key = e.key.toLowerCase();
1540
- if (key === 'r' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); manualRefresh(); }
1541
- else if (key === 'f') { e.preventDefault(); var fi = document.querySelector('.view.active .filter-input'); if (fi) fi.focus(); }
1542
- else if (key === '1') navTo('overview');
1543
- else if (key === '2') navTo('roadmap');
1544
- else if (key === '3') navTo('milestones');
1545
- else if (key === '4') navTo('phases');
1546
- else if (key === '5') navTo('sprints');
1547
- else if (key === '6') navTo('tasks');
1548
- else if (key === '7') navTo('files');
1549
- else if (key === '8') navTo('agents');
1550
- else if (key === '9') navTo('decisions');
1551
- // #277: E/C for expand/collapse all in roadmap
1552
- else if (key === 'e' && location.hash.includes('roadmap')) toggleAllRoadmap(true);
1553
- else if (key === 'c' && location.hash.includes('roadmap')) toggleAllRoadmap(false);
1554
- });
1555
-
1556
- // #318: export snapshot
1557
- function exportSnapshot() {
1558
- var data = JSON.stringify(S, null, 2);
1559
- var blob = new Blob([data], {type: 'application/json'});
1560
- var url = URL.createObjectURL(blob);
1561
- var a = document.createElement('a');
1562
- a.href = url; a.download = 'majlis-snapshot-' + new Date().toISOString().slice(0,10) + '.json';
1563
- a.click(); URL.revokeObjectURL(url);
1564
- showToast('Snapshot exported!');
1565
- }
1566
-
1567
- // #312: copy URL
1568
- function copyUrl() {
1569
- navigator.clipboard.writeText(location.href);
1570
- showToast('URL copied!');
1571
- }
1572
-
1573
- // #313: dark/light mode
1574
- function toggleTheme() {
1575
- var current = document.documentElement.getAttribute('data-theme');
1576
- var next = current === 'light' ? 'dark' : 'light';
1577
- document.documentElement.setAttribute('data-theme', next === 'dark' ? '' : next);
1578
- localStorage.setItem('majlis-theme', next);
1579
- var btn = document.getElementById('theme-btn');
1580
- if (btn) btn.textContent = next === 'light' ? '🌙' : '☀️';
1581
- }
1582
- (function() {
1583
- var saved = localStorage.getItem('majlis-theme');
1584
- if (saved === 'light') {
1585
- document.documentElement.setAttribute('data-theme', 'light');
1586
- var btn = document.getElementById('theme-btn');
1587
- if (btn) btn.textContent = '🌙';
1588
- }
1589
- })();
1590
-
1591
- // #324: dynamic title
1592
- var _origTitle = document.title;
1593
- function updateTitle() {
1594
- var view = (location.hash.slice(1) || 'overview').split('/')[0];
1595
- var viewNames = {overview:'Overview',roadmap:'Roadmap',milestones:'Milestones',phases:'Phases',sprints:'Sprints',tasks:'Tasks',files:'Files',agents:'Agents',decisions:'Decisions'};
1596
- document.title = (viewNames[view] || 'Overview') + ' — Majlis';
1597
- }
1598
- window.addEventListener('hashchange', updateTitle);
1599
-
1600
- // Sidebar toggle (hamburger menu)
1601
- function toggleSidebar() {
1602
- var sidebar = document.querySelector('.sidebar');
1603
- var backdrop = document.getElementById('sidebar-backdrop');
1604
- if (!sidebar) return;
1605
- var open = sidebar.classList.toggle('sidebar-open');
1606
- if (backdrop) backdrop.classList.toggle('active', open);
1607
- document.body.classList.toggle('sidebar-visible', open);
1608
- }
1609
- function closeSidebar() {
1610
- var sidebar = document.querySelector('.sidebar');
1611
- var backdrop = document.getElementById('sidebar-backdrop');
1612
- if (sidebar) sidebar.classList.remove('sidebar-open');
1613
- if (backdrop) backdrop.classList.remove('active');
1614
- document.body.classList.remove('sidebar-visible');
1615
- }
1616
-
1617
- // ---- Boot ----
1618
- route();
1619
- updateTitle();
1620
-
1621
- // ── xterm Terminal Panel ─────────────────────────────────────────────────────
1622
- var _term = null;
1623
- var _termFit = null;
1624
- var _termEvt = null;
1625
- var _termStoryId = null;
1626
-
1627
- function _orchToken() { return window.__ORCH_TOKEN__ || ''; }
1628
-
1629
- function openTermPanel(storyId, title) {
1630
- _termStoryId = storyId;
1631
- document.getElementById('term-title').textContent = title || storyId;
1632
- document.getElementById('term-panel').classList.add('open');
1633
- document.getElementById('term-backdrop').classList.add('open');
1634
- setTermDot('connecting');
1635
-
1636
- if (!_term && typeof Terminal !== 'undefined') {
1637
- _term = new Terminal({
1638
- theme: {
1639
- background: '#0c0c0e', foreground: '#c9d1d9',
1640
- cursor: '#58a6ff', selectionBackground: 'rgba(94,106,210,0.25)',
1641
- black: '#0c0c0e', red: '#ff4444', green: '#3fb950',
1642
- yellow: '#d29922', blue: '#58a6ff', magenta: '#bc8cff',
1643
- cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681',
1644
- },
1645
- fontFamily: '"JetBrains Mono","SF Mono",Consolas,monospace',
1646
- fontSize: 12, lineHeight: 1.4, convertEol: true,
1647
- scrollback: 5000, cursorStyle: 'bar',
1648
- });
1649
- if (typeof FitAddon !== 'undefined') {
1650
- _termFit = new FitAddon.FitAddon();
1651
- _term.loadAddon(_termFit);
1652
- }
1653
- _term.open(document.getElementById('term-container'));
1654
- if (_termFit) _termFit.fit();
1655
- _term.onData(function(data) {
1656
- var tok = _orchToken();
1657
- if (!_termStoryId || !tok) return;
1658
- fetch('http://localhost:7718/api/message', {
1659
- method: 'POST',
1660
- headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
1661
- body: JSON.stringify({ storyId: _termStoryId, data: data })
1662
- }).catch(function() {});
1663
- });
1664
- window.addEventListener('resize', function() { if (_termFit) _termFit.fit(); });
1665
- } else if (_term) {
1666
- _term.clear();
1667
- if (_termFit) _termFit.fit();
1668
- }
1669
-
1670
- if (_termEvt) { _termEvt.close(); _termEvt = null; }
1671
- var tok = _orchToken();
1672
- if (!tok) {
1673
- if (_term) _term.writeln('\\r\\x1b[31m✗ No orchestrator token — restart the dashboard\\x1b[0m');
1674
- return;
1675
- }
1676
-
1677
- if (_term) _term.writeln('\\x1b[90m── connecting to stream: ' + storyId + ' ──\\x1b[0m\\r\\n');
1678
-
1679
- var url = 'http://localhost:7718/api/stream/' + encodeURIComponent(storyId) + '?token=' + tok;
1680
- _termEvt = new EventSource(url);
1681
- _termEvt.onmessage = function(e) {
1682
- try {
1683
- var d = JSON.parse(e.data);
1684
- if (d.line) { if (_term) _term.writeln('\\r' + d.line); }
1685
- if (d.chunk) { if (_term) _term.write(d.chunk); }
1686
- if (d.fileOp) { if (_term) _term.writeln('\\r\\x1b[36m[' + d.fileOp.type + '] ' + d.fileOp.path + '\\x1b[0m'); }
1687
- if (d.status) {
1688
- setTermDot(d.status);
1689
- if (d.status === 'done' || d.status === 'error' || d.status === 'stopped') {
1690
- if (_term) _term.writeln('\\r\\n\\x1b[90m── session ' + d.status + ' ──\\x1b[0m');
1691
- if (_termEvt) { _termEvt.close(); _termEvt = null; }
1692
- } else {
1693
- setTermDot(d.status);
1694
- }
1695
- }
1696
- if (d.error) { if (_term) _term.writeln('\\r\\x1b[31m✗ ' + d.error + '\\x1b[0m'); }
1697
- } catch(ex) {}
1698
- };
1699
- _termEvt.onerror = function() {
1700
- if (_term) _term.writeln('\\r\\x1b[31m✗ stream disconnected\\x1b[0m');
1701
- setTermDot('error');
1702
- };
1703
- }
1704
-
1705
- function setTermDot(status) {
1706
- var dot = document.getElementById('term-status-dot');
1707
- if (dot) dot.className = 'term-status-dot ' + (status || '');
1708
- }
1709
-
1710
- function closeTermPanel() {
1711
- document.getElementById('term-panel').classList.remove('open');
1712
- document.getElementById('term-backdrop').classList.remove('open');
1713
- if (_termEvt) { _termEvt.close(); _termEvt = null; }
1714
- }
1715
-
1716
- function termStop() {
1717
- var tok = _orchToken();
1718
- if (!_termStoryId || !tok) return;
1719
- fetch('http://localhost:7718/api/stop', {
1720
- method: 'POST',
1721
- headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
1722
- body: JSON.stringify({ storyId: _termStoryId })
1723
- }).catch(function() {});
1724
- }
1725
-
1726
- function termSend() {
1727
- var inp = document.getElementById('term-input');
1728
- if (!inp) return;
1729
- var msg = (inp.value || '').trim();
1730
- if (!msg) return;
1731
- inp.value = '';
1732
- var tok = _orchToken();
1733
- if (!_termStoryId || !tok) return;
1734
- if (_term) _term.writeln('\\r\\x1b[90m[you] ' + msg + '\\x1b[0m');
1735
- fetch('http://localhost:7718/api/message', {
1736
- method: 'POST',
1737
- headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
1738
- body: JSON.stringify({ storyId: _termStoryId, data: msg + '\\n' })
1739
- }).catch(function() {});
1740
- }
1741
-
1742
- document.addEventListener('keydown', function(e) {
1743
- if (e.key === 'Escape') {
1744
- var panel = document.getElementById('term-panel');
1745
- if (panel && panel.classList.contains('open')) closeTermPanel();
1746
- }
1747
- });
1748
-
1749
- var _termInputEl = document.getElementById('term-input');
1750
- if (_termInputEl) {
1751
- _termInputEl.addEventListener('keydown', function(e) {
1752
- if (e.key === 'Enter') termSend();
1753
- });
1754
- }
1755
-
1756
- function runAndOpenTerm(storyId, cmd, title) {
1757
- var tok = _orchToken();
1758
- openTermPanel(storyId, title || storyId);
1759
- if (!tok) return;
1760
- fetch('http://localhost:7718/api/run', {
1761
- method: 'POST',
1762
- headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
1763
- body: JSON.stringify({ storyId: storyId, cmd: cmd })
1764
- }).then(function(r) { return r.json(); })
1765
- .then(function(data) {
1766
- if (data.error && data.error !== 'already running') {
1767
- if (_term) _term.writeln('\\r\\x1b[31m✗ ' + data.error + '\\x1b[0m');
1768
- } else if (data.error === 'already running') {
1769
- if (_term) _term.writeln('\\r\\x1b[33m⚠ Already running (pid ' + data.pid + ') — showing live output\\x1b[0m');
1770
- setTermDot('running');
1771
- }
15
+ const { ICONS } = require('./icons');
16
+
17
+ // Fields the client needs from the scanned state. Kept in sync with
18
+ // store.js initial state and the view components that read it.
19
+ function clientState(state) {
20
+ return JSON.stringify({
21
+ phases: state.phaseTree || state.raw?.phases || [],
22
+ milestone: state.raw?.milestone || '',
23
+ currentPhase: state.raw?.current_phase || null,
24
+ currentSprint: state.raw?.current_sprint || null,
25
+ decisions: state.raw?.decisions || [],
26
+ blockers: state.raw?.blockers || [],
27
+ council_sessions: state.raw?.council_sessions || [],
28
+ last_session: state.raw?.last_session || null,
29
+ chains: state.raw?.chains || [],
30
+ workstreams: state.raw?.workstreams || [],
31
+ pendingHandoff: state.pendingHandoff || null,
32
+ memoryBank: state.memoryBank || null,
1772
33
  })
1773
- .catch(function(err) {
1774
- if (_term) _term.writeln('\\r\\x1b[31m✗ Orchestrator unreachable: ' + err.message + '\\x1b[0m');
1775
- });
34
+ // Prevent a stray "</script>" inside any string from closing the inline
35
+ // <script> early. Escaping "<" keeps the JSON valid and inert.
36
+ .replace(/</g, '\\u003c');
1776
37
  }
1777
38
 
1778
- // Also fix existing kanban run/stop to use auth token
1779
- var _origRunStory = window.runStory;
1780
-
1781
- </script>`;
39
+ function renderClientJs(state) {
40
+ // Emit __ICONS__ so the Preact client can use the same icon set as the
41
+ // server without duplicating the map in a way that would require a build step.
42
+ const iconsJson = JSON.stringify(ICONS).replace(/</g, '\\u003c');
43
+ return [
44
+ `<script>window.__S__ = ${clientState(state)}; window.__ICONS__ = ${iconsJson};</script>`,
45
+ // Preact entry — type=module defers until HTML is parsed.
46
+ `<script type="module" src="/js/app.js"></script>`,
47
+ ].join('\n');
1782
48
  }
1783
49
 
1784
50
  module.exports = { renderClientJs };