@yemi33/minions 0.1.2172 → 0.1.2173

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.
@@ -24,6 +24,7 @@
24
24
  <span class="panel-sub">Command Center &amp; quick triggers</span>
25
25
  </div>
26
26
  <div class="actions-chat">
27
+ <div class="chat-tabs" id="chat-tabs"></div>
27
28
  <div class="chat-messages" id="chat-messages">
28
29
  <div class="chat-empty">No messages yet — say hi to Command Center.</div>
29
30
  </div>
@@ -1,8 +1,26 @@
1
1
  // ── Chatbox wired to /api/command-center/stream ──────────────
2
2
  // Mirrors the full dashboard's command center: SSE streaming, tool-call
3
3
  // progress, abort, and per-tab session continuity.
4
- var SLIM_STORAGE_KEY = 'slim-cc-state-v1';
5
- var SLIM_TAB_KEY = 'slim-cc-tabid-v1';
4
+ // ── Shared Command-Center store (carry-over with the classic dashboard) ──
5
+ // Slim used to persist a private single-chat blob under its own slim-only
6
+ // localStorage key with its own slim-prefixed tab id. That siloed it from
7
+ // the classic dashboard:
8
+ // switching views lost the conversation AND started a fresh server-side CC
9
+ // session (cc-sessions.json is keyed by tabId). To honor the welcome
10
+ // popup's promise — "multi-tab conversations that carry over from the
11
+ // classic dashboard" — slim now reads/writes the SAME localStorage the
12
+ // classic command center owns:
13
+ // cc-tabs — [{id, title, sessionId, messages:[{role, html, ...}]}]
14
+ // cc-active-tab — id of the focused tab
15
+ // Slim is a single-pane chat, so it mirrors the *active* tab bidirectionally
16
+ // and reuses its `cc-*` id as the server session key, so the same backend
17
+ // session continues across either view. Messages are stored in the classic
18
+ // shape ({role, html}) so the classic dashboard renders them, with slim's
19
+ // extra fields ({text, toolCalls, severity}) carried alongside — classic
20
+ // ignores them, slim re-hydrates from them losslessly.
21
+ var CC_TABS_KEY = 'cc-tabs';
22
+ var CC_ACTIVE_KEY = 'cc-active-tab';
23
+ var SLIM_MAX_TABS = 20; // matches the classic dashboard's CC_MAX_TABS
6
24
  var SLIM_PROJECT_KEY = 'slim-current-project-v1';
7
25
  var SLIM_MAX_MESSAGES = 30;
8
26
  var STREAM_TIMEOUT_MS = (60 * 60 * 1000) + 60000;
@@ -14,30 +32,115 @@
14
32
  var currentProject = null;
15
33
  try { currentProject = localStorage.getItem(SLIM_PROJECT_KEY) || null; } catch (_e) { /* private mode */ }
16
34
 
