@yemi33/minions 0.1.2244 → 0.1.2246

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.
@@ -4,7 +4,7 @@
4
4
  var label = action.title || action.id || action.file || action.agent || '';
5
5
  return label ? t + ': ' + label : t;
6
6
  }
7
- function renderActionResults(actions, results) {
7
+ function renderActionResults(turnTabId, actions, results) {
8
8
  if (!actions || !actions.length) return;
9
9
  for (var i = 0; i < actions.length; i++) {
10
10
  var action = actions[i];
@@ -17,22 +17,22 @@
17
17
  else if (r && r.warning) { severity = 'warn'; symbol = 'ℹ'; detail = ' — ' + r.warning; }
18
18
  else if (r && r.duplicate) { severity = 'warn'; symbol = '↺'; detail = ' (already exists)'; }
19
19
  var text = symbol + ' ' + label + detail;
20
- appendActionStatus(severity, text);
21
- messages.push({ role: 'action', severity: severity, text: text });
20
+ if (turnTabId === tabId) appendActionStatus(severity, text);
21
+ recordTabMessage(turnTabId, { role: 'action', severity: severity, text: text });
22
22
  }
23
- saveState();
24
23
  }
25
24
 
26
25
  // Public entry point for the Send button / Enter key. While a turn is in
27
- // flight the composer stays interactive (setSending no longer disables it),
28
- // so a second submit QUEUES the message instead of dropping it or racing the
29
- // active stream. The queue drains FIFO, one turn at a time (W-mqayw3x6).
26
+ // flight the composer stays interactive (the per-tab sending flag no longer
27
+ // disables it), so a second submit QUEUES the message instead of dropping it
28
+ // or racing the active stream. The queue drains FIFO, one turn at a time
29
+ // per tab (W-mqayw3x6).
30
30
  function sendMessage() {
31
31
  var text = inputEl.value.trim();
32
32
  if (!text) return;
33
33
  inputEl.value = '';
34
34
  inputEl.style.height = 'auto';
35
- if (sending) {
35
+ if (_isTabSending(tabId)) {
36
36
  _enqueue(text);
37
37
  inputEl.focus();
38
38
  return;
@@ -46,57 +46,74 @@
46
46
  renderQueue();
47
47
  }
48
48
 
49
- // Promote the next queued message for the active tab, one at a time. No-op
50
- // while a turn is in flight or the queue is suspended (after abort/error).
51
- function _drainQueue() {
52
- if (sending || queueSuspended[tabId]) return;
53
- var q = _getQueue();
54
- if (!q.length) return;
49
+ // Promote the next queued message for the active tab.
50
+ function _drainQueue() { _drainQueueFor(tabId); }
51
+
52
+ // Promote the next queued message for a SPECIFIC tab, one at a time. No-op
53
+ // while that tab's turn is in flight or its queue is suspended (after an
54
+ // abort/error). Background tabs (W-mqqvba66) drain into their OWN turn, so a
55
+ // tab you switched away from keeps working through its queue.
56
+ function _drainQueueFor(id) {
57
+ if (_isTabSending(id) || queueSuspended[id]) return;
58
+ var q = queues[id];
59
+ if (!q || !q.length) return;
55
60
  var next = q.shift();
56
- renderQueue();
57
- _performSend(next);
61
+ if (id === tabId) renderQueue();
62
+ _performSend(next, id);
58
63
  }
59
64
 
60
65
  // Called from a finished turn's `finally`. On clean completion auto-fire the
61
- // next queued message; on abort or error suspend auto-drain and leave the
62
- // queue visible so the user can resend or dismiss explicitly (W-mqayw3x6 #5/#6).
66
+ // next queued message FOR THAT TAB; on abort or error suspend auto-drain and
67
+ // leave the queue visible so the user can resend or dismiss explicitly
68
+ // (W-mqayw3x6 #5/#6).
63
69
  function _afterTurn(turnTabId, outcome) {
64
70
  var q = queues[turnTabId];
65
71
  if (!q || !q.length) return;
66
- if (outcome === 'done' && turnTabId === tabId) {
67
- _drainQueue();
72
+ if (outcome === 'done') {
73
+ _drainQueueFor(turnTabId);
68
74
  } else {
69
75
  queueSuspended[turnTabId] = true;
70
76
  if (turnTabId === tabId) renderQueue();
71
77
  }
72
78
  }
73
79
 
74
- async function _performSend(text) {
75
- // Bind this turn to the tab it started on so a mid-turn tab switch (which
76
- // aborts the stream) settles the queue against the right tab, not the one
77
- // the user navigated to.
78
- var turnTabId = tabId;
80
+ async function _performSend(text, sendTabId) {
81
+ // Bind this turn to the tab it started on (W-mqqvba66). EVERY append /
82
+ // saveState targets `turnTabId` — never the live-global `tabId`/`messages`
83
+ // — so a mid-turn tab switch keeps the stream running in the background and
84
+ // its partial is recorded on the originating tab, never mis-attributed to
85
+ // whatever tab the user navigated to. `live` is whether that tab is the
86
+ // currently-visible pane (only then do we touch the DOM directly).
87
+ var turnTabId = sendTabId || tabId;
88
+ var st = _streamState(turnTabId);
79
89
  var turnOutcome = 'done';
90
+ var live = (turnTabId === tabId);
80
91
 
81
92
  // The tab title is derived from the first user message; only that message
82
93
  // changes the chip, so re-render the bar once instead of on every send.
83
- var wasFirstUser = !messages.some(function(m) { return m && m.role === 'user'; });
84
- appendBubble('user', text);
85
- messages.push({ role: 'user', text: text });
86
- saveState();
94
+ var wasFirstUser = !_tabHasUserMessage(turnTabId);
95
+ if (live) appendBubble('user', text);
96
+ recordTabMessage(turnTabId, { role: 'user', text: text });
87
97
  if (wasFirstUser) renderTabBar();
88
98
 
89
- setSending(true);
99
+ setSendingFor(turnTabId, true);
90
100
 
91
- var stream = buildStreamBubble();
92
- var streamedText = '';
93
- abortController = new AbortController();
94
- var userAborted = false;
101
+ st.streamedText = '';
102
+ st.toolList = [];
103
+ st.userAborted = false;
104
+ // The live bubble exists only while this tab is visible; a background turn
105
+ // accumulates into st.streamedText / st.toolList and re-attaches its bubble
106
+ // on switch-back (_restoreStreamFor). Share st.toolList by reference so
107
+ // restore replays already-streamed tools.
108
+ st.stream = live ? buildStreamBubble(st.streamedText, st.toolList) : null;
109
+ st.abortController = new AbortController();
95
110
  var timeoutSignal = null;
96
111
  try { timeoutSignal = AbortSignal.timeout(STREAM_TIMEOUT_MS); } catch (_e) { /* old browsers */ }
97
112
  var signal = timeoutSignal && AbortSignal.any
98
- ? AbortSignal.any([abortController.signal, timeoutSignal])
99
- : abortController.signal;
113
+ ? AbortSignal.any([st.abortController.signal, timeoutSignal])
114
+ : st.abortController.signal;
115
+
116
+ var turnSessionId = _getTabSessionId(turnTabId);
100
117
 
101
118
  try {
102
119
  var res = await fetch('/api/command-center/stream', {
@@ -106,7 +123,7 @@
106
123
  // when the indicator reads "Select project"). noProjectSelected lets the
107
124
  // server inject a "no project — ask the user" CC preamble instead of
108
125
  // silently defaulting; only the slim composer sends it (W-mqayzsj3).
109
- body: JSON.stringify({ message: text, tabId: tabId, sessionId: sessionId, currentProject: currentProject || undefined, noProjectSelected: currentProject ? undefined : true }),
126
+ body: JSON.stringify({ message: text, tabId: turnTabId, sessionId: turnSessionId, currentProject: currentProject || undefined, noProjectSelected: currentProject ? undefined : true }),
110
127
  signal: signal,
111
128
  });
112
129
  if (!res.ok) {
@@ -135,10 +152,14 @@
135
152
  var evt;
136
153
  try { evt = JSON.parse(line.slice(6)); } catch (_e) { continue; }
137
154
  if (evt.type === 'chunk') {
138
- streamedText = mergeStreamText(streamedText, evt.text || '');
139
- stream.setText(streamedText);
155
+ st.streamedText = mergeStreamText(st.streamedText, evt.text || '');
156
+ if (st.stream) st.stream.setText(st.streamedText);
140
157
  } else if (evt.type === 'tool') {
141
- stream.addTool(evt.name, evt.input || {});
158
+ // st.stream.addTool pushes into the shared st.toolList; when the tab
159
+ // is backgrounded (no bubble) accumulate directly so the tool set is
160
+ // intact for restore / the final message.
161
+ if (st.stream) st.stream.addTool(evt.name, evt.input || {});
162
+ else st.toolList.push({ name: evt.name, input: evt.input || {} });
142
163
  } else if (evt.type === 'heartbeat') {
143
164
  /* server keep-alive — ignored on purpose */
144
165
  } else if (evt.type === 'done') {
@@ -150,81 +171,82 @@
150
171
  }
151
172
 
152
173
  if (errorEvt) {
153
- stream.replaceWithError(errorEvt.error || 'Error');
154
- messages.push({ role: 'error', text: errorEvt.error || 'Error' });
155
- saveState();
174
+ if (st.stream) st.stream.replaceWithError(errorEvt.error || 'Error');
175
+ recordTabMessage(turnTabId, { role: 'error', text: errorEvt.error || 'Error' });
156
176
  turnOutcome = 'error';
157
177
  return;
158
178
  }
159
179
 
160
180
  if (doneEvt) {
161
- var finalText = mergeStreamText(streamedText, doneEvt.text || '');
162
- stream.finalize(finalText || '(no response)');
163
- messages.push({ role: 'assistant', text: finalText || '(no response)', toolCalls: stream.getToolList() });
164
- if (doneEvt.sessionId !== undefined) sessionId = doneEvt.sessionId || null;
165
- saveState();
181
+ var finalText = mergeStreamText(st.streamedText, doneEvt.text || '');
182
+ if (st.stream) st.stream.finalize(finalText || '(no response)');
183
+ recordTabMessage(turnTabId, { role: 'assistant', text: finalText || '(no response)', toolCalls: st.toolList });
184
+ if (doneEvt.sessionId !== undefined) _setTabSessionId(turnTabId, doneEvt.sessionId || null);
166
185
  if (doneEvt.actionParseError) {
167
186
  var pe = '⚠️ Actions block emitted but JSON could not be parsed — no actions executed.';
168
- appendActionStatus('err', pe);
169
- messages.push({ role: 'action', severity: 'err', text: pe });
170
- saveState();
187
+ if (turnTabId === tabId) appendActionStatus('err', pe);
188
+ recordTabMessage(turnTabId, { role: 'action', severity: 'err', text: pe });
171
189
  }
172
190
  if (doneEvt.actions && doneEvt.actions.length > 0) {
173
- renderActionResults(doneEvt.actions, doneEvt.actionResults || []);
191
+ renderActionResults(turnTabId, doneEvt.actions, doneEvt.actionResults || []);
174
192
  }
175
193
  // Refresh status + history opportunistically — actions probably
176
194
  // changed something.
177
195
  scheduleStatusRefresh(800);
178
196
  } else {
179
- stream.finalize(streamedText || '(stream ended before completion)');
180
- messages.push({ role: 'assistant', text: streamedText || '(stream ended before completion)', toolCalls: stream.getToolList() });
181
- saveState();
197
+ if (st.stream) st.stream.finalize(st.streamedText || '(stream ended before completion)');
198
+ recordTabMessage(turnTabId, { role: 'assistant', text: st.streamedText || '(stream ended before completion)', toolCalls: st.toolList });
182
199
  }
183
200
  } catch (e) {
184
201
  if (e && (e.name === 'AbortError' || /aborted/i.test(String(e.message || '')))) {
185
- userAborted = true;
202
+ st.userAborted = true;
186
203
  turnOutcome = 'aborted';
187
- if (streamedText) {
188
- stream.finalize(streamedText);
189
- messages.push({ role: 'assistant', text: streamedText, toolCalls: stream.getToolList() });
204
+ if (st.streamedText) {
205
+ if (st.stream) st.stream.finalize(st.streamedText);
206
+ recordTabMessage(turnTabId, { role: 'assistant', text: st.streamedText, toolCalls: st.toolList });
190
207
  } else {
191
- stream.replaceWithError('Stopped.');
192
- messages.push({ role: 'error', text: 'Stopped.' });
208
+ if (st.stream) st.stream.replaceWithError('Stopped.');
209
+ recordTabMessage(turnTabId, { role: 'error', text: 'Stopped.' });
193
210
  }
194
- saveState();
195
211
  } else {
196
212
  turnOutcome = 'error';
197
213
  var msg = 'Send failed: ' + (e && e.message ? e.message : e);
198
- stream.replaceWithError(msg);
199
- messages.push({ role: 'error', text: msg });
200
- saveState();
214
+ if (st.stream) st.stream.replaceWithError(msg);
215
+ recordTabMessage(turnTabId, { role: 'error', text: msg });
201
216
  }
202
217
  } finally {
203
- setSending(false);
204
- abortController = null;
205
- if (turnTabId === tabId && !userAborted) inputEl.focus();
218
+ setSendingFor(turnTabId, false);
219
+ st.abortController = null;
220
+ st.stream = null;
221
+ if (turnTabId === tabId && !st.userAborted) inputEl.focus();
206
222
  _afterTurn(turnTabId, turnOutcome);
207
223
  }
208
224
  }
209
225
 
210
- function abortInFlight() {
211
- if (!sending || !abortController) return;
226
+ // Abort a SPECIFIC tab's in-flight turn — kills the server turn AND aborts the
227
+ // fetch. Wired to ONLY two callers (W-mqqvba66): the explicit Stop button
228
+ // (abortInFlight → active tab) and closeSlimTab (closing a streaming tab).
229
+ // A plain tab SWITCH never calls this.
230
+ function abortTab(id) {
231
+ var st = tabStreams[id];
232
+ if (!st || !st.sending || !st.abortController) return;
212
233
  try {
213
234
  fetch('/api/command-center/abort', {
214
235
  method: 'POST',
215
236
  headers: { 'Content-Type': 'application/json' },
216
- body: JSON.stringify({ tabId: tabId }),
237
+ body: JSON.stringify({ tabId: id }),
217
238
  }).catch(function() {});
218
239
  } catch (_e) { /* ignore */ }
219
- try { abortController.abort(); } catch (_e) { /* ignore */ }
240
+ try { st.abortController.abort(); } catch (_e) { /* ignore */ }
220
241
  }
242
+ function abortInFlight() { abortTab(tabId); }
221
243
 
222
244
  async function slimChatNew() {
223
- if (sending) abortInFlight();
224
- // New chat = fresh session: drop the leaving tab's pending queue (#7). The
225
- // aborted in-flight turn's _afterTurn then finds an empty queue and no-ops.
226
- delete queues[tabId];
227
- queueSuspended[tabId] = false;
245
+ // New chat creates a fresh tab and switches to it. Like switchSlimTab, it
246
+ // does NOT abort the leaving tab's in-flight stream (W-mqqvba66) — that turn
247
+ // keeps streaming + draining its queue in the background. Detach its live
248
+ // bubble first since the transcript is about to be wiped.
249
+ _detachActiveStream();
228
250
  // Register a fresh tab in the shared cc-tabs store and switch slim onto
229
251
  // it — classic's other tabs are left intact, and the previous session
230
252
  // keeps its own server-side cc-sessions.json entry (no DELETE).
@@ -241,6 +263,7 @@
241
263
  saveState();
242
264
  rerenderHistory();
243
265
  renderTabBar();
266
+ _refreshComposerUI();
244
267
  inputEl.focus();
245
268
  }
246
269
 
@@ -17,6 +17,7 @@
17
17
  bindModalClose('slim-agent-modal', 'slim-agent-close', _stopAgentDetailRuntime);
18
18
  bindModalClose('slim-tools-modal', 'slim-tools-close');
19
19
  bindModalClose('slim-tile-modal', 'slim-tile-close');
20
+ bindModalClose('slim-plans-modal', 'slim-plans-close');
20
21
 
21
22
  function tileEmpty(body, text) {
22
23
  var d = document.createElement('div');
@@ -320,6 +321,9 @@
320
321
  // The Knowledge tile opens the unified Knowledge control panel (Pinned
321
322
  // Context + Notes + KB tabs) rather than the read-only tile detail view.
322
323
  if (key === 'knowledge') { openKnowledgeModal(); return; }
324
+ // The Plans tile opens the tabbed Plans/PRD control panel (rendered by
325
+ // dashboard/slim/js/plans.js) rather than the read-only tile detail view.
326
+ if (key === 'plans') { openPlansModal(); return; }
323
327
  var view = TILE_VIEWS[key];
324
328
  if (!view) return;
325
329
  var modal = document.getElementById('slim-tile-modal');
@@ -356,3 +360,22 @@
356
360
  }
357
361
  })();
358
362
 
363
+ // ── Plans control-panel wiring ─────────────────────────────────────
364
+ // The Plans tile opens #slim-plans-modal (routed in openTileModal above).
365
+ // plans.js is the renderer only; the tab-switch + lazy tile-count wiring
366
+ // lives here, mirroring knowledge.js#bindKnowledgeUi. The plan/PRD count is
367
+ // not part of the /api/status poll, so it is fetched lazily from /api/plans
368
+ // shortly after first paint (and refreshed on each modal open by the renderer).
369
+ (function bindPlansUi() {
370
+ var tabBar = document.getElementById('slim-plans-tabs');
371
+ if (tabBar) {
372
+ tabBar.addEventListener('click', function(ev) {
373
+ var btn = ev.target && ev.target.closest ? ev.target.closest('.kn-tab') : null;
374
+ if (!btn) return;
375
+ var tab = btn.getAttribute('data-plans-tab');
376
+ if (tab && typeof setPlansTab === 'function') setPlansTab(tab);
377
+ });
378
+ }
379
+ if (typeof loadPlansCounts === 'function') setTimeout(loadPlansCounts, 1500);
380
+ })();
381
+