@yemi33/minions 0.1.2175 → 0.1.2177

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.
@@ -3,10 +3,13 @@
3
3
  // Signal the engine to tick immediately (pick up new work without waiting 60s)
4
4
  function wakeEngine() { fetch('/api/engine/wakeup', { method: 'POST' }).catch(() => {}); }
5
5
 
6
- // "Try new Slim UX" — flips the slim-ux feature flag ON then reloads so the
7
- // root route serves the slim cockpit (the takeover is checked at request time,
8
- // so no engine restart is needed). The first-visit welcome popup is gated on
9
- // the browser side in dashboard/slim/js/settings.js, not here.
6
+ // "Try new Slim UX" — flips the slim-ux feature flag ON then navigates to the
7
+ // root route so it serves the slim cockpit (the takeover is checked at request
8
+ // time, so no engine restart is needed). We navigate to '/' rather than
9
+ // location.reload() because the slim takeover is gated on pathname === '/';
10
+ // reloading from a non-home classic page (/work, /prs, …) preserves the path
11
+ // and silently re-serves the classic dashboard. The first-visit welcome popup
12
+ // is gated on the browser side in dashboard/slim/js/settings.js, not here.
10
13
  async function trySlimUx(btn) {
11
14
  if (btn) { btn.disabled = true; btn.textContent = 'Switching…'; }
12
15
  try {
@@ -16,7 +19,7 @@ async function trySlimUx(btn) {
16
19
  body: JSON.stringify({ id: 'slim-ux', enabled: true }),
17
20
  });
18
21
  if (!res.ok) throw new Error('HTTP ' + res.status);
19
- location.reload();
22
+ location.href = '/';
20
23
  } catch (e) {
21
24
  if (btn) { btn.disabled = false; btn.textContent = '✨ Try new Slim UX'; }
22
25
  alert('Could not switch to Slim UX: ' + (e && e.message ? e.message : 'unknown error'));
@@ -21,7 +21,10 @@
21
21
  <div class="panel panel-actions">
22
22
  <div class="panel-header">
23
23
  Actions
24
- <span class="panel-sub">Command Center &amp; quick triggers</span>
24
+ <span class="panel-head-meta">
25
+ <span class="cc-model-badge" id="cc-model-badge" hidden></span>
26
+ <span class="panel-sub">Command Center &amp; quick triggers</span>
27
+ </span>
25
28
  </div>
26
29
  <div class="actions-chat">
27
30
  <div class="chat-tabs" id="chat-tabs"></div>
@@ -39,7 +42,7 @@
39
42
  rows="2"
40
43
  placeholder="Ask anything or give a command... (Enter to send, Shift+Enter for newline)"
41
44
  ></textarea>
42
- <button id="chat-stop" class="chat-stop" type="button" title="Stop the in-flight response">Stop</button>
45
+ <button id="chat-stop" class="chat-stop" type="button" title="Stop the in-flight response" aria-label="Stop agent"><svg class="chat-stop-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"></circle><rect x="8" y="8" width="8" height="8" rx="1" fill="currentColor"></rect></svg></button>
43
46
  <button id="chat-send" class="chat-send" type="button">Send</button>
44
47
  </div>
45
48
  </div>
@@ -77,23 +80,23 @@
77
80
  count now lives in the Team cards above, so that tile is removed. -->
78
81
  <div class="cockpit-section">
79
82
  <div class="cockpit-grid" id="cockpit-grid">
80
- <div class="cockpit-tile" data-tile="engine">
83
+ <div class="cockpit-tile cockpit-tile--full" data-tile="engine">
81
84
  <div class="cockpit-label"><span class="cockpit-dot"></span> Engine</div>
82
85
  <div class="cockpit-value dim">—</div>
83
86
  <div class="cockpit-detail">checking&hellip;</div>
84
87
  </div>
85
- <div class="cockpit-tile" data-tile="dispatches">
86
- <div class="cockpit-label"><span class="cockpit-dot"></span> Active dispatches</div>
87
- <div class="cockpit-value dim">0</div>
88
- <div class="cockpit-detail">none in flight</div>
89
- </div>
90
88
  <div class="cockpit-tile" data-tile="queued">
91
89
  <div class="cockpit-label"><span class="cockpit-dot"></span> Queued work</div>
92
90
  <div class="cockpit-value dim">0</div>
93
91
  <div class="cockpit-detail">queue empty</div>
94
92
  </div>
93
+ <div class="cockpit-tile" data-tile="dispatches">
94
+ <div class="cockpit-label"><span class="cockpit-dot"></span> Active dispatches</div>
95
+ <div class="cockpit-value dim">0</div>
96
+ <div class="cockpit-detail">no agents working</div>
97
+ </div>
95
98
  <div class="cockpit-tile" data-tile="prs">
96
- <button id="slim-tile-linkpr-chip" class="linkpr-chip on-tile" type="button" title="Link a pull request">+ Link PR</button>
99
+ <button id="slim-tile-linkpr-chip" class="linkpr-chip on-tile" type="button" title="Link a pull request" aria-label="Link a pull request">+</button>
97
100
  <div class="cockpit-label"><span class="cockpit-dot"></span> Active PRs</div>
98
101
  <div class="cockpit-value dim">0</div>
99
102
  <div class="cockpit-detail">no open PRs</div>
@@ -29,6 +29,12 @@
29
29
  var messages = [];
30
30
  var sending = false;
31
31
  var abortController = null;
32
+ // Per-tab queue of follow-up messages submitted while a turn is streaming
33
+ // (W-mqayw3x6). Keyed by tabId so each chat tab keeps its own queue across
34
+ // tab switches. In-memory only — a hard refresh drops the queue by design.
35
+ var queues = {};
36
+ var queueSuspended = {}; // tabId -> true after an abort/error suspends auto-drain
37
+ var queueEl = null; // bottom-pinned container that holds the queued bubbles
32
38
  var currentProject = null;
33
39
  try { currentProject = localStorage.getItem(SLIM_PROJECT_KEY) || null; } catch (_e) { /* private mode */ }
34
40
 
@@ -241,7 +247,7 @@
241
247
  } else {
242
248
  div.textContent = text;
243
249
  }
244
- msgsEl.appendChild(div);
250
+ _appendMsgEl(div);
245
251
  scrollToBottom();
246
252
  return div;
247
253
  }
@@ -249,11 +255,85 @@
249
255
  var div = document.createElement('div');
250
256
  div.className = 'chat-action ' + (severity || '');
251
257
  div.textContent = label;
252
- msgsEl.appendChild(div);
258
+ _appendMsgEl(div);
253
259
  scrollToBottom();
254
260
  return div;
255
261
  }
256
262
 