17
- var tabId = (function() {
35
+ function _readCcTabs() {
18
36
  try {
19
- var t = sessionStorage.getItem(SLIM_TAB_KEY);
20
- if (t) return t;
21
- t = 'slim-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
22
- sessionStorage.setItem(SLIM_TAB_KEY, t);
23
- return t;
24
- } catch (_e) {
25
- return 'slim-' + Date.now().toString(36);
37
+ var raw = localStorage.getItem(CC_TABS_KEY);
38
+ var arr = raw ? JSON.parse(raw) : [];
39
+ return Array.isArray(arr) ? arr : [];
40
+ } catch (_e) { return []; }
41
+ }
42
+ // Persist the FULL array — never trim here. Trimming on every write would
43
+ // silently evict the oldest tabs (and could orphan cc-active-tab) the moment
44
+ // slim saves a message, dropping tabs the classic dashboard still owns. Like
45
+ // classic, the cap is enforced at tab-CREATION time (see _pushCappedTab).
46
+ function _writeCcTabs(tabs) {
47
+ try { localStorage.setItem(CC_TABS_KEY, JSON.stringify(tabs)); } catch (_e) { /* full */ }
48
+ }
49
+ function _findTabIdx(tabs, id) {
50
+ return tabs.findIndex(function(t) { return t && t.id === id; });
51
+ }
52
+ function _newTabId() {
53
+ return 'cc-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
54
+ }
55
+ // Append a new tab, evicting the oldest to stay within the cap (classic
56
+ // parity). Clears cc-active-tab if the evicted tab was the active one.
57
+ function _pushCappedTab(tabs, tab) {
58
+ while (tabs.length >= SLIM_MAX_TABS) {
59
+ var dropped = tabs.shift();
60
+ try {
61
+ if (dropped && dropped.id === localStorage.getItem(CC_ACTIVE_KEY)) localStorage.removeItem(CC_ACTIVE_KEY);
62
+ } catch (_e) { /* private mode */ }
63
+ }
64
+ tabs.push(tab);
65
+ return tab;
66
+ }
67
+ // Single source of truth for a fresh tab's shape — used wherever a tab is
68
+ // created or has to be reconstructed (load, save, new-chat).
69
+ function _newTab(id) {
70
+ return { id: id || _newTabId(), title: 'New chat', sessionId: null, messages: [] };
71
+ }
72
+ // Resolve the tab slim should mirror: the classic active tab if present,
73
+ // else the most-recent existing tab, else a fresh one registered into the
74
+ // shared store so the classic dashboard immediately sees it.
75
+ function _ensureActiveTab() {
76
+ var tabs = _readCcTabs();
77
+ var activeId = null;
78
+ try { activeId = localStorage.getItem(CC_ACTIVE_KEY); } catch (_e) { /* private mode */ }
79
+ var ai = activeId ? _findTabIdx(tabs, activeId) : -1;
80
+ var tab = ai >= 0 ? tabs[ai] : null;
81
+ if (!tab && tabs.length) tab = tabs[tabs.length - 1];
82
+ if (!tab) {
83
+ tab = _newTab();
84
+ tabs.push(tab);
85
+ _writeCcTabs(tabs);
26
86
  }
27
- })();
87
+ try { localStorage.setItem(CC_ACTIVE_KEY, tab.id); } catch (_e) { /* private mode */ }
88
+ return tab;
89
+ }
90
+
91
+ var tabId = _ensureActiveTab().id;
28
92
 
29
93
  var msgsEl = document.getElementById('chat-messages');
30
94
  var inputEl = document.getElementById('chat-input');
31
95
  var sendBtn = document.getElementById('chat-send');
32
96
  var stopBtn = document.getElementById('chat-stop');
33
97
 
98
+ // Extract plain text from classic-authored message HTML (no slim `text`).
99
+ function _htmlToText(html) {
100
+ if (!html) return '';
101
+ var tmp = document.createElement('div');
102
+ // eslint-disable-next-line no-unsanitized/property -- reason: html is read back from our own persisted store (renderMarkdown/escHtml produced it); only textContent is extracted, never re-inserted into the live DOM here
103
+ tmp.innerHTML = String(html);
104
+ return (tmp.textContent || tmp.innerText || '').trim();
105
+ }
106
+ // Stored ({role, html, ...}) → slim in-memory ({role, text, toolCalls, severity}).
107
+ function _storedToSlim(m) {
108
+ if (!m || typeof m !== 'object') return null;
109
+ var text = (typeof m.text === 'string') ? m.text : _htmlToText(m.html);
110
+ var out = { role: m.role || 'assistant', text: text };
111
+ if (Array.isArray(m.toolCalls)) out.toolCalls = m.toolCalls;
112
+ if (m.severity) out.severity = m.severity;
113
+ return out;
114
+ }
115
+ // Slim in-memory → stored. Keep slim fields AND a classic-renderable `html`
116
+ // so both dashboards display it. User/action/error text is escaped; the
117
+ // assistant body is markdown-rendered (renderMarkdown escapes first).
118
+ function _slimToStored(m) {
119
+ var role = m.role || 'assistant';
120
+ var html = (role === 'assistant') ? renderMarkdown(m.text || '') : escHtmlChat(m.text || '');
121
+ var out = { role: role, html: html, text: m.text || '' };
122
+ if (Array.isArray(m.toolCalls)) out.toolCalls = m.toolCalls;
123
+ if (m.severity) out.severity = m.severity;
124
+ return out;
125
+ }
126
+ function _deriveTitle() {
127
+ for (var i = 0; i < messages.length; i++) {
128
+ if (messages[i] && messages[i].role === 'user' && messages[i].text) {
129
+ return messages[i].text.slice(0, 40);
130
+ }
131
+ }
132
+ return 'New chat';
133
+ }
134
+
34
135
  function loadState() {
35
136
  try {
36
- var raw = localStorage.getItem(SLIM_STORAGE_KEY);
37
- if (!raw) return;
38
- var data = JSON.parse(raw) || {};
39
- sessionId = data.sessionId || null;
40
- messages = Array.isArray(data.messages) ? data.messages.slice(-SLIM_MAX_MESSAGES) : [];
137
+ var tabs = _readCcTabs();
138
+ var idx = _findTabIdx(tabs, tabId);
139
+ if (idx < 0) return;
140
+ var tab = tabs[idx];
141
+ sessionId = tab.sessionId || null;
142
+ var msgs = Array.isArray(tab.messages) ? tab.messages.slice(-SLIM_MAX_MESSAGES) : [];
143
+ messages = msgs.map(_storedToSlim).filter(Boolean);
41
144
  } catch (_e) { /* ignore */ }
42
145
  }
