@yemi33/minions 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dashboard/js/command-center.js +377 -0
  3. package/dashboard/js/command-history.js +70 -0
  4. package/dashboard/js/command-input.js +268 -0
  5. package/dashboard/js/command-parser.js +129 -0
  6. package/dashboard/js/detail-panel.js +98 -0
  7. package/dashboard/js/live-stream.js +69 -0
  8. package/dashboard/js/modal-qa.js +268 -0
  9. package/dashboard/js/modal.js +131 -0
  10. package/dashboard/js/refresh.js +59 -0
  11. package/dashboard/js/render-agents.js +17 -0
  12. package/dashboard/js/render-dispatch.js +148 -0
  13. package/dashboard/js/render-inbox.js +126 -0
  14. package/dashboard/js/render-kb.js +107 -0
  15. package/dashboard/js/render-other.js +181 -0
  16. package/dashboard/js/render-plans.js +304 -0
  17. package/dashboard/js/render-prd.js +469 -0
  18. package/dashboard/js/render-prs.js +94 -0
  19. package/dashboard/js/render-schedules.js +158 -0
  20. package/dashboard/js/render-skills.js +89 -0
  21. package/dashboard/js/render-work-items.js +219 -0
  22. package/dashboard/js/settings.js +135 -0
  23. package/dashboard/js/state.js +84 -0
  24. package/dashboard/js/utils.js +39 -0
  25. package/dashboard/layout.html +123 -0
  26. package/dashboard/pages/engine.html +12 -0
  27. package/dashboard/pages/home.html +31 -0
  28. package/dashboard/pages/inbox.html +17 -0
  29. package/dashboard/pages/plans.html +4 -0
  30. package/dashboard/pages/prd.html +5 -0
  31. package/dashboard/pages/prs.html +4 -0
  32. package/dashboard/pages/schedule.html +10 -0
  33. package/dashboard/pages/work.html +5 -0
  34. package/dashboard/styles.css +598 -0
  35. package/dashboard-build.js +51 -0
  36. package/dashboard.js +44 -1
  37. package/package.json +1 -1