263
+ // ── Queued-message rendering (W-mqayw3x6) ──────────────────────────
264
+ function _getQueue() {
265
+ return queues[tabId] || (queues[tabId] = []);
266
+ }
267
+ // Keep a single bottom-pinned container so queued bubbles always sit below
268
+ // the live transcript (newest content nearest the composer). appendChild on
269
+ // an already-attached node moves it, so this re-pins the container to the end.
270
+ function _ensureQueueEl() {
271
+ if (!queueEl || queueEl.parentNode !== msgsEl) {
272
+ queueEl = document.createElement('div');
273
+ queueEl.className = 'chat-queue';
274
+ }
275
+ msgsEl.appendChild(queueEl);
276
+ return queueEl;
277
+ }
278
+ // Insert a real/stream bubble above the queue container so the pending
279
+ // queued bubbles stay pinned at the bottom of the transcript.
280
+ function _appendMsgEl(el) {
281
+ if (queueEl && queueEl.parentNode === msgsEl) msgsEl.insertBefore(el, queueEl);
282
+ else msgsEl.appendChild(el);
283
+ }
284
+ // Rebuild the queued-message bubbles for the active tab: greyed-out user
285
+ // bubbles with a per-message dismiss. When auto-drain is suspended (after an
286
+ // abort/error) a "Send queued (N)" control lets the user resume explicitly.
287
+ function renderQueue() {
288
+ var q = _getQueue();
289
+ if (!q.length) {
290
+ if (queueEl && queueEl.parentNode) queueEl.parentNode.removeChild(queueEl);
291
+ queueEl = null;
292
+ queueSuspended[tabId] = false;
293
+ return;
294
+ }
295
+ _ensureQueueEl();
296
+ queueEl.textContent = '';
297
+ if (queueSuspended[tabId]) {
298
+ var resume = document.createElement('button');
299
+ resume.className = 'chat-queue-resume';
300
+ resume.type = 'button';
301
+ resume.textContent = 'Send queued (' + q.length + ')';
302
+ resume.title = 'Send the queued messages now';
303
+ resume.addEventListener('click', function() {
304
+ queueSuspended[tabId] = false;
305
+ renderQueue();
306
+ _drainQueue();
307
+ });
308
+ queueEl.appendChild(resume);
309
+ }
310
+ q.forEach(function(text, i) {
311
+ var row = document.createElement('div');
312
+ row.className = 'chat-msg user queued';
313
+ var span = document.createElement('span');
314
+ span.className = 'chat-queue-text';
315
+ span.textContent = text;
316
+ row.appendChild(span);
317
+ var badge = document.createElement('span');
318
+ badge.className = 'chat-queue-badge';
319
+ badge.textContent = 'queued';
320
+ row.appendChild(badge);
321
+ var dismiss = document.createElement('button');
322
+ dismiss.className = 'chat-queue-dismiss';
323
+ dismiss.type = 'button';
324
+ dismiss.textContent = '×';
325
+ dismiss.title = 'Remove from queue';
326
+ dismiss.addEventListener('click', function() {
327
+ var qq = _getQueue();
328
+ qq.splice(i, 1);
329
+ renderQueue();
330
+ });
331
+ row.appendChild(dismiss);
332
+ queueEl.appendChild(row);
333
+ });
334
+ scrollToBottom();
335
+ }
336
+
257
337
  // Render a tool invocation as a one-line string. `full` keeps the complete
258
338
  // command/args (used by the tool-calls modal); otherwise long values are
259
339
  // clipped for the collapsed 3-line panel.
@@ -370,7 +450,7 @@
370
450
  tp.panel.style.display = 'none';
371
451
  toolbar.appendChild(tp.panel);
372
452
 
373
- msgsEl.appendChild(div);
453
+ _appendMsgEl(div);
374
454
  scrollToBottom();
375
455
 
376
456
  return {
@@ -411,26 +491,32 @@
411
491
 
412
492
  function setSending(on) {
413
493
  sending = on;
414
- sendBtn.disabled = on;
415
- sendBtn.textContent = on ? 'Sending…' : 'Send';
494
+ // Composer stays interactive while a turn streams so the user can keep
495
+ // typing and queue follow-up messages (W-mqayw3x6 #1). The Send button
496
+ // stays enabled — submitting mid-turn enqueues — and only its label
497
+ // reflects the in-flight state.
498
+ sendBtn.disabled = false;
499
+ sendBtn.textContent = on ? 'Queue' : 'Send';
416
500
  stopBtn.style.display = on ? 'block' : 'none';
417
- inputEl.disabled = on;
501
+ inputEl.disabled = false;
418
502
  }
419
503
 
420
504
  function rerenderHistory() {
421
505
  msgsEl.innerHTML = '';
506
+ queueEl = null; // detached by the innerHTML reset; renderQueue rebuilds it
422
507
  if (!messages.length) {
423
508
  var empty = document.createElement('div');
424
509
  empty.className = 'chat-empty';
425
510
  empty.textContent = 'No messages yet — say hi to Command Center.';
426
511
  msgsEl.appendChild(empty);
427
- return;
428
- }
429
- for (var i = 0; i < messages.length; i++) {
430
- var m = messages[i];
431
- if (m.role === 'action') appendActionStatus(m.severity || '', m.text || '');
432
- else appendBubble(m.role, m.text || '', m.toolCalls);
512
+ } else {
513
+ for (var i = 0; i < messages.length; i++) {
514
+ var m = messages[i];
515
+ if (m.role === 'action') appendActionStatus(m.severity || '', m.text || '');
516
+ else appendBubble(m.role, m.text || '', m.toolCalls);
517
+ }
433
518
  }
519
+ renderQueue();
434
520
  }
435
521
  // ── Command Center tab bar (shared with the classic dashboard) ──
436
522
  // One chip per shared cc-tabs entry, so the same conversations are visible
@@ -23,10 +23,60 @@
23
23
  saveState();
24
24
  }
25
25
 
26
- async function sendMessage() {
27
- if (sending) return;
26
+ // 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).
30
+ function sendMessage() {
28
31
  var text = inputEl.value.trim();
29
32
  if (!text) return;
33
+ inputEl.value = '';
34
+ inputEl.style.height = 'auto';
35
+ if (sending) {
36
+ _enqueue(text);
37
+ inputEl.focus();
38
+ return;
39
+ }
40
+ _performSend(text);
41
+ }
42
+
43
+ // Append to the active tab's pending queue and render the greyed bubbles.
44
+ function _enqueue(text) {
45
+ _getQueue().push(text);
46
+ renderQueue();
47
+ }
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;
55
+ var next = q.shift();
56
+ renderQueue();
57
+ _performSend(next);
58
+ }
59
+
60
+ // 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).
63
+ function _afterTurn(turnTabId, outcome) {
64
+ var q = queues[turnTabId];
65
+ if (!q || !q.length) return;
66
+ if (outcome === 'done' && turnTabId === tabId) {
67
+ _drainQueue();
68
+ } else {
69
+ queueSuspended[turnTabId] = true;
70
+ if (turnTabId === tabId) renderQueue();
71
+ }
72
+ }
73
+
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;
79
+ var turnOutcome = 'done';
30
80
 