43
146
  var saveDebounce = null;
@@ -46,10 +149,15 @@
46
149
  saveDebounce = setTimeout(function() {
47
150
  saveDebounce = null;
48
151
  try {
49
- localStorage.setItem(SLIM_STORAGE_KEY, JSON.stringify({
50
- sessionId: sessionId,
51
- messages: messages.slice(-SLIM_MAX_MESSAGES),
52
- }));
152
+ var tabs = _readCcTabs();
153
+ var idx = _findTabIdx(tabs, tabId);
154
+ var tab = (idx >= 0) ? tabs[idx] : _newTab(tabId);
155
+ tab.sessionId = sessionId;
156
+ tab.title = _deriveTitle();
157
+ tab.messages = messages.slice(-SLIM_MAX_MESSAGES).map(_slimToStored);
158
+ if (idx >= 0) tabs[idx] = tab; else tabs.push(tab);
159
+ _writeCcTabs(tabs);
160
+ try { localStorage.setItem(CC_ACTIVE_KEY, tabId); } catch (_e) { /* private mode */ }
53
161
  } catch (_e) { /* localStorage full */ }
54
162
  }, 300);
55
163
  }
@@ -324,6 +432,89 @@
324
432
  else appendBubble(m.role, m.text || '', m.toolCalls);
325
433
  }
326
434
  }
435
+ // ── Command Center tab bar (shared with the classic dashboard) ──
436
+ // One chip per shared cc-tabs entry, so the same conversations are visible
437
+ // and switchable in either view. Slim shows a single pane at a time; this
438
+ // bar is how you move between the tabs the classic dashboard also shows.
439
+ var tabsEl = document.getElementById('chat-tabs');
440
+
441
+ function _tabChipLabel(t) {
442
+ var s = (t && t.title) ? String(t.title) : 'New chat';
443
+ return s.length > 24 ? s.slice(0, 24) + '…' : s;
444
+ }
445
+
446
+ function renderTabBar() {
447
+ if (!tabsEl) return;
448
+ var tabs = _readCcTabs();
449
+ tabsEl.textContent = '';
450
+ tabs.forEach(function(t) {
451
+ if (!t || !t.id) return;
452
+ var chip = document.createElement('div');
453
+ chip.className = 'chat-tab' + (t.id === tabId ? ' active' : '');
454
+ chip.title = t.title || 'New chat';
455
+ var label = document.createElement('span');
456
+ label.className = 'chat-tab-label';
457
+ label.textContent = _tabChipLabel(t);
458
+ label.addEventListener('click', function() { switchSlimTab(t.id); });
459
+ chip.appendChild(label);
460
+ var close = document.createElement('button');
461
+ close.className = 'chat-tab-close';
462
+ close.type = 'button';
463
+ close.textContent = '×';
464
+ close.title = 'Close chat';
465
+ close.addEventListener('click', function(ev) { ev.stopPropagation(); closeSlimTab(t.id); });
466
+ chip.appendChild(close);
467
+ tabsEl.appendChild(chip);
468
+ });
469
+ var add = document.createElement('button');
470
+ add.className = 'chat-tab-new';
471
+ add.type = 'button';
472
+ add.textContent = '+';
473
+ add.title = 'New chat';
474
+ add.addEventListener('click', function() { slimChatNew(); });
475
+ tabsEl.appendChild(add);
476
+ }
477
+
478
+ // Switch the visible pane to another shared tab. Aborts an in-flight stream
479
+ // first (the SSE is bound to the current tabId), then re-hydrates from the
480
+ // target tab's persisted state.
481
+ function switchSlimTab(id) {
482
+ if (!id || id === tabId) return;
483
+ if (sending) abortInFlight();
484
+ tabId = id;
485
+ try { localStorage.setItem(CC_ACTIVE_KEY, id); } catch (_e) { /* private mode */ }
486
+ sessionId = null;
487
+ messages = [];
488
+ loadState();
489
+ rerenderHistory();
490
+ renderTabBar();
491
+ inputEl.focus();
492
+ }
493
+
494
+ function closeSlimTab(id) {
495
+ if (!id) return;
496
+ if (id === tabId && sending) abortInFlight();
497
+ var tabs = _readCcTabs();
498
+ var idx = _findTabIdx(tabs, id);
499
+ if (idx < 0) return;
500
+ tabs.splice(idx, 1);
501
+ _writeCcTabs(tabs);
502
+ // Evict the server-side session for the closed tab (mirrors classic).
503
+ try { fetch('/api/cc-sessions/' + encodeURIComponent(id), { method: 'DELETE' }).catch(function() {}); } catch (_e) { /* ignore */ }
504
+ if (id !== tabId) { renderTabBar(); return; }
505
+ if (tabs.length) {
506
+ switchSlimTab(tabs[Math.min(idx, tabs.length - 1)].id);
507
+ } else {
508
+ slimChatNew(); // never leave the user with zero tabs
509
+ }
510
+ }
511
+
512
+ // Surface tabs created/closed by the classic dashboard in another window.
513
+ window.addEventListener('storage', function(ev) {
514
+ if (!ev || ev.key === CC_TABS_KEY || ev.key === CC_ACTIVE_KEY || ev.key === null) renderTabBar();
515
+ });
516
+
327
517
  loadState();
