@yemi33/minions 0.1.2245 → 0.1.2247

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.
@@ -112,6 +112,11 @@
112
112
  <div class="cockpit-value dim">0</div>
113
113
  <div class="cockpit-detail">pinned context, notes &amp; KB</div>
114
114
  </div>
115
+ <div class="cockpit-tile" data-tile="plans">
116
+ <div class="cockpit-label"><span class="cockpit-dot"></span> Plans</div>
117
+ <div class="cockpit-value dim">0</div>
118
+ <div class="cockpit-detail">drafts &amp; materialized PRDs</div>
119
+ </div>
115
120
  </div>
116
121
  </div>
117
122
  </div>
@@ -212,6 +217,29 @@
212
217
  </div>
213
218
  </div>
214
219
 
220
+ <!-- Plans + PRD control panel — opened from the "Plans" status tile. Two tabs:
221
+ Plans (.md plan drafts) and PRD (materialized PRDs). Each tab is rendered
222
+ lazily by dashboard/slim/js/plans.js into #slim-plans-body. Backed by the
223
+ existing endpoints (no new server routes):
224
+ Plans — /api/plans (list), /api/plans/:file (read), and the lifecycle
225
+ POSTs (/api/plans/{approve,execute,reject,pause,regenerate,
226
+ archive,delete,unarchive}).
227
+ PRD — the .json entries from /api/plans plus /api/prd and the
228
+ read-only subset of /api/prd-items. -->
229
+ <div class="modal-bg" id="slim-plans-modal">
230
+ <div class="modal slim-plans-modal-inner">
231
+ <div class="modal-header">
232
+ <h3>Plans</h3>
233
+ <div class="kn-tabs" id="slim-plans-tabs" role="tablist">
234
+ <button class="kn-tab active" id="slim-plans-tab-plans" data-plans-tab="plans" type="button" role="tab" aria-selected="true" aria-controls="slim-plans-body">Plans</button>
235
+ <button class="kn-tab" id="slim-plans-tab-prd" data-plans-tab="prd" type="button" role="tab" aria-selected="false" aria-controls="slim-plans-body">PRD</button>
236
+ </div>
237
+ <button id="slim-plans-close" class="icon-btn" title="Close" style="margin-left:auto">&times;</button>
238
+ </div>
239
+ <div class="modal-body" id="slim-plans-body" role="tabpanel" tabindex="0" aria-labelledby="slim-plans-tab-plans"></div>
240
+ </div>
241
+ </div>
242
+
215
243
  <!-- Pin editor — create (Pin Content action / "+ Pin") or edit an existing
216
244
  note. Submits to POST /api/pinned (create) or /api/pinned/update (edit). -->
217
245
  <div class="modal-bg" id="slim-pin-edit-modal">
@@ -27,8 +27,27 @@
27
27
 
28
28
  var sessionId = null;
29
29
  var messages = [];
30
- var sending = false;
31
- var abortController = null;
30
+ // Per-tab streaming runtime (W-mqqvba66). Stream state lives here, keyed by
31
+ // tabId, so an in-flight turn keeps running AND accumulating when the user
32
+ // switches to another tab — mirroring the classic dashboard's per-tab
33
+ // tab._sending / _abortController / _segments model (command-center.js). The
34
+ // old module-global `sending` / `abortController` (one turn at a time) caused
35
+ // a mid-stream tab switch to abort the server turn and push the captured
36
+ // partial onto the wrong (now-active) tab. Each entry:
37
+ // { sending, abortController, streamedText, toolList, stream, userAborted }
38
+ // `stream` is the live DOM bubble handle — only set while that tab is the
39
+ // visible pane (null on background tabs; re-created on switch-back).
40
+ var tabStreams = {};
41
+ function _streamState(id) {
42
+ return tabStreams[id] || (tabStreams[id] = {
43
+ sending: false, abortController: null, streamedText: '',
44
+ toolList: [], stream: null, userAborted: false,
45
+ });
46
+ }
47
+ function _isTabSending(id) {
48
+ var st = tabStreams[id];
49
+ return !!(st && st.sending);
50
+ }
32
51
  // Per-tab queue of follow-up messages submitted while a turn is streaming
33
52
  // (W-mqayw3x6). Keyed by tabId so each chat tab keeps its own queue across
34
53
  // tab switches. In-memory only — a hard refresh drops the queue by design.
@@ -129,14 +148,19 @@
129
148
  if (m.severity) out.severity = m.severity;
130
149
  return out;
131
150
  }
