@hanzlaa/rcode 3.5.0 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -1
- package/rihal/bin/rihal-tools.cjs +274 -31
- package/server/dashboard.js +105 -3
- package/server/lib/html/client/agents-data.js +27 -0
- package/server/lib/html/client/app.js +15 -0
- package/server/lib/html/client/components/App.js +211 -0
- package/server/lib/html/client/components/OrchPanel.js +293 -0
- package/server/lib/html/client/components/Sidebar.js +73 -0
- package/server/lib/html/client/components/Topbar.js +53 -0
- package/server/lib/html/client/components/XtermPanel.js +220 -0
- package/server/lib/html/client/components/shared.js +330 -0
- package/server/lib/html/client/icons-client.js +85 -0
- package/server/lib/html/client/orchestrator.js +280 -0
- package/server/lib/html/client/preact.js +34 -0
- package/server/lib/html/client/store.js +91 -0
- package/server/lib/html/client/util.js +186 -0
- package/server/lib/html/client/views/AgentsView.js +83 -0
- package/server/lib/html/client/views/DecisionsView.js +102 -0
- package/server/lib/html/client/views/FilesView.js +223 -0
- package/server/lib/html/client/views/KanbanView.js +236 -0
- package/server/lib/html/client/views/MemoryView.js +157 -0
- package/server/lib/html/client/views/MilestonesView.js +136 -0
- package/server/lib/html/client/views/OrchestrationView.js +167 -0
- package/server/lib/html/client/views/OverviewView.js +221 -0
- package/server/lib/html/client/views/PhasesView.js +184 -0
- package/server/lib/html/client/views/RoadmapView.js +238 -0
- package/server/lib/html/client/views/SprintsView.js +178 -0
- package/server/lib/html/client/views/TasksView.js +148 -0
- package/server/lib/html/client.js +41 -1775
- package/server/lib/html/css.js +265 -56
- package/server/lib/html/icons.js +68 -0
- package/server/lib/html/shell.js +9 -296
- package/server/lib/scanner.js +89 -0
- package/server/orchestrator.js +252 -310
|
@@ -1,1784 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client
|
|
3
|
-
*
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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,'<') + '</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
|
-
|
|
1774
|
-
|
|
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
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
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 };
|