31
81
  // The tab title is derived from the first user message; only that message
32
82
  // changes the chip, so re-render the bar once instead of on every send.
@@ -36,8 +86,6 @@
36
86
  saveState();
37
87
  if (wasFirstUser) renderTabBar();
38
88
 
39
- inputEl.value = '';
40
- inputEl.style.height = 'auto';
41
89
  setSending(true);
42
90
 
43
91
  var stream = buildStreamBubble();
@@ -54,7 +102,11 @@
54
102
  var res = await fetch('/api/command-center/stream', {
55
103
  method: 'POST',
56
104
  headers: { 'Content-Type': 'application/json' },
57
- body: JSON.stringify({ message: text, tabId: tabId, sessionId: sessionId, currentProject: currentProject || undefined }),
105
+ // currentProject is the slim project picker's explicit selection (null
106
+ // when the indicator reads "Select project"). noProjectSelected lets the
107
+ // server inject a "no project — ask the user" CC preamble instead of
108
+ // 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 }),
58
110
  signal: signal,
59
111
  });
60
112
  if (!res.ok) {
@@ -101,6 +153,7 @@
101
153
  stream.replaceWithError(errorEvt.error || 'Error');
102
154
  messages.push({ role: 'error', text: errorEvt.error || 'Error' });
103
155
  saveState();
156
+ turnOutcome = 'error';
104
157
  return;
105
158
  }
106
159
 
@@ -130,6 +183,7 @@
130
183
  } catch (e) {
131
184
  if (e && (e.name === 'AbortError' || /aborted/i.test(String(e.message || '')))) {
132
185
  userAborted = true;
186
+ turnOutcome = 'aborted';
133
187
  if (streamedText) {
134
188
  stream.finalize(streamedText);
135
189
  messages.push({ role: 'assistant', text: streamedText, toolCalls: stream.getToolList() });
@@ -139,6 +193,7 @@
139
193
  }
140
194
  saveState();
141
195
  } else {
196
+ turnOutcome = 'error';
142
197
  var msg = 'Send failed: ' + (e && e.message ? e.message : e);
143
198
  stream.replaceWithError(msg);
144
199
  messages.push({ role: 'error', text: msg });
@@ -147,7 +202,8 @@
147
202
  } finally {
148
203
  setSending(false);
149
204
  abortController = null;
150
- if (!userAborted) inputEl.focus();
205
+ if (turnTabId === tabId && !userAborted) inputEl.focus();
206
+ _afterTurn(turnTabId, turnOutcome);
151
207
  }
152
208
  }
153
209
 
@@ -165,6 +221,10 @@
165
221
 
166
222
  async function slimChatNew() {
167
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;
168
228
  // Register a fresh tab in the shared cc-tabs store and switch slim onto
169
229
  // it — classic's other tabs are left intact, and the previous session
170
230
  // keeps its own server-side cc-sessions.json entry (no DELETE).
@@ -23,7 +23,15 @@
23
23
  body.appendChild(d);
24
24
  }
25
25
 
26
- // One list row for the tile detail modal. opts: { title, meta, chip:{text,cls}, href }.
26
+ // One list row for the tile detail modal.
27
+ // opts: { title, meta, metaNodes, subtle, subtleBeforeMeta, chip:{text,cls}, href }.
28
+ // meta — plain-text meta line.
29
+ // metaNodes — array of Nodes for the meta line, interleaved with
30
+ // ' · ' separators; takes precedence over `meta`.
31
+ // subtle — optional muted second line (e.g. org/project); skipped
32
+ // when empty.
33
+ // subtleBeforeMeta — when true, subtle renders directly under the title
34
+ // (above the meta row); default is below the meta row.
27
35
  // Built as an <a> when href is present so PR rows open the PR in a new tab.
28
36
  function tileCard(opts) {
29
37
  var card = document.createElement(opts.href ? 'a' : 'div');
@@ -43,15 +51,45 @@
43
51
  top.appendChild(chip);
44
52
  }
45
53
  card.appendChild(top);
46
- if (opts.meta) {
47
- var meta = document.createElement('div');
48
- meta.className = 'tile-item-meta';
49
- meta.textContent = opts.meta;
50
- card.appendChild(meta);
54
+ function appendSubtle() {
55
+ if (!opts.subtle) return;
56
+ var subtle = document.createElement('div');
57
+ subtle.className = 'tile-item-subtle';
58
+ subtle.textContent = opts.subtle;
59
+ card.appendChild(subtle);
51
60
  }
61
+ function appendMeta() {
62
+ if (opts.metaNodes && opts.metaNodes.length) {
63
+ var metaRow = document.createElement('div');
64
+ metaRow.className = 'tile-item-meta';
65
+ opts.metaNodes.forEach(function(node, i) {
66
+ if (i > 0) metaRow.appendChild(document.createTextNode(' · '));
67
+ metaRow.appendChild(node);
68
+ });
69
+ card.appendChild(metaRow);
70
+ } else if (opts.meta) {
71
+ var meta = document.createElement('div');
72
+ meta.className = 'tile-item-meta';
73
+ meta.textContent = opts.meta;
74
+ card.appendChild(meta);
75
+ }
76
+ }
77
+ if (opts.subtleBeforeMeta) { appendSubtle(); appendMeta(); }
78
+ else { appendMeta(); appendSubtle(); }
52
79
  return card;
53
80
  }
54
81
 
82
+ // Parse the org/project slug from a canonical PR id, dropping the host prefix
83
+ // and the trailing "#<num>". E.g. "ado:office/iss/constellation#5315603" →
84
+ // "office/iss/constellation", "github:yemi33/minions#2911" → "yemi33/minions".
85
+ function parsePrSlug(id) {
86
+ if (!id || typeof id !== 'string') return '';
87
+ var hash = id.indexOf('#');
88
+ var head = hash >= 0 ? id.slice(0, hash) : id;
89
+ var colon = head.indexOf(':');
90
+ return colon >= 0 ? head.slice(colon + 1) : head;
91
+ }
92
+
55
93
  // Per-tile body renderers for the detail modal. Each reads the latest status
56
94
  // snapshot and fills the modal body; openTileModal looks them up by key.
57
95
  function renderEngineTileBody(body, data) {
@@ -83,17 +121,61 @@
83
121
  }
84
122
 