328
518
  rerenderHistory();
519
+ renderTabBar();
329
520
 
@@ -28,9 +28,13 @@
28
28
  var text = inputEl.value.trim();
29
29
  if (!text) return;
30
30
 
31
+ // The tab title is derived from the first user message; only that message
32
+ // changes the chip, so re-render the bar once instead of on every send.
33
+ var wasFirstUser = !messages.some(function(m) { return m && m.role === 'user'; });
31
34
  appendBubble('user', text);
32
35
  messages.push({ role: 'user', text: text });
33
36
  saveState();
37
+ if (wasFirstUser) renderTabBar();
34
38
 
35
39
  inputEl.value = '';
36
40
  inputEl.style.height = 'auto';
@@ -161,13 +165,22 @@
161
165
 
162
166
  async function slimChatNew() {
163
167
  if (sending) abortInFlight();
168
+ // Register a fresh tab in the shared cc-tabs store and switch slim onto
169
+ // it — classic's other tabs are left intact, and the previous session
170
+ // keeps its own server-side cc-sessions.json entry (no DELETE).
171
+ try {
172
+ var tabs = _readCcTabs();
173
+ var fresh = _newTab();
174
+ _pushCappedTab(tabs, fresh); // enforce the tab cap at creation (classic parity)
175
+ _writeCcTabs(tabs);
176
+ tabId = fresh.id;
177
+ try { localStorage.setItem(CC_ACTIVE_KEY, fresh.id); } catch (_e) { /* private mode */ }
178
+ } catch (_e) { /* fall through to in-memory reset */ }
164
179
  sessionId = null;
165
180
  messages = [];
166
181
  saveState();
167
182
  rerenderHistory();
168
- try {
169
- fetch('/api/cc-sessions/' + encodeURIComponent(tabId), { method: 'DELETE' }).catch(function() {});
170
- } catch (_e) { /* ignore */ }
183
+ renderTabBar();
171
184
  inputEl.focus();
172
185
  }
173
186
 
@@ -484,11 +484,43 @@
484
484
  listEl.replaceChildren(frag);
485
485
  }
486
486
 