@@ -0,0 +1,268 @@
1
+ // modal-qa.js — Modal Q&A (document chat) functions extracted from dashboard.html
2
+
3
+ let _modalDocContext = { title: '', content: '', selection: '' };
4
+ let _modalFilePath = null; // file path for steering (null = read-only Q&A only)
5
+ let _modalOriginalPlan = null; // tracks original plan file when editing a forked version
6
+
7
+ function showModalQa() {
8
+ document.getElementById('modal-qa').style.display = '';
9
+ }
10
+ let _qaHistory = []; // multi-turn conversation history [{role:'user',text:''},{role:'assistant',text:''}]
11
+ let _qaProcessing = false; // true while waiting for Haiku response
12
+ let _qaQueue = []; // queued messages while processing
13
+ let _qaSessionKey = ''; // key for current conversation (title or filePath)
14
+ const _qaSessions = new Map(); // persist conversations across modal open/close {key → {history, threadHtml}}
15
+ // Restore from localStorage
16
+ try {
17
+ const saved = JSON.parse(localStorage.getItem('qa-sessions') || '{}');
18
+ for (const [k, v] of Object.entries(saved)) _qaSessions.set(k, v);
19
+ } catch {}
20
+ function _saveQaSessions() {
21
+ try {
22
+ const obj = {};
23
+ // Only persist last 10 sessions, cap threadHtml at 50KB each
24
+ const entries = [..._qaSessions.entries()].slice(-10);
25
+ for (const [k, v] of entries) obj[k] = { ...v, threadHtml: (v.threadHtml || '').slice(0, 50000) };
26
+ localStorage.setItem('qa-sessions', JSON.stringify(obj));
27
+ } catch {}
28
+ }
29
+
30
+ function modalAskAboutSelection() {
31
+ document.getElementById('ask-selection-btn').style.display = 'none';
32
+
33
+ // If the modal isn't open but we have a selection (from detail panel), open modal for Q&A
34
+ const modal = document.getElementById('modal');
35
+ if (!modal.classList.contains('open')) {
36
+ document.getElementById('modal-title').textContent = 'Q&A: ' + (_modalDocContext.title || 'Document');
37
+ document.getElementById('modal-body').textContent = _modalDocContext.content.slice(0, 3000) + (_modalDocContext.content.length > 3000 ? '\n\n...(truncated for display)' : '');
38
+ modal.classList.add('open');
39
+ }
40
+
41
+ // Show the selection pill
42
+ const pill = document.getElementById('modal-qa-pill');
43
+ const pillText = document.getElementById('modal-qa-pill-text');
44
+ const sel = _modalDocContext.selection || '';
45
+ if (sel) {
46
+ pillText.textContent = sel.slice(0, 80) + (sel.length > 80 ? '...' : '');
47
+ pill.style.display = 'flex';
48
+ }
49
+
50
+ const input = document.getElementById('modal-qa-input');
51
+ input.value = '';
52
+ input.placeholder = 'What do you want to know about this?';
53
+ input.focus();
54
+ }
55
+
56
+ function clearQaSelection() {
57
+ _modalDocContext.selection = '';
58
+ document.getElementById('modal-qa-pill').style.display = 'none';
59
+ document.getElementById('modal-qa-input').placeholder = 'Ask about this document (or select text first)...';
60
+ }
61
+
62
+
63
+ function _initQaSession() {
64
+ var key = _modalFilePath || _modalDocContext.title || '';
65
+ if (!key || _qaSessionKey === key) return;
66
+ _qaSessionKey = key;
67
+ // Clear notification badge on the source card when reopening
68
+ const card = findCardForFile(_modalFilePath);
69
+ if (card) clearNotifBadge(card);
70
+ var prior = _qaSessions.get(key);
71
+ if (prior) {
72
+ _qaHistory = prior.history;
73
+ document.getElementById('modal-qa-thread').innerHTML = prior.threadHtml;
74
+ if (prior.docContext) {
75
+ // Preserve freshly-fetched content and title — prior session may have stale/empty content
76
+ const freshContent = _modalDocContext.content;
77
+ const freshTitle = _modalDocContext.title;
78
+ _modalDocContext = Object.assign({}, prior.docContext, {
79
+ selection: _modalDocContext.selection,
80
+ content: freshContent || prior.docContext.content || '',
81
+ title: freshTitle || prior.docContext.title || '',
82
+ });
83
+ }
84
+ if (prior.filePath) { _modalFilePath = prior.filePath; showModalQa(); }
85
+ document.getElementById('qa-clear-btn').style.display = 'block';
86
+ } else {
87
+ _qaHistory = [];
88
+ document.getElementById('modal-qa-thread').innerHTML = '';
89
+ document.getElementById('qa-clear-btn').style.display = 'none';
90
+ }
91
+ }
92
+
93
+ function clearQaConversation() {
94
+ _qaHistory = [];
95
+ _qaQueue = [];
96
+ _qaProcessing = false;
97
+ document.getElementById('modal-qa-thread').innerHTML = '';
98
+ document.getElementById('qa-clear-btn').style.display = 'none';
99
+ if (_qaSessionKey) _qaSessions.delete(_qaSessionKey);
100
+ }
101
+
102
+ function modalSend() {
103
+ var input = document.getElementById('modal-qa-input');
104
+ var message = input.value.trim();
105
+ if (!message) return;
106
+
107
+ if (!_modalDocContext.content) {
108
+ var body = document.getElementById('modal-body');
109
+ if (body) {
110
+ _modalDocContext.content = body.textContent || body.innerText || '';
111
+ _modalDocContext.title = document.getElementById('modal-title')?.textContent || '';
112
+ }
113
+ }
114
+ if (!_modalDocContext.content) {
115
+ showToast('cmd-toast', 'No document content', false);
116
+ return;
117
+ }
118
+
119
+ _initQaSession();
120
+ document.getElementById('qa-clear-btn').style.display = 'block';
121
+
122
+ var thread = document.getElementById('modal-qa-thread');
123
+ const selection = _modalDocContext.selection || '';
124
+
125
+ // Show message in thread immediately
126
+ let qHtml = '<div class="modal-qa-q">' + escHtml(message);
127
+ if (selection) {
128
+ qHtml += '<span class="selection-ref">Re: "' + escHtml(selection.slice(0, 100)) + ((selection.length > 100) ? '...' : '') + '"</span>';
129
+ }
130
+ qHtml += '</div>';
131
+ thread.innerHTML += qHtml;
132
+ thread.scrollTop = thread.scrollHeight;
133
+
134
+ // Clear input immediately so user can type next message
135
+ input.value = '';
136
+ _modalDocContext.selection = '';
137
+ document.getElementById('modal-qa-pill').style.display = 'none';
138
+
139
+ if (_qaProcessing) {
140
+ // Queue the message — show it as "queued" in the thread
141
+ _qaQueue.push({ message, selection });
142
+ const preview = message.split(/\s+/).slice(0, 6).join(' ') + (message.split(/\s+/).length > 6 ? '...' : '');
143
+ thread.innerHTML += '<div class="modal-qa-loading" style="color:var(--muted);font-size:10px">Queued: "' + escHtml(preview) + '" — will send after current response</div>';
144
+ thread.scrollTop = thread.scrollHeight;
145
+ return;
146
+ }
147
+
148
+ _processQaMessage(message, selection);
149
+ }
150
+
151
+ async function _processQaMessage(message, selection) {
152
+ const thread = document.getElementById('modal-qa-thread');
153
+ const btn = document.getElementById('modal-send-btn');
154
+ _qaProcessing = true;
155
+
156
+ // Capture state now — closeModal may null these while we're awaiting
157
+ const capturedFilePath = _modalFilePath;
158
+ const capturedDocContext = { ..._modalDocContext };
159
+
160
+ const loadingId = 'chat-loading-' + Date.now();
161
+ const qaQueueBadge = _qaQueue.length > 0 ? ' <span style="font-size:9px;color:var(--muted);background:var(--surface);padding:1px 5px;border-radius:8px;border:1px solid var(--border)">+' + _qaQueue.length + ' queued</span>' : '';
162
+ thread.innerHTML += '<div class="modal-qa-loading" id="' + loadingId + '">' +
163
+ '<div class="dot-pulse"><span></span><span></span><span></span></div> ' +
164
+ '<span id="' + loadingId + '-text">Thinking...</span> ' +
165
+ '<span id="' + loadingId + '-time" style="font-size:10px;color:var(--muted)"></span>' +
166
+ qaQueueBadge + '</div>';
167
+ thread.scrollTop = thread.scrollHeight;
168
+
169
+ const isPlanEdit = _modalFilePath && _modalFilePath.match(/^plans\/.*\.md$/);
170
+ const qaStartTime = Date.now();
171
+ const qaPhases = isPlanEdit
172
+ ? [[0,'Reading plan...'],[3000,'Analyzing structure...'],[8000,'Researching context...'],[15000,'Drafting revisions...'],[30000,'Writing updated plan...'],[60000,'Still working (large document)...'],[120000,'Deep edit in progress...'],[300000,'Almost there...']]
173
+ : [[0,'Thinking...'],[3000,'Reading document...'],[8000,'Analyzing...'],[20000,'Still working...'],[60000,'Taking a while...']];
174
+ const qaTimer = setInterval(() => {
175
+ const elapsed = Date.now() - qaStartTime;
176
+ const timeEl = document.getElementById(loadingId + '-time');
177
+ const textEl = document.getElementById(loadingId + '-text');
178
+ if (timeEl) timeEl.textContent = Math.floor(elapsed / 1000) + 's';
179
+ if (textEl) { for (let i = qaPhases.length - 1; i >= 0; i--) { if (elapsed >= qaPhases[i][0]) { textEl.textContent = qaPhases[i][1]; break; } } }
180
+ }, 500);
181
+
182
+ try {
183
+ const res = await fetch('/api/doc-chat', {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({
187
+ message,
188
+ document: capturedDocContext.content,
189
+ title: capturedDocContext.title,
190
+ selection: selection,
191
+ filePath: capturedFilePath || null,
192
+ }),
193
+ });
194
+ const data = await res.json();
195
+ clearInterval(qaTimer);
196
+ const loadingEl = document.getElementById(loadingId);
197
+ if (loadingEl) loadingEl.remove();
198
+
199
+ if (data.ok) {
200
+ const borderColor = data.edited ? 'var(--green)' : 'var(--blue)';
201
+ const suffix = data.edited ? '\n\n\u2713 Document saved.' : '';
202
+ const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
203
+ const qaTimeLabel = '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsed + 's</div>';
204
+ thread.innerHTML += '<div class="modal-qa-a" style="border-left-color:' + borderColor + '">' + llmCopyBtn() + escHtml(data.answer + suffix) + qaTimeLabel + '</div>';
205
+
206
+ // Track conversation history
207
+ _qaHistory.push({ role: 'user', text: message });
208
+ _qaHistory.push({ role: 'assistant', text: data.answer });
209
+
210
+ // Execute any CC actions (dispatch, note, etc.)
211
+ if (data.actions && data.actions.length > 0) {
212
+ for (const action of data.actions) { await ccExecuteAction(action); }
213
+ }
214
+
215
+ // Refresh modal body if document was edited
216
+ if (data.edited && data.content) {
217
+ const display = data.content.replace(/^---[\s\S]*?---\n*/m, '');
218
+ document.getElementById('modal-body').textContent = display;
219
+ _modalDocContext.content = display;
220
+ }
221
+ } else {
222
+ const qaElapsedErr = Math.round((Date.now() - qaStartTime) / 1000);
223
+ thread.innerHTML += '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(data.error || 'Failed') + '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsedErr + 's</div></div>';
224
+ }
225
+ } catch (e) {
226
+ clearInterval(qaTimer);
227
+ const loadingEl = document.getElementById(loadingId);
228
+ if (loadingEl) loadingEl.remove();
229
+ const qaElapsedExc = Math.round((Date.now() - qaStartTime) / 1000);
230
+ thread.innerHTML += '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(e.message) + '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsedExc + 's</div></div>';
231
+ }
232
+
233
+ _qaProcessing = false;
234
+ thread.scrollTop = thread.scrollHeight;
235
+
236
+ // Save session (persists even if modal was closed during processing)
237
+ const modalIsOpen = document.getElementById('modal').classList.contains('open');
238
+ if (_qaSessionKey) {
239
+ // Use captured values if closeModal nulled the globals during processing
240
+ const sessionFilePath = _modalFilePath || capturedFilePath;
241
+ const sessionDocContext = _modalDocContext.title ? { ..._modalDocContext } : { ...capturedDocContext, ..._modalDocContext, title: capturedDocContext.title };
242
+ _qaSessions.set(_qaSessionKey, {
243
+ history: _qaHistory,
244
+ threadHtml: thread.innerHTML,
245
+ docContext: sessionDocContext,
246
+ filePath: sessionFilePath,
247
+ });
248
+ _saveQaSessions();
249
+ // If modal was closed while processing, show notification badge on source card
250
+ if (!modalIsOpen) {
251
+ const card = findCardForFile(sessionFilePath);
252
+ if (card) showNotifBadge(card);
253
+ _qaSessionKey = '';
254
+ }
255
+ }
256
+
257
+ // Process next queued message
258
+ if (_qaQueue.length > 0) {
259
+ const next = _qaQueue.shift();
260
+ const queuedEls = thread.querySelectorAll('.modal-qa-loading');
261
+ for (const el of queuedEls) {
262
+ if (el.textContent.includes('Queued')) { el.remove(); break; }
263
+ }
264
+ _processQaMessage(next.message, next.selection);
265
+ } else if (modalIsOpen) {
266
+ document.getElementById('modal-qa-input')?.focus();
267
+ }
268
+ }
@@ -0,0 +1,131 @@
1
+ // modal.js — Modal and notification badge functions extracted from dashboard.html
2
+
3
+ function closeModal() {
4
+ const modalEl = document.querySelector('#modal .modal');
5
+ if (modalEl) modalEl.classList.remove('modal-wide');
6
+ document.getElementById('modal').classList.remove('open');
7
+ // Hide Q&A section (only shown for document modals)
8
+ document.getElementById('modal-qa').style.display = 'none';
9
+ // Remove settings save button if present
10
+ const settingsBtn = document.getElementById('modal-settings-save');
11
+ if (settingsBtn) settingsBtn.remove();
12
+ // Save Q&A session for this document (persist across modal open/close)
13
+ if (_qaSessionKey && (_qaHistory.length > 0 || _qaQueue.length > 0)) {
14
+ _qaSessions.set(_qaSessionKey, {
15
+ history: _qaHistory,
16
+ threadHtml: document.getElementById('modal-qa-thread').innerHTML,
17
+ docContext: { ..._modalDocContext },
18
+ filePath: _modalFilePath,
19
+ });
20
+ _saveQaSessions();
21
+ }
22
+ // If still processing, show animated badge on the source card
23
+ if (_qaProcessing && _modalFilePath) {
24
+ const card = findCardForFile(_modalFilePath);
25
+ if (card) showNotifBadge(card, 'processing');
26
+ }
27
+ // Reset UI state but don't kill processing/queue — they run in background
28
+ _modalDocContext = { title: '', content: '', selection: '' };
29
+ // Keep session key alive if processing is in flight — result will save when it completes
30
+ if (!_qaProcessing) _qaSessionKey = '';
31
+ document.getElementById('modal-qa-input').value = '';
32
+ document.getElementById('modal-qa-input').placeholder = 'Ask about this document (or select text first)...';
33
+ document.getElementById('modal-qa-pill').style.display = 'none';
34
+ document.getElementById('ask-selection-btn').style.display = 'none';
35
+ // Clear edit/steer state
36
+ _modalEditable = null;
37
+ _modalFilePath = null;
38
+ _modalOriginalPlan = null;
39
+ // steer btn removed — unified send
40
+ const body = document.getElementById('modal-body');
41
+ body.contentEditable = 'false';
42
+ body.style.border = '';
43
+ body.style.padding = '';
44
+ document.getElementById('modal-edit-btn').style.display = 'none';
45
+ document.getElementById('modal-save-btn').style.display = 'none';
46
+ document.getElementById('modal-cancel-edit-btn').style.display = 'none';
47
+ }
48
+
49
+ function copyModalContent() {
50
+ const body = document.getElementById('modal-body');
51
+ const btn = document.getElementById('modal-copy-btn');
52
+ navigator.clipboard.writeText(body.textContent).then(() => {
53
+ btn.classList.add('copied');
54
+ btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> Copied';
55
+ setTimeout(() => {
56
+ btn.classList.remove('copied');
57
+ btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25z"/></svg> Copy';
58
+ }, 2000);
59
+ });
60
+ }
61
+
62
+ // ─── Notification Badges ──────────────────────────────────────────────────────
63
+ // Show a red dot on a card/button when a background response arrives
64
+ // _activeBadges tracks badges by filePath so they survive DOM re-renders
65
+ const _activeBadges = new Map(); // filePath → state ('done' | 'processing')
66
+
67
+ function showNotifBadge(targetEl, state) {
68
+ if (!targetEl) return;
69
+ clearNotifBadge(targetEl);
70
+ targetEl.style.position = 'relative';
71
+ const badge = document.createElement('span');
72
+ badge.className = 'notif-badge ' + (state || 'done');
73
+ if (state === 'processing') {
74
+ badge.innerHTML = '<span></span><span></span><span></span>';
75
+ }
76
+ targetEl.appendChild(badge);
77
+ // Track by data-file so badge can be restored after DOM re-render
78
+ const fileKey = targetEl.getAttribute('data-file');
79
+ if (fileKey) _activeBadges.set(fileKey, state || 'done');
80
+ }
81
+ function clearNotifBadge(targetEl) {
82
+ if (!targetEl) return;
83
+ const dot = targetEl.querySelector('.notif-badge');
84
+ if (dot) dot.remove();
85
+ const fileKey = targetEl.getAttribute('data-file');
86
+ if (fileKey) _activeBadges.delete(fileKey);
87
+ }
88
+ // Re-apply tracked badges after DOM re-renders (called after renderInbox, renderPlans, renderKnowledgeBase)
89
+ function restoreNotifBadges() {
90
+ for (const [filePath, state] of _activeBadges) {
91
+ const card = findCardForFile(filePath);
92
+ if (card && !card.querySelector('.notif-badge')) {
93
+ card.style.position = 'relative';
94
+ const badge = document.createElement('span');
95
+ badge.className = 'notif-badge ' + state;
96
+ if (state === 'processing') {
97
+ badge.innerHTML = '<span></span><span></span><span></span>';
98
+ }
99
+ card.appendChild(badge);
100
+ }
101
+ }
102
+ }
103
+ // Find the plan/KB/inbox card that matches a filePath
104
+ function findCardForFile(filePath) {
105
+ if (!filePath) return null;
106
+ // Direct match by data-file attribute
107
+ var card = document.querySelector('[data-file="' + CSS.escape(filePath) + '"]');
108
+ if (card) return card;
109
+ // Plan cards may have data-file="plans/x.json" but filePath="prd/x.json" (PRD variant)
110
+ if (filePath.startsWith('prd/')) {
111
+ card = document.querySelector('[data-file="' + CSS.escape('plans/' + filePath.slice(4)) + '"]');
112
+ if (card) return card;
113
+ }
114
+ // Archived items: badge the "View Archives" button
115
+ if (filePath.includes('archive')) {
116
+ if (filePath.startsWith('prd/') || filePath.startsWith('plans/')) {
117
+ card = document.querySelector('[data-file="prd-archives"]') || document.querySelector('[data-file="plan-archives"]');
118
+ if (card) return card;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function renderArchiveButtons(archives) {
125
+ archivedPrds = archives;
126
+ const el = document.getElementById('archive-btns');
127
+ if (!archives.length) { el.innerHTML = ''; return; }
128
+ el.innerHTML = archives.map((a, i) =>
129
+ '<button class="archive-btn" onclick="openArchive(' + i + ')">Archived: ' + escHtml(a.version) + ' (' + a.total + ' items)</button>'
130
+ ).join(' ');
131
+ }
@@ -0,0 +1,59 @@
1
+ // refresh.js — Main refresh loop and initialization extracted from dashboard.html
2
+
3
+ async function refresh() {
4
+ try {
5
+ const data = await fetch('/api/status').then(r => r.json());
6
+ // Detect fresh install — clear stale browser state if install ID changed
7
+ if (data.installId) {
8
+ const prev = localStorage.getItem('minions-install-id');
9
+ if (prev && prev !== data.installId) {
10
+ localStorage.clear();
11
+ console.log('Minions: fresh install detected, cleared browser state');
12
+ }
13
+ localStorage.setItem('minions-install-id', data.installId);
14
+ }
15
+ document.getElementById('ts').textContent = new Date(data.timestamp).toLocaleTimeString();
16
+ const engineState = (data.engine && data.engine.state) ? data.engine.state : 'stopped';
17
+ document.getElementById('setup-banner').style.display = (!data.initialized && engineState !== 'stopped') ? 'block' : 'none';
18
+ renderAgents(data.agents);
19
+ renderPrdProgress(data.prdProgress);
20
+ _cachePrdItems(data.prdProgress);
21
+ renderInbox(data.inbox);
22
+ cmdUpdateAgentList(data.agents);
23
+ cmdUpdateProjectList(data.projects || []);
24
+ renderNotes(data.notes);
25
+ renderPrd(data.prd, data.prdProgress);
26
+ renderPrs(data.pullRequests || []);
27
+ renderArchiveButtons(data.archivedPrds || []);
28
+ renderEngineStatus(data.engine);
29
+ renderDispatch(data.dispatch);
30
+ window._lastDispatch = data.dispatch;
31
+ window._lastWorkItems = data.workItems || [];
32
+ window._lastStatus = data;
33
+ prunePrdRequeueState(window._lastWorkItems);
34
+ renderEngineLog(data.engineLog || []);
35
+ renderProjects(data.projects || []);
36
+ renderMetrics(data.metrics || {});
37
+ renderWorkItems(data.workItems || []);
38
+ renderSkills(data.skills || []);
39
+ renderMcpServers(data.mcpServers || []);
40
+ renderSchedules(data.schedules || []);
41
+ // Update sidebar counts
42
+ const swi = document.getElementById('sidebar-wi');
43
+ if (swi) swi.textContent = (data.workItems || []).length || '';
44
+ const spr = document.getElementById('sidebar-pr');
45
+ if (spr) spr.textContent = (data.pullRequests || []).length || '';
46
+ // Refresh KB and plans less frequently (every 3rd cycle = ~12s)
47
+ if (!window._kbRefreshCount) window._kbRefreshCount = 0;
48
+ if (window._kbRefreshCount++ % 3 === 0) { refreshKnowledgeBase(); refreshPlans(); }
49
+ } catch(e) { console.error('refresh error', e); }
50
+ }
51
+
52
+ refresh();
53
+ setInterval(refresh, 4000);
54
+
55
+ // Wire sidebar navigation
56
+ document.querySelectorAll('.sidebar-link').forEach(link => {
57
+ link.addEventListener('click', e => { e.preventDefault(); switchPage(link.dataset.page); });
58
+ });
59
+ switchPage(currentPage);
@@ -0,0 +1,17 @@
1
+ // dashboard/js/render-agents.js — Agent grid rendering extracted from dashboard.html
2
+
3
+ function renderAgents(agents) {
4
+ agentData = agents;
5
+ const grid = document.getElementById('agents-grid');
6
+ grid.innerHTML = agents.map(a => `
7
+ <div class="agent-card ${statusColor(a.status)}" onclick="openAgentDetail('${a.id}')">
8
+ <div class="agent-card-header">
9
+ <span class="agent-name"><span class="agent-emoji">${a.emoji}</span>${a.name}</span>
10
+ <span class="status-badge ${a.status}">${a.status}</span>
11
+ </div>
12
+ <div class="agent-role">${a.role}</div>
13
+ <div class="agent-action" title="${escHtml(a.lastAction)}">${escHtml(a.lastAction)}</div>
14
+ ${a.resultSummary ? `<div class="agent-result" title="${escHtml(a.resultSummary)}">${escHtml(a.resultSummary.slice(0, 200))}${a.resultSummary.length > 200 ? '...' : ''}</div>` : ''}
15
+ </div>
16
+ `).join('');
17
+ }
@@ -0,0 +1,148 @@
1
+ // dashboard/js/render-dispatch.js — Engine status, dispatch, and log rendering extracted from dashboard.html
2
+
3
+ function renderEngineStatus(engine) {
4
+ const badge = document.getElementById('engine-badge');
5
+ let state = engine?.state || 'stopped';
6
+ let staleMs = 0;
7
+
8
+ // Detect stale engine — says running but heartbeat is old (>2 min)
9
+ if (state === 'running' && engine?.heartbeat) {
10
+ staleMs = Date.now() - engine.heartbeat;
11
+ if (staleMs > 120000) {
12
+ state = 'stale';
13
+ }
14
+ } else if (state === 'running' && !engine?.heartbeat) {
15
+ // Running but no heartbeat yet — engine just started or old version
16
+ state = 'running';
17
+ }
18
+
19
+ badge.className = 'engine-badge ' + (state === 'stale' ? 'stopped' : state);
20
+ badge.textContent = state === 'stale' ? 'STALE' : state.toUpperCase();
21
+ badge.title = state === 'stale'
22
+ ? 'Engine claims running but heartbeat is stale (>2min). It may have crashed. Run: node engine.js start'
23
+ : state === 'stopped' ? 'Engine is stopped. Run: node engine.js start' : '';
24
+ renderEngineAlert(state, staleMs);
25
+ }
26
+
27
+ function renderEngineAlert(state, staleMs) {
28
+ const el = document.getElementById('engine-alert');
29
+ if (!el) return;
30
+ if (state !== 'stale') {
31
+ el.style.display = 'none';
32
+ el.innerHTML = '';
33
+ return;
34
+ }
35
+ const mins = Math.max(1, Math.round(staleMs / 60000));
36
+ el.innerHTML =
37
+ '<span class="engine-alert-msg">&#x26A0;&#xFE0F; Engine heartbeat is stale (' + mins + 'm old). Dispatch may be stuck.</span>' +
38
+ '<span class="engine-alert-action" id="engine-alert-restart">Restart engine</span>';
39
+ document.getElementById('engine-alert-restart').onclick = async function() {
40
+ this.classList.add('clicked');
41
+ this.textContent = 'Restarting...';
42
+ try {
43
+ const res = await fetch('/api/engine/restart', { method: 'POST' });
44
+ const data = await res.json();
45
+ if (data.ok) {
46
+ this.textContent = 'Restarted (PID ' + data.pid + ')';
47
+ setTimeout(() => refresh(), 3000);
48
+ } else {
49
+ this.textContent = 'Failed: ' + (data.error || 'unknown');
50
+ this.classList.remove('clicked');
51
+ }
52
+ } catch (e) {
53
+ this.textContent = 'Failed: ' + e.message;
54
+ this.classList.remove('clicked');
55
+ }
56
+ };
57
+ el.style.display = 'flex';
58
+ }
59
+
60
+ function renderDispatch(dispatch) {
61
+ if (!dispatch) return;
62
+
63
+ // Stats
64
+ const stats = document.getElementById('dispatch-stats');
65
+ stats.innerHTML =
66
+ '<div class="dispatch-stat"><div class="dispatch-stat-num yellow">' + (dispatch.active || []).length + '</div><div class="dispatch-stat-label">Active</div></div>' +
67
+ '<div class="dispatch-stat"><div class="dispatch-stat-num blue">' + (dispatch.pending || []).length + '</div><div class="dispatch-stat-label">Pending</div></div>' +
68
+ '<div class="dispatch-stat"><div class="dispatch-stat-num green">' + (dispatch.completed || []).length + '</div><div class="dispatch-stat-label">Completed</div></div>';
69
+
70
+ // Active
71
+ const activeEl = document.getElementById('dispatch-active');
72
+ if ((dispatch.active || []).length > 0) {
73
+ activeEl.innerHTML = '<div style="font-size:11px;color:var(--green);margin-bottom:6px;font-weight:600">ACTIVE</div><div class="dispatch-list">' +
74
+ dispatch.active.map(d =>
75
+ '<div class="dispatch-item">' +
76
+ '<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
77
+ '<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
78
+ '<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
79
+ '<span class="dispatch-time">' + shortTime(d.started_at) + '</span>' +
80
+ '</div>'
81
+ ).join('') + '</div>';
82
+ } else {
83
+ activeEl.innerHTML = '<div style="color:var(--muted);font-size:11px;margin-bottom:8px">No active dispatches</div>';
84
+ }
85
+
86
+ // Pending
87
+ const pendingEl = document.getElementById('dispatch-pending');
88
+ if ((dispatch.pending || []).length > 0) {
89
+ pendingEl.innerHTML = '<div style="font-size:11px;color:var(--yellow);margin:8px 0 6px;font-weight:600">PENDING</div><div class="dispatch-list">' +
90
+ dispatch.pending.map(d =>
91
+ '<div class="dispatch-item">' +
92
+ '<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
93
+ '<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
94
+ '<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
95
+ (d.skipReason ? '<span style="font-size:9px;color:var(--muted);margin-left:6px" title="' + escHtml(d.skipReason) + '">' + escHtml(d.skipReason.replace(/_/g, ' ')) + '</span>' : '') +
96
+ '</div>'
97
+ ).join('') + '</div>';
98
+ } else {
99
+ pendingEl.innerHTML = '';
100
+ }
101
+
102
+ // Completed
103
+ const completedEl = document.getElementById('completed-content');
104
+ const completedCount = document.getElementById('completed-count');
105
+ const completed = (dispatch.completed || []).slice().reverse();
106
+ completedCount.textContent = completed.length;
107
+
108
+ if (completed.length > 0) {
109
+ completedEl.innerHTML = '<table class="pr-table"><thead><tr><th>ID</th><th>Type</th><th>Agent</th><th>Task</th><th>Result</th><th>Completed</th></tr></thead><tbody>' +
110
+ completed.map(d => {
111
+ const isError = d.result === 'error';
112
+ const agentId = (d.agent || '').toLowerCase();
113
+ const errorBtn = isError
114
+ ? ' <button class="error-details-btn" data-agent="' + escHtml(agentId) + '" data-reason="' + escHtml(d.reason || 'No reason recorded') + '" data-task="' + escHtml((d.task || '').slice(0, 100)) + '" onclick="showErrorDetails(this.dataset.agent, this.dataset.reason, this.dataset.task)" title="View error details">details</button>'
115
+ : '';
116
+ return '<tr>' +
117
+ '<td style="font-family:Consolas;font-size:10px" title="' + escHtml(d.id || '') + '">' + escHtml(d.id || '') + '</td>' +
118
+ '<td><span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span></td>' +
119
+ '<td>' + escHtml(d.agentName || d.agent || '') + '</td>' +
120
+ '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml((d.task || '').slice(0, 60)) + '</td>' +
121
+ '<td style="color:' + (d.result === 'success' ? 'var(--green)' : 'var(--red)') + '">' + escHtml(d.result || '') + errorBtn + '</td>' +
122
+ '<td class="pr-date">' + shortTime(d.completed_at) + '</td>' +
123
+ '</tr>';
124
+ }).join('') + '</tbody></table>';
125
+ } else {
126
+ completedEl.innerHTML = '<p class="empty">No completed dispatches yet.</p>';
127
+ }
128
+ }
129
+
130
+ function renderEngineLog(log) {
131
+ const el = document.getElementById('engine-log');
132
+ if (!log || log.length === 0) {
133
+ el.innerHTML = '<div class="empty">No log entries yet.</div>';
134
+ return;
135
+ }
136
+ el.innerHTML = log.slice().reverse().map(e =>
137
+ '<div class="log-entry">' +
138
+ '<span class="log-ts">' + shortTime(e.timestamp) + '</span> ' +
139
+ '<span class="log-level-' + (e.level || 'info') + '">[' + (e.level || 'info') + ']</span> ' +
140
+ escHtml(e.message || '') +
141
+ '</div>'
142
+ ).join('');
143
+ }
144
+
145
+ function shortTime(t) {
146
+ if (!t) return '';
147
+ try { return new Date(t).toLocaleTimeString(); } catch { return t; }
148
+ }