85
123
  function renderPrTileBody(body, data) {
124
+ // Emoji glyphs make build/review status scannable at a glance; the
125
+ // accompanying label keeps the row readable when the glyph alone is
126
+ // ambiguous (✅ vs ✅, ❌ vs ❌, ⏳ vs 💬). The raw status string also
127
+ // stays in the title attribute for hover discoverability.
128
+ // Keyed by normalized (lowercased) status; missing/unknown → ➖.
129
+ var BUILD_STATUS = {
130
+ success: { glyph: '✅', label: 'passing' },
131
+ succeeded: { glyph: '✅', label: 'passing' },
132
+ passing: { glyph: '✅', label: 'passing' },
133
+ failing: { glyph: '❌', label: 'failing' },
134
+ failed: { glyph: '❌', label: 'failing' },
135
+ error: { glyph: '❌', label: 'failing' },
136
+ pending: { glyph: '⏳', label: 'pending' },
137
+ running: { glyph: '⏳', label: 'running' },
138
+ queued: { glyph: '⏳', label: 'queued' },
139
+ in_progress: { glyph: '⏳', label: 'in progress' },
140
+ };
141
+ var REVIEW_STATUS = {
142
+ approved: { glyph: '✅', label: 'approved' },
143
+ 'changes-requested': { glyph: '❌', label: 'changes requested' },
144
+ changes_requested: { glyph: '❌', label: 'changes requested' },
145
+ 'requested-changes': { glyph: '❌', label: 'changes requested' },
146
+ rejected: { glyph: '❌', label: 'rejected' },
147
+ pending: { glyph: '💬', label: 'pending' },
148
+ commented: { glyph: '💬', label: 'commented' },
149
+ };
150
+ function statusSpan(subsystem, raw, table) {
151
+ var info = table[String(raw || '').toLowerCase()];
152
+ var glyph = info ? info.glyph : '➖';
153
+ var label = info ? info.label : (raw ? String(raw) : 'none');
154
+ var span = document.createElement('span');
155
+ span.className = 'pr-status';
156
+ span.textContent = glyph + ' ' + subsystem + ' ' + label;
157
+ span.title = subsystem + ': ' + (raw || 'unknown');
158
+ return span;
159
+ }
86
160
  var prs = Array.isArray(data.pullRequests) ? data.pullRequests : [];
87
161
  if (!prs.length) { tileEmpty(body, 'No tracked pull requests.'); return; }
88
162
  prs.forEach(function(p) {
89
163
  var num = p.prNumber || p.number || p.id;
90
164
  var build = p.buildStatus || '';
165
+ var review = p.reviewStatus || '';
91
166
  var cls = p.status === 'merged' ? 'green'
92
167
  : (build === 'failing' ? 'red'
93
168
  : ((p.status === 'active' || p.status === 'linked') ? 'blue' : ''));
169
+ var statusText = document.createElement('span');
170
+ statusText.textContent = p.status || 'unknown';
94
171
  body.appendChild(tileCard({
95
172
  title: (num ? '#' + num + ' ' : '') + (p.title || '(untitled PR)'),
96
- meta: [p.status || 'unknown', build ? 'build ' + build : 'no build', p.reviewStatus ? 'review ' + p.reviewStatus : null].filter(Boolean).join(' · '),
173
+ // Project slug first (under title), then build/review row below the
174
+ // project is the strongest disambiguator across PRs and benefits from
175
+ // being adjacent to the title.
176
+ subtle: parsePrSlug(p.id),
177
+ subtleBeforeMeta: true,
178
+ metaNodes: [statusText, statusSpan('build', build, BUILD_STATUS), statusSpan('review', review, REVIEW_STATUS)],
97
179
  chip: { text: p.status || '—', cls: cls },
98
180
  href: p.url || null,
99
181
  }));
@@ -1,8 +1,12 @@
1
1
  // Project context picker: populated from /api/status (which already returns
2
- // the configured project list). Single-project: rendered as a static label
3
- // so it's still visible but not clickable. Multi-project: <select> dropdown.
4
- // Zero-project: a hint to add one. Selection persists per-browser in
5
- // localStorage and is included in every /api/command-center/stream call.
2
+ // the configured project list). One-or-more projects: a <select> dropdown
3
+ // whose first option is a "Select project" sentinel (value ""), italicized
4
+ // via CSS, so the indicator reads "Select project" until the user explicitly
5
+ // picks one and can always be cleared back to it. Zero-project: a hint to
6
+ // add one. No auto-default: we never silently fall back to projects[0] /
7
+ // last-used / constellation, so project-scoped CC actions can't land in the
8
+ // wrong project (W-mqayzsj3). Selection persists per-browser in localStorage
9
+ // and is included in every /api/command-center/stream call.
6
10
  // Build the "+ Add" button as a real DOM node (was an HTML string + innerHTML
7
11
  // injection — SEC-03 ratchet prefers we avoid that pattern, see
8
12
  // test/unit.test.js DYNAMIC_INNERHTML_BASELINE).
@@ -22,19 +26,20 @@
22
26
  empty.textContent = 'No projects linked yet.';
23
27
  return empty;
24
28
  }
25
- // Strip variant: single-project static label (no picker, just the name).
26
- function makeContextStaticNode(name) {
27
- var staticSpan = document.createElement('span');
28
- staticSpan.className = 'context-static';
29
- staticSpan.textContent = name;
30
- return staticSpan;
31
- }
32
- // Strip variant: multi-project <select>. Returns the select so the caller
33
- // can attach a change listener.
29
+ // Strip variant: project <select>. The first option is a "Select project"
30
+ // sentinel (value "") that is selected whenever no project is explicitly
31
+ // chosen, so the indicator never auto-defaults and the user can always clear
32
+ // their choice back to it. Returns the select so the caller can attach a
33
+ // change listener.
34
34
  function makeContextSelect(projects, selected) {
35
35
  var sel = document.createElement('select');
36
36
  sel.id = 'chat-context-select';
37
37
  sel.setAttribute('aria-label', 'Working in project');
38
+ var placeholder = document.createElement('option');
39
+ placeholder.value = '';
40
+ placeholder.textContent = 'Select project';
41
+ if (!selected) placeholder.selected = true;
42
+ sel.appendChild(placeholder);
38
43
  for (var i = 0; i < projects.length; i++) {
39
44
  var p = projects[i];
40
45
  var opt = document.createElement('option');
@@ -69,22 +74,25 @@
69
74
  currentProject = null;
70
75
  try { localStorage.removeItem(SLIM_PROJECT_KEY); } catch (_e) {}
71
76
  }
72
- if (!currentProject) {
73
- currentProject = projects[0];
74
- try { localStorage.setItem(SLIM_PROJECT_KEY, currentProject); } catch (_e) {}
75
- }
76
- if (projects.length === 1) {
77
- stripEl.style.display = '';
78
- controlsEl.replaceChildren(makeContextStaticNode(currentProject), makeContextAddBtn());
79
- } else {
80
- var sel = makeContextSelect(projects, currentProject);
81
- stripEl.style.display = '';
82
- controlsEl.replaceChildren(sel, makeContextAddBtn());
83
- sel.addEventListener('change', function(ev) {
84
- currentProject = ev.target.value || null;
77
+ // No auto-default: when nothing is explicitly selected, currentProject
78
+ // stays null and the picker shows the "Select project" sentinel
79
+ // (italicized via CSS). We deliberately do NOT fall back to projects[0]
80
+ // here (W-mqayzsj3) so CC turns and project-scoped actions don't
81
+ // silently target the wrong project.
82
+ var sel = makeContextSelect(projects, currentProject);
83
+ stripEl.style.display = '';
84
+ controlsEl.replaceChildren(sel, makeContextAddBtn());
85
+ sel.addEventListener('change', function(ev) {
86
+ var val = ev.target.value || '';
87
+ if (val) {
88
+ currentProject = val;
85
89
  try { localStorage.setItem(SLIM_PROJECT_KEY, currentProject); } catch (_e) {}
86
- });
87
- }
90
+ } else {
91
+ // User cleared back to "Select project" — drop the persisted choice.
92
+ currentProject = null;
93
+ try { localStorage.removeItem(SLIM_PROJECT_KEY); } catch (_e) {}
94
+ }
95
+ });
88
96
  }
89
97
  var addBtn = document.getElementById('chat-context-add');
90
98
  if (addBtn) addBtn.addEventListener('click', openAddProjectModal);
@@ -16,6 +16,13 @@
16
16
  }, delay || 0);