487
+ // /api/status was slimmed to the small engine/throttle/version envelope
488
+ // (issue #2949) — the heavy slices the cockpit needs (dispatch, agents,
489
+ // pull requests, pinned, watches) moved to dedicated endpoints. The slim
490
+ // poll therefore has to gather those alongside /api/status and merge them
491
+ // into one snapshot before applyStatus(), or every tile reads `undefined`
492
+ // and the dashboard never reflects work in flight.
493
+ async function _slimFetchJson(url) {
494
+ var res = await fetch(url, { headers: { 'Accept': 'application/json' } });
495
+ if (!res.ok) throw new Error('HTTP ' + res.status);
496
+ return res.json();
497
+ }
498
+ // Resolve to `fallback` instead of rejecting, so one transient endpoint
499
+ // error doesn't blank the whole cockpit — we keep the last-known slice.
500
+ function _slimSettle(url, fallback) {
501
+ return _slimFetchJson(url).then(function(v) { return v; }, function() { return fallback; });
502
+ }
503
+
487
504
  async function pollStatusOnce() {
505
+ var prev = lastStatusData || {};
488
506
  try {
489
- var res = await fetch('/api/status', { headers: { 'Accept': 'application/json' } });
490
- if (!res.ok) throw new Error('HTTP ' + res.status);
491
- var data = await res.json();
507
+ // /api/status is the only required fetch (drives the engine tile +
508
+ // timestamp); its rejection rejects the batch and we keep prior values.
509
+ // The slice endpoints fail soft to their previous value.
510
+ var results = await Promise.all([
511
+ _slimFetchJson('/api/status'),
512
+ _slimSettle('/api/dispatch', prev.dispatch),
513
+ _slimSettle('/api/agents', prev.agents),
514
+ _slimSettle('/api/pull-requests', prev.pullRequests),
515
+ _slimSettle('/api/pinned', prev.pinned),
516
+ _slimSettle('/state/engine/watches.json', prev.watches),
517
+ ]);
518
+ var data = results[0] || {};
519
+ data.dispatch = (results[1] && typeof results[1] === 'object') ? results[1] : {};
520
+ data.agents = Array.isArray(results[2]) ? results[2] : [];
521
+ data.pullRequests = Array.isArray(results[3]) ? results[3] : [];
522
+ data.pinned = Array.isArray(results[4]) ? results[4] : [];
523
+ data.watches = Array.isArray(results[5]) ? results[5] : [];
492
524
  applyStatus(data);
493
525
  } catch (e) {
494
526
  // Soft failure — leave previous values, surface in stamp
@@ -247,6 +247,66 @@
247
247
  flex-direction: column;
248
248
  min-height: 0;
249
249
  }
250
+ /* Command Center tab bar — shared cc-tabs surfaced for parity with the
251
+ classic dashboard. Sits above the message pane. */
252
+ .chat-tabs {
253
+ display: flex;
254
+ align-items: center;
255
+ gap: 6px;
256
+ flex-wrap: wrap;
257
+ padding: 8px 12px 0;
258
+ width: 100%;
259
+ max-width: 800px;
260
+ margin-left: auto;
261
+ margin-right: auto;
262
+ }
263
+ .chat-tab {
264
+ display: inline-flex;
265
+ align-items: center;
266
+ gap: 6px;
267
+ max-width: 220px;
268
+ padding: 4px 6px 4px 10px;
269
+ border: 1px solid var(--border);
270
+ border-radius: 999px;
271
+ background: var(--surface2);
272
+ color: var(--muted);
273
+ font-size: var(--text-sm);
274
+ cursor: pointer;
275
+ white-space: nowrap;
276
+ }
277
+ .chat-tab.active {
278
+ color: var(--text);
279
+ border-color: var(--blue);
280
+ background: color-mix(in srgb, var(--blue) 14%, var(--surface2));
281
+ }
282
+ .chat-tab-label {
283
+ overflow: hidden;
284
+ text-overflow: ellipsis;
285
+ white-space: nowrap;
286
+ }
287
+ .chat-tab-close {
288
+ border: none;
289
+ background: transparent;
290
+ color: var(--muted);
291
+ cursor: pointer;
292
+ font-size: var(--text-md);
293
+ line-height: 1;
294
+ padding: 0 2px;
295
+ border-radius: 4px;
296
+ }
297
+ .chat-tab-close:hover { color: var(--red); background: var(--surface); }
298
+ .chat-tab-new {
299
+ border: 1px dashed var(--border);
300
+ background: transparent;
301
+ color: var(--muted);
302
+ cursor: pointer;
303
+ border-radius: 999px;
304
+ width: 26px;
305
+ height: 26px;
306
+ font-size: var(--text-md);
307
+ line-height: 1;
308
+ }
309
+ .chat-tab-new:hover { color: var(--text); border-color: var(--blue); }
250
310
  .chat-messages {
251
311
  flex: 1;
252
312
  overflow-y: auto;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2172",
3
+ "version": "0.1.2173",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"