132
- function _deriveTitle() {
133
- for (var i = 0; i < messages.length; i++) {
134
- if (messages[i] && messages[i].role === 'user' && messages[i].text) {
135
- return messages[i].text.slice(0, 40);
151
+ function _deriveTitleFrom(msgs) {
152
+ for (var i = 0; i < (msgs || []).length; i++) {
153
+ var m = msgs[i];
154
+ if (m && m.role === 'user') {
155
+ var t = (typeof m.text === 'string') ? m.text : _htmlToText(m.html);
156
+ if (t) return t.slice(0, 40);
136
157
  }
137
158
  }
138
159
  return 'New chat';
139
160
  }
161
+ function _deriveTitle() {
162
+ return _deriveTitleFrom(messages);
163
+ }
140
164
 
141
165
  function loadState() {
142
166
  try {
@@ -168,6 +192,70 @@
168
192
  }, 300);
169
193
  }
170
194
 
195
+ // Append a message to a SPECIFIC tab (W-mqqvba66). For the active (visible)
196
+ // tab we reuse the in-memory `messages` array + debounced saveState; for a
197
+ // background tab — one whose turn is still streaming while the user views a
198
+ // different tab — we write straight through to the shared cc-tabs store so the
199
+ // response is recorded on its OWN tab and never mis-attributed to the live
200
+ // `messages`/`tabId`. The originating tabId is captured at send time and
201
+ // threaded through every append (see command-send.js _performSend).
202
+ function recordTabMessage(id, msg) {
203
+ if (id === tabId) {
204
+ messages.push(msg);
205
+ saveState();
206
+ } else {
207
+ _persistBackgroundMessage(id, msg);
208
+ }
209
+ }
210
+ function _persistBackgroundMessage(id, msg) {
211
+ try {
212
+ var tabs = _readCcTabs();
213
+ var idx = _findTabIdx(tabs, id);
214
+ if (idx < 0) return; // tab was closed/removed — drop rather than resurrect it
215
+ var tab = tabs[idx];
216
+ var msgs = Array.isArray(tab.messages) ? tab.messages : [];
217
+ msgs.push(_slimToStored(msg));
218
+ tab.messages = msgs.slice(-SLIM_MAX_MESSAGES);
219
+ tab.title = _deriveTitleFrom(tab.messages);
220
+ tabs[idx] = tab;
221
+ _writeCcTabs(tabs);
222
+ } catch (_e) { /* localStorage full */ }
223
+ }
224
+ // Resolve / persist a tab's server session id, targeting the right store entry
225
+ // regardless of which tab is currently visible.
226
+ function _getTabSessionId(id) {
227
+ if (id === tabId) return sessionId;
228
+ try {
229
+ var tabs = _readCcTabs();
230
+ var idx = _findTabIdx(tabs, id);
231
+ return idx >= 0 ? (tabs[idx].sessionId || null) : null;
232
+ } catch (_e) { return null; }
233
+ }
234
+ function _setTabSessionId(id, sid) {
235
+ if (id === tabId) { sessionId = sid; saveState(); return; }
236
+ try {
237
+ var tabs = _readCcTabs();
238
+ var idx = _findTabIdx(tabs, id);
239
+ if (idx < 0) return;
240
+ tabs[idx].sessionId = sid;
241
+ _writeCcTabs(tabs);
242
+ } catch (_e) { /* localStorage full */ }
243
+ }
244
+ // True if a given tab already has at least one user message (drives the
245
+ // first-message title-chip refresh), targeting the active in-memory array or
246
+ // the persisted store for a background tab.
247
+ function _tabHasUserMessage(id) {
248
+ if (id === tabId) {
249
+ return messages.some(function(m) { return m && m.role === 'user'; });
250
+ }
251
+ try {
252
+ var tabs = _readCcTabs();
253
+ var idx = _findTabIdx(tabs, id);
254
+ if (idx < 0) return false;
255
+ return (tabs[idx].messages || []).some(function(m) { return m && m.role === 'user'; });
256
+ } catch (_e) { return false; }
257
+ }
258
+
171
259
  function escHtmlChat(s) {
172
260
  return String(s == null ? '' : s)
173
261
  .replace(/&/g, '&amp;')
@@ -425,7 +513,7 @@
425
513
  return { panel: panel, scroll: scroll };
426
514
  }
427
515
 
428
- function buildStreamBubble() {
516
+ function buildStreamBubble(initialText, initialTools) {
429
517
  clearEmpty();
430
518
  var div = document.createElement('div');
431
519
  div.className = 'chat-msg assistant streaming';
@@ -445,11 +533,20 @@
445
533
  thinking.innerHTML = 'Thinking<span class="chat-thinking-dots"><span></span><span></span><span></span></span>';
446
534
  toolbar.appendChild(thinking);
447
535
 
448
- var toolList = [];
536
+ // Reuse the caller's backing tool list (shared by reference) when restoring
537
+ // a live bubble after a tab switch, so already-streamed tools replay and new
538
+ // ones keep appending into the SAME per-tab array (W-mqqvba66).
539
+ var toolList = Array.isArray(initialTools) ? initialTools : [];
449
540
  var tp = buildToolsPanel(toolList);
450
- tp.panel.style.display = 'none';
541
+ tp.panel.style.display = toolList.length ? '' : 'none';
451
542
  toolbar.appendChild(tp.panel);
452
543
 
544
+ // Seed the partial text accumulated so far (restore-on-switch-back path).
545
+ if (initialText) {
546
+ // eslint-disable-next-line no-unsanitized/property -- reason: renderMarkdown() escapes input via escHtmlChat() first; later substitutions only re-insert pre-escaped <pre><code>/<a>/<strong>/<em> spans with scheme-validated hrefs
547
+ body.innerHTML = renderMarkdown(initialText);
548
+ }
549
+
453
550
  _appendMsgEl(div);
454
551
  scrollToBottom();
455
552
 
@@ -489,8 +586,15 @@
489
586
  };
490
587
  }
491
588
 
492
- function setSending(on) {
493
- sending = on;
589
+ // Stream state is per-tab now (W-mqqvba66): toggle the originating tab's
590
+ // sending flag, and only refresh the composer chrome when that tab is the
591
+ // visible one (the Send/Stop buttons always reflect the ACTIVE tab).
592
+ function setSendingFor(id, on) {
593
+ _streamState(id).sending = on;
594
+ if (id === tabId) _refreshComposerUI();
595
+ }
596
+ function _refreshComposerUI() {
597
+ var on = _isTabSending(tabId);
494
598
  // Composer stays interactive while a turn streams so the user can keep
495
599
  // typing and queue follow-up messages (W-mqayw3x6 #1). The Send button
496
600
  // stays enabled — submitting mid-turn enqueues — and only its label
@@ -561,30 +665,56 @@
561
665
  tabsEl.appendChild(add);
562
666
  }
563
667
 
564
- // Switch the visible pane to another shared tab. Aborts an in-flight stream
565
- // first (the SSE is bound to the current tabId), then re-hydrates from the
566
- // target tab's persisted state.
668
+ // Detach the active tab's live stream-bubble handle before its transcript DOM
669
+ // is wiped (rerenderHistory). The background fetch loop checks `st.stream`
670
+ // every chunk, so nulling it makes the still-running turn stop touching the
671
+ // DOM while keeping its partial accumulating into st.streamedText / saveState.
672
+ function _detachActiveStream() {
673
+ var st = tabStreams[tabId];
674
+ if (st) st.stream = null;
675
+ }
676
+ // Re-attach a live streaming bubble for a tab the user is switching BACK to
677
+ // while its turn is still in flight, seeded from the tab's accumulated partial
678
+ // + tool list (mirror classic's _restoreStreamHtml). The running fetch loop
679
+ // resumes updating st.stream as new chunks arrive; slim has no elapsed-timer
680
+ // chrome, so unlike classic no 1s restore interval is needed.
681
+ function _restoreStreamFor(id) {
682
+ var st = tabStreams[id];
683
+ if (!st || !st.sending) return;
684
+ st.stream = buildStreamBubble(st.streamedText, st.toolList);
685
+ }
686
+
687
+ // Switch the visible pane to another shared tab. The previous tab's in-flight
688
+ // stream is KEPT RUNNING in the background (W-mqqvba66) — we never abort on a
689
+ // switch — and the target tab is re-hydrated from its own persisted state,
690
+ // restoring its live streaming bubble if it is mid-turn.
567
691
  function switchSlimTab(id) {
568
692
  if (!id || id === tabId) return;
569
- if (sending) abortInFlight();
693
+ _detachActiveStream();
570
694
  tabId = id;
571
695
  try { localStorage.setItem(CC_ACTIVE_KEY, id); } catch (_e) { /* private mode */ }
572
696
  sessionId = null;
573
697
  messages = [];
574
698
  loadState();
575
699
  rerenderHistory();
700
+ _restoreStreamFor(id);
576
701
  renderTabBar();
702
+ _refreshComposerUI();
577
703
  inputEl.focus();
578
704
  }
579
705
 
580
706
  function closeSlimTab(id) {
581
707
  if (!id) return;
582
- if (id === tabId && sending) abortInFlight();
708
+ // Closing a streaming tab DOES abort + delete its server session (one of the
709
+ // only two abort paths left, alongside the explicit Stop button).
710
+ if (_isTabSending(id)) abortTab(id);
583
711
  var tabs = _readCcTabs();
584
712
  var idx = _findTabIdx(tabs, id);
585
713
  if (idx < 0) return;
586
714
  tabs.splice(idx, 1);
587
715
  _writeCcTabs(tabs);
716
+ delete queues[id];
717
+ delete queueSuspended[id];
588
718
  // Evict the server-side session for the closed tab (mirrors classic).
589
719
  try { fetch('/api/cc-sessions/' + encodeURIComponent(id), { method: 'DELETE' }).catch(function() {}); } catch (_e) { /* ignore */ }
590
720
  if (id !== tabId) { renderTabBar(); return; }
@@ -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
+