17
17
  }
18
18
 
19
+ // Tiles whose numeric value should fire a one-shot cockpit-flash animation
20
+ // whenever it changes (W-mqb02bga: status panel). Suppressed on first paint
21
+ // — only deltas from a previously-seen number trigger the flash, so opening
22
+ // the dashboard doesn't pulse every tile.
23
+ var FLASH_ON_CHANGE_KEYS = { dispatches: true, prs: true };
24
+ var lastTileValues = Object.create(null);
25
+
19
26
  // Apply lit-state and value/detail text to a tile.
20
27
  function updateTile(key, value, detail, lit) {
21
28
  var tile = document.querySelector('.cockpit-tile[data-tile="' + key + '"]');
@@ -27,16 +34,57 @@
27
34
  valEl.classList.toggle('dim', !lit);
28
35
  }
29
36
  if (detEl) detEl.textContent = detail || '';
30
- tile.classList.remove('lit-blue', 'lit-green', 'lit-amber', 'lit-red');
37
+ tile.classList.remove('lit-blue', 'lit-green', 'lit-amber', 'lit-orange', 'lit-red');
31
38
  if (lit) tile.classList.add('lit-' + lit);
39
+ // Flash the tile when its number changes. Remove → force reflow → re-add
40
+ // restarts the animation even when the class was already present from the
41
+ // previous tick (otherwise repeated changes would only fire on the first
42
+ // delta after class application). Only numeric values count as a change;
43
+ // ignore null/undefined transitions.
44
+ if (FLASH_ON_CHANGE_KEYS[key] && typeof value === 'number') {
45
+ var prev = lastTileValues[key];
46
+ if (typeof prev === 'number' && prev !== value) {
47
+ tile.classList.remove('flash-change');
48
+ void tile.offsetWidth;
49
+ tile.classList.add('flash-change');
50
+ }
51
+ lastTileValues[key] = value;
52
+ }
32
53
  }
33
54
 
34
55
  // Latest /api/status snapshot — read by the cockpit-tile detail modals.
35
56
  var lastStatusData = null;
36
57
 
58
+ // Reflect the Command Center's resolved runtime + model in the Actions panel
59
+ // header (slim-ux only — classic chrome is untouched). Source is the same
60
+ // /api/status payload the cockpit polls: autoMode.ccModel (config.engine
61
+ // ccModel → defaultModel → runtime default) and autoMode.ccCli. When no model
62
+ // is resolvable (runtime picks its own default), the badge stays hidden rather
63
+ // than guessing. textContent keeps it XSS-safe — the values come from config.
64
+ function updateCcModelBadge(model, cli) {
65
+ var badge = document.getElementById('cc-model-badge');
66
+ if (!badge) return;
67
+ var m = (model === null || model === undefined) ? '' : String(model).trim();
68
+ if (!m) {
69
+ badge.hidden = true;
70
+ badge.textContent = '';
71
+ badge.removeAttribute('title');
72
+ return;
73
+ }
74
+ var c = (cli === null || cli === undefined) ? '' : String(cli).trim();
75
+ var label = c ? (c + ' · ' + m) : m;
76
+ badge.textContent = label;
77
+ badge.title = 'Command Center model: ' + label;
78
+ badge.hidden = false;
79
+ }
80
+
37
81
  function applyStatus(data) {
38
82
  if (!data) return;
39
83
  lastStatusData = data;
84
+ // ── Command Center model badge (Actions panel header) ──────
85
+ var autoMode = data.autoMode || {};
86
+ updateCcModelBadge(autoMode.ccModel, autoMode.ccCli);
87
+
40
88
  // ── Engine tile ────────────────────────────────────────────
41
89
  var engine = data.engine || {};
42
90
  // /api/status exposes `state` ('running'|'paused'|'stopping'|'stopped')
@@ -65,14 +113,14 @@
65
113
  updateTile(
66
114
  'dispatches',
67
115
  active.length,
68
- active.length ? 'in flight' : 'none in flight',
69
- active.length ? 'blue' : null
116
+ active.length ? 'agents working' : 'no agents working',
117
+ active.length ? 'amber' : null
70
118
  );
71
119
  updateTile(
72
120
  'queued',
73
121
  pending.length,
74
122
  pending.length ? 'waiting to spawn' : 'queue empty',
75
- pending.length ? 'amber' : null
123
+ pending.length ? 'orange' : null
76
124
  );
77
125
 
78
126
  // ── PR tile (status snapshot from cached pull-requests) ─────
@@ -20,9 +20,15 @@
20
20
  --blue: #58a6ff;
21
21
  --green: #3fb950;
22
22
  --amber: #d29922;
23
+ --orange: #ea580c;
23
24
  --red: #f85149;
24
25
  --radius: 6px;
25
26
 
27
+ /* Spacing scale (W-mqazlkym) — 4/8/12/16/20 rhythm for the chat
28
+ surface. Slim ships its CSS independently, so the scale is declared
29
+ here rather than reusing dashboard/styles.css's --space-* tokens. */
30
+ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 20px;
31
+
26
32
  /* Typography — mirrors dashboard/styles.css :root tokens
27
33
  (W-mq1c8og40003a7cf). Slim ships its CSS independently
28
34
  (template-baked into slim/layout.html), so we duplicate the
@@ -221,6 +227,34 @@
221
227
  color: var(--muted);
222
228
  font-size: var(--text-base);
223
229
  }
230
+ /* Right-aligned cluster in the Actions panel header: keeps the CC model
231
+ badge tucked next to the "Command Center" sub-label without disturbing
232
+ the header's space-between layout. */
233
+ .panel-header .panel-head-meta {
234
+ display: flex;
235
+ align-items: center;
236
+ gap: 8px;
237
+ min-width: 0;
238
+ }
239
+ /* Compact pill surfacing the Command Center's resolved runtime/model
240
+ (e.g. "copilot · gpt-5.4"). Muted, monospace, low-key — visible but
241
+ unobtrusive. Populated by status.js#updateCcModelBadge. */
242
+ .cc-model-badge {
243
+ text-transform: none;
244
+ letter-spacing: 0;
245
+ font-weight: 500;
246
+ font-size: var(--text-sm);
247
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
248
+ color: var(--muted);
249
+ background: var(--surface2);
250
+ border: 1px solid var(--border);
251
+ border-radius: 999px;
252
+ padding: 1px 8px;
253
+ white-space: nowrap;
254
+ overflow: hidden;
255
+ text-overflow: ellipsis;
256
+ max-width: 220px;
257
+ }
224
258
  .panel-body {
225
259
  flex: 1;
226
260
  overflow-y: auto;
@@ -252,9 +286,9 @@
252
286
  .chat-tabs {
253
287
  display: flex;
254
288
  align-items: center;
255
- gap: 6px;
289
+ gap: var(--space-2);
256
290
  flex-wrap: wrap;
257
- padding: 8px 12px 0;
291
+ padding: var(--space-2) var(--space-3) 0;
258
292
  width: 100%;
259
293
  max-width: 800px;
260
294
  margin-left: auto;
@@ -310,10 +344,13 @@
310
344
  .chat-messages {
311
345
  flex: 1;
312
346
  overflow-y: auto;
313
- padding: 12px;
347
+ /* Extra top padding so the first message clears the tab/context chip
348
+ row above it (#10); 12px sides keep the left grid line shared with the
349
+ input row and action toolbar (#3). */
350
+ padding: var(--space-4) var(--space-3) var(--space-3);
314
351
  display: flex;
315
352
  flex-direction: column;
316
- gap: 10px;
353
+ gap: var(--space-3);
317
354
  min-height: 0;
318
355
  width: 100%;
319
356
  max-width: 800px;
@@ -329,7 +366,9 @@
329
366
  }
330
367
  .chat-msg {
331
368
  max-width: 90%;
332
- padding: 10px 14px;
369
+ /* Uniform bubble padding across variants (#2); 20px horizontal gives
370
+ wrapped technical text >=16px right-edge clearance (#8). */
371
+ padding: var(--space-4) var(--space-5);
333
372
  border-radius: var(--radius);
334
373
  white-space: pre-wrap;
335
374
  word-wrap: break-word;
@@ -360,10 +399,65 @@
360
399
  border: 1px solid var(--red);
361
400
  font-size: var(--text-lg);
362
401
  }
363
- .chat-input-wrap {
402
+ /* Queued messages (W-mqayw3x6): follow-ups submitted while a turn is still
403
+ streaming. The container is pinned to the bottom of the transcript; each
404
+ item is a greyed-out user bubble that fires FIFO on turn completion. */
405
+ .chat-queue {
364
406
  display: flex;
407
+ flex-direction: column;
365
408
  gap: 8px;
366
- padding: 10px 12px;
409
+ align-self: stretch;
410
+ }
411
+ .chat-msg.user.queued {
412
+ align-self: flex-end;
413
+ background: var(--surface2);
414
+ color: var(--muted);
415
+ border: 1px dashed var(--border);
416
+ opacity: 0.8;
417
+ display: flex;
418
+ align-items: center;
419
+ gap: 8px;
420
+ }
421
+ .chat-queue-text { white-space: pre-wrap; word-wrap: break-word; }
422
+ .chat-queue-badge {
423
+ font-size: var(--text-base);
424
+ text-transform: uppercase;
425
+ letter-spacing: 0.5px;
426
+ color: var(--muted);
427
+ border: 1px solid var(--border);
428
+ border-radius: var(--radius);
429
+ padding: 1px 6px;
430
+ flex-shrink: 0;
431
+ }
432
+ .chat-queue-dismiss {
433
+ background: none;
434
+ border: none;
435
+ color: var(--muted);
436
+ cursor: pointer;
437
+ font-size: var(--text-lg);
438
+ line-height: 1;
439
+ padding: 0 2px;
440
+ flex-shrink: 0;
441
+ }
442
+ .chat-queue-dismiss:hover { color: var(--red); }
443
+ .chat-queue-resume {
444
+ align-self: center;
445
+ background: var(--blue);
446
+ color: #fff;
447
+ border: none;
448
+ border-radius: var(--radius);
449
+ padding: 6px 14px;
450
+ font-size: var(--text-md);
451
+ font-weight: 600;
452
+ cursor: pointer;
453
+ }
454
+ .chat-queue-resume:hover { filter: brightness(1.1); }
455
+ .chat-input-wrap {
456
+ display: flex;
457
+ gap: var(--space-3);
458
+ /* Breathing room around the textarea; the larger gap separates it from
459
+ the Send/Stop buttons (#5). */
460
+ padding: var(--space-3);
367
461
  border-top: 1px solid var(--border);
368
462
  flex-shrink: 0;
369
463
  background: var(--surface);
@@ -380,8 +474,8 @@
380
474
  .chat-context-strip {
381
475
  display: flex;
382
476
  align-items: center;
383
- gap: 10px;
384
- padding: 6px 12px;
477
+ gap: var(--space-3);
478
+ padding: var(--space-2) var(--space-3);
385
479
  border-top: 1px solid var(--border);
386
480
  flex-shrink: 0;
387
481
  background: var(--surface);
@@ -409,6 +503,16 @@
409
503
  cursor: pointer;
410
504
  }
411
505
  .chat-context-strip select:focus { outline: none; border-color: var(--blue); }
506
+ /* Italicize the "Select project" placeholder option AND the closed select
507
+ widget while the placeholder is the active value. `:has()` is needed
508
+ because native <select> rendering ignores child <option> styles for the
509
+ displayed (closed) text — we have to style the <select> itself. The
510
+ explicit `option { font-style: normal }` reset stops real project names
511
+ from inheriting the parent select's italic when the dropdown is opened
512
+ while the placeholder is still selected. */
513
+ .chat-context-strip select option { font-style: normal; color: var(--text); }
514
+ .chat-context-strip select option[value=""] { font-style: italic; color: var(--muted); }
515
+ .chat-context-strip select:has(option[value=""]:checked) { font-style: italic; color: var(--muted); }
412
516
  .chat-context-strip .context-static {
413
517
  color: var(--text);
414
518
  font-weight: 500;
@@ -461,17 +565,19 @@
461
565
  .chat-send:hover { filter: brightness(1.1); }
462
566
  .chat-send:disabled { opacity: 0.5; cursor: not-allowed; }
463
567
  .chat-stop {
464
- padding: 10px 14px;
568
+ padding: 9px;
465
569
  background: var(--surface2);
466
570
  color: var(--red);
467
571
  border: 1px solid var(--border);
468
572
  border-radius: var(--radius);
469
573
  font-size: var(--text-md);
574
+ line-height: 0;
470
575
  cursor: pointer;
471
576
  align-self: flex-end;
472
577
  display: none;
473
578
  }
474
579
  .chat-stop:hover { filter: brightness(1.1); }
580
+ .chat-stop .chat-stop-icon { display: block; width: 20px; height: 20px; }
475
581
 
476
582
  /* Tool-call progress panel + thinking indicator. While streaming, the
477
583
  thinking indicator sits at the bottom-left of the response card and the
@@ -481,7 +587,7 @@
481
587
  display: flex;
482
588
  align-items: flex-start;
483
589
  gap: 12px;
484
- margin-top: 14px;
590
+ margin-top: var(--space-3);
485
591
  }
486
592
  /* Collapsed tool panel: at most 3 non-wrapping lines, scrolls (newest at
487
593
  the bottom) once there are more. Clicking opens the full list in a modal. */
@@ -561,6 +667,8 @@
561
667
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
562
668
  }
563
669
  .tile-item-meta { font-size: var(--text-base); color: var(--muted); margin-top: 3px; word-break: break-word; }
670
+ .tile-item-subtle { font-size: var(--text-sm); color: var(--muted); margin-top: 2px; word-break: break-word; }
671
+ .pr-status { display: inline-block; margin: 0 1px; text-decoration: none; }
564
672
  .tile-chip {
565
673
  flex: 0 0 auto;
566
674
  font-size: var(--text-base); font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase;
@@ -623,12 +731,26 @@
623
731
 
624
732
  .chat-action {
625
733
  align-self: flex-start;
626
- padding: 5px 11px;
734
+ padding: var(--space-1) var(--space-3);
627
735
  border-radius: 4px;
628
736
  font-size: var(--text-base);
629
737
  border: 1px dashed var(--border);
630
738
  color: var(--muted);
631
739
  }
740
+ /* A status line that immediately follows a message reads as that card's
741
+ output (#9): tighten the gap (negative margin trims the 12px column gap
742
+ to ~4px), drop the free-floating dashed box, and tie it to the card
743
+ above with a shared left edge + left-border continuation. */
744
+ .chat-msg + .chat-action {
745
+ margin-top: calc(-1 * var(--space-2));
746
+ border: none;
747
+ border-left: 2px solid var(--border);
748
+ border-radius: 0 var(--radius) var(--radius) 0;
749
+ padding-left: var(--space-3);
750
+ }
751
+ .chat-msg + .chat-action.ok { border-left-color: var(--green); }
752
+ .chat-msg + .chat-action.warn { border-left-color: var(--amber); }
753
+ .chat-msg + .chat-action.err { border-left-color: var(--red); }
632
754
  .chat-action.ok { color: var(--green); }
633
755
  .chat-action.warn { color: var(--amber); }
634
756
  .chat-action.err { color: var(--red); }
@@ -640,9 +762,10 @@
640
762
  }
641
763
  .chat-msg.assistant code {
642
764
  background: var(--bg);
643
- padding: 1px 4px;
765
+ padding: 2px 6px;
644
766
  border-radius: 3px;
645
767
  font-size: var(--text-code);
768
+ vertical-align: baseline;
646
769
  }
647
770
  .chat-msg.assistant pre {
648
771
  background: var(--bg);
@@ -656,13 +779,16 @@
656
779
  .chat-msg.assistant a { color: var(--blue); }
657
780
  .chat-msg.assistant strong { color: var(--text); }
658
781
 
659
- /* ── Action footer: square action buttons (one for now: Link PR) ── */
782
+ /* ── Action footer: Link PR + Pin Content ──
783
+ De-emphasized (#6, option b): ghost/secondary styling so they no longer
784
+ compete with the primary blue Send button. The 4-column grid keeps each
785
+ button at a quarter of the panel width (low visual weight). */
660
786
  .actions-buttons {
661
787
  flex-shrink: 0;
662
788
  display: grid;
663
789
  grid-template-columns: repeat(4, 1fr);
664
- gap: 8px;
665
- padding: 10px 12px 12px;
790
+ gap: var(--space-2);
791
+ padding: var(--space-3);
666
792
  border-top: 1px solid var(--border);
667
793
  width: 100%;
668
794
  max-width: 800px;
@@ -670,11 +796,11 @@
670
796
  margin-right: auto;
671
797
  }
672
798
  .link-pr-btn {
673
- background: var(--surface2);
674
- color: var(--text);
799
+ background: transparent;
800
+ color: var(--muted);
675
801
  border: 1px solid var(--border);
676
802
  border-radius: var(--radius);
677
- padding: 10px 12px;
803
+ padding: var(--space-2) var(--space-3);
678
804
  cursor: pointer;
679
805
  font-size: var(--text-md);
680
806
  font-family: inherit;
@@ -740,9 +866,16 @@
740
866
  table inside the IIFE. */
741
867
  .cockpit-grid {
742
868
  display: grid;
743
- grid-template-columns: repeat(2, 1fr);
869
+ grid-template-columns: repeat(6, 1fr);
744
870
  gap: 8px;
745
871
  }
872
+ /* Row 2: Queued | Dispatches | PRs — three equal columns (2 of 6 each). */
873
+ .cockpit-grid > [data-tile="queued"],
874
+ .cockpit-grid > [data-tile="dispatches"],
875
+ .cockpit-grid > [data-tile="prs"] { grid-column: span 2; }
876
+ /* Row 3: Watches | Pinned — two equal columns (3 of 6 each). */
877
+ .cockpit-grid > [data-tile="watches"],
878
+ .cockpit-grid > [data-tile="pinned"] { grid-column: span 3; }
746
879
  .cockpit-tile {
747
880
  background: var(--surface2);
748
881
  border: 1px solid var(--border);
@@ -752,22 +885,37 @@
752
885
  flex-direction: column;
753
886
  gap: 4px;
754
887
  transition: border-color 0.2s, background 0.2s;
888
+ /* Per-state flash color (rgb triplet, no rgba wrapper) consumed by
889
+ `cockpit-flash` so the change-flash ring matches the tile's lit state
890
+ (amber for working dispatches, orange for queued, red for failing
891
+ PRs, etc.). Each lit-* class below overrides this. Default is blue
892
+ for unlit / generic tiles. */
893
+ --flash-color: 88, 166, 255;
755
894
  }
756
895
  .cockpit-tile.lit-blue {
757
896
  border-color: rgba(88, 166, 255, 0.6);
758
897
  background: rgba(88, 166, 255, 0.06);
898
+ --flash-color: 88, 166, 255;
759
899
  }
760
900
  .cockpit-tile.lit-green {
761
901
  border-color: rgba(63, 185, 80, 0.6);
762
902
  background: rgba(63, 185, 80, 0.06);
903
+ --flash-color: 63, 185, 80;
763
904
  }
764
905
  .cockpit-tile.lit-amber {
765
906
  border-color: rgba(210, 153, 34, 0.6);
766
907
  background: rgba(210, 153, 34, 0.06);
908
+ --flash-color: 210, 153, 34;
909
+ }
910
+ .cockpit-tile.lit-orange {
911
+ border-color: rgba(234, 88, 12, 0.6);
912
+ background: rgba(234, 88, 12, 0.08);
913
+ --flash-color: 234, 88, 12;
767
914
  }
768
915
  .cockpit-tile.lit-red {
769
916
  border-color: rgba(248, 81, 73, 0.6);
770
917
  background: rgba(248, 81, 73, 0.08);
918
+ --flash-color: 248, 81, 73;
771
919
  }
772
920
  .cockpit-label {
773
921
  font-size: var(--text-base);
@@ -789,6 +937,7 @@
789
937
  .lit-blue .cockpit-dot { background: var(--blue); box-shadow: 0 0 6px var(--blue); }
790
938
  .lit-green .cockpit-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
791
939
  .lit-amber .cockpit-dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
940
+ .lit-orange .cockpit-dot { background: var(--orange); box-shadow: 0 0 6px var(--orange); }
792
941
  .lit-red .cockpit-dot { background: var(--red); box-shadow: 0 0 6px var(--red); }
793
942
  .cockpit-value {
794
943
  font-size: var(--text-stat);
@@ -803,10 +952,59 @@
803
952
  line-height: 1.3;
804
953
  min-height: 14px;
805
954
  }
955
+ /* Engine tile: full-width, compact single-line horizontal strip. */
956
+ .cockpit-tile--full {
957
+ grid-column: 1 / -1;
958
+ flex-direction: row;
959
+ align-items: center;
960
+ gap: 16px;
961
+ padding: 7px 12px;
962
+ }
963
+ .cockpit-tile--full .cockpit-value { font-size: var(--text-xl); }
964
+ .cockpit-tile--full .cockpit-detail {
965
+ min-height: 0;
966
+ margin-left: auto;
967
+ text-align: right;
968
+ }
806
969
  /* Cockpit tiles are clickable -> detail modal. */
807
970
  .cockpit-tile { cursor: pointer; position: relative; }
808
971
  .cockpit-tile:hover { border-color: var(--blue); }
809
972
  .cockpit-tile:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
973
+ /* Active Dispatches tile pulses while there is work in flight — same
974
+ cadence as the per-member "working" badge in the Team section, so the
975
+ eye instantly ties "things are happening" in both regions. The pulse
976
+ only runs in the lit-amber state (dispatches > 0); empty state is
977
+ static. Respects prefers-reduced-motion. */
978
+ .cockpit-tile[data-tile="dispatches"].lit-amber {
979
+ animation: slim-pulse 1.6s ease-in-out infinite;
980
+ }
981
+ /* Change-flash: brief one-shot ring around a tile whose value just
982
+ changed, used by status.js for the `dispatches` and `prs` tiles. Layered
983
+ on a pseudo-element so it composes cleanly with `slim-pulse` above
984
+ (running both on the tile itself would force a single `animation:` rule
985
+ and clobber one of them). The ring expands then fades — fully visible
986
+ even when the underlying tile is already lit. Respects
987
+ prefers-reduced-motion. */
988
+ .cockpit-tile::after {
989
+ content: '';
990
+ position: absolute;
991
+ inset: -1px;
992
+ border-radius: inherit;
993
+ pointer-events: none;
994
+ box-shadow: 0 0 0 0 rgba(var(--flash-color), 0);
995
+ }
996
+ .cockpit-tile.flash-change::after {
997
+ animation: cockpit-flash 0.75s ease-out;
998
+ }
999
+ @keyframes cockpit-flash {
1000
+ 0% { box-shadow: 0 0 0 0 rgba(var(--flash-color), 0); }
1001
+ 30% { box-shadow: 0 0 0 6px rgba(var(--flash-color), 0.7); }
1002
+ 100% { box-shadow: 0 0 0 0 rgba(var(--flash-color), 0); }
1003
+ }
1004
+ @media (prefers-reduced-motion: reduce) {
1005
+ .cockpit-tile[data-tile="dispatches"].lit-amber { animation: none; }
1006
+ .cockpit-tile.flash-change::after { animation: none; }
1007
+ }
810
1008
 
811
1009
  /* ── Status panel sub-divisions: Team cards over System tiles,
812
1010
  separated by a divider line (no text headers). ──── */
package/dashboard.js CHANGED
@@ -8865,13 +8865,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8865
8865
  // `currentProject` hint so CC can default action.project without the
8866
8866
  // user naming it every turn. Validate against the configured project
8867
8867
  // list before injecting — body.currentProject is untrusted input.
8868
+ // When slim has no project explicitly selected (the "Select project"
8869
+ // indicator), it instead sends `noProjectSelected: true` so we inject an
8870
+ // explicit "do not default — ask the user" preamble rather than silently
8871
+ // letting CC pick projects[0] (W-mqayzsj3). The classic dashboard sends
8872
+ // neither field, so its prompt stays byte-identical (empty part).
8868
8873
  const _knownProjectNames = (PROJECTS || []).map(p => p && p.name).filter(Boolean);
8869
8874
  const _currentProject = (body.currentProject && _knownProjectNames.includes(body.currentProject))
8870
8875
  ? body.currentProject
8871
8876
  : null;
8872
8877
  const projectContextPart = _currentProject
8873
8878
  ? `[Project Context] The user is currently working in project "${_currentProject}". Default any action's "project" field to this project unless the user explicitly names a different one.`
8874
- : '';
8879
+ : (body.noProjectSelected
8880
+ ? `[Project Context] No project is selected. Do NOT default any action's "project" field — ask the user to pick a project before creating or dispatching project-scoped work.`
8881
+ : '');
8875
8882
  const prompt = _joinCcPromptParts(preamble, resumeGuard, carryover, turnHeader, projectContextPart, body.message);
8876
8883
 
8877
8884
  const { trackEngineUsage: trackUsage, trackEngineError: trackErr, withCcTurnTimeout: withTimeout } = require('./engine/llm');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2175",
3
+ "version": "0.1.2177",
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"