NeonClary Cursor commited on
Commit
a808caa
·
1 Parent(s): c430435

feat(ui): consolidate downloads, move theme into settings, restructure & darken settings menu, fix max-participants prop wiring

Browse files

Header

* Replace the standalone Sun/Moon button + the four-button download strip with a single Download dropdown (DownloadMenu.js). Members: View summary table, Chat .txt, Chat .md, Summary table .csv, and Full API history (the only download that lives only here).

* Header now passes every prop to DevMenu explicitly. The previous '...devProps' rest spread silently dropped 'maxParticipants' because Header was destructuring it for ParticipantDropdown's use, leaving DevMenu's stepper to compute Math.max(3, undefined - 1) = NaN on every click. With explicit wiring the stepper receives a real number; the +/- buttons update state again.

Settings menu (DevMenu.js)

* New top item: Theme toggle (Sun/Moon flips Switch to dark/light mode label).

* Every category's items are now indented to match the old Display Options pattern, so the small uppercase category labels read as headers.

* Models / Per-participant rows render two-line: name on top, currently-selected model on a second line, ellipsis-truncated when it overflows. Same treatment for Orchestrator and Summarizer.

* Transparency rows (View Credential Summary, View current chat prompts) lose their right-side > chevron - they trigger modals, not sub-panels, so the chevron was misleading and was wrapping noisily.

* Full API history removed from this menu (lives in the new header DownloadMenu). The other three download items (chat .txt / .md / summary .csv) intentionally remain here too.

Contrast

* .dev-panel button text: --text-secondary -> --text-primary (#4B5563 -> #111827).

* .dev-panel-label and .dev-panel-hint: --text-muted -> --text-secondary.

* Disabled-button opacity raised 0.4 -> 0.55 since the base text is darker now.

* Stepper row indented (padding-left 22px) to align with .dev-panel-row.

Debug instrumentation

* DevMenu.js (panel-render snapshot + +/- click) and App.js handleMaxParticipantsChange include temporary fetch() calls posting to the local debug server so the upcoming reproduction also serves as the verification run for the prop-wiring fix. A follow-up commit will remove these once the logs confirm the bug is resolved.

Co-authored-by: Cursor <cursoragent@cursor.com>

frontend/src/App.js CHANGED
@@ -191,11 +191,34 @@ export default function App() {
191
  }, []);
192
  const handleMaxParticipantsChange = useCallback((n) => {
193
  const clamped = Math.max(3, Math.min(9, n));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  setMaxParticipants(clamped);
195
  if (selectedIds.length > clamped) {
196
  setSelectedIds(prev => prev.slice(0, clamped));
197
  }
198
- }, [selectedIds]);
199
  const handleModelAssignmentChange = useCallback((participantId, modelId) => {
200
  setModelAssignments(prev => {
201
  const next = { ...prev };
 
191
  }, []);
192
  const handleMaxParticipantsChange = useCallback((n) => {
193
  const clamped = Math.max(3, Math.min(9, n));
194
+ // #region agent log
195
+ fetch('http://127.0.0.1:7660/ingest/b27d4bb5-c1ab-4767-aa98-1cdf1e8fb0ae', {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '896623' },
198
+ body: JSON.stringify({
199
+ sessionId: '896623',
200
+ runId: 'maxparticipants-repro',
201
+ hypothesisId: 'H2',
202
+ location: 'App.js:handleMaxParticipantsChange',
203
+ message: 'Parent handler invoked',
204
+ data: {
205
+ n,
206
+ nType: typeof n,
207
+ nIsNaN: Number.isNaN(n),
208
+ clamped,
209
+ clampedIsNaN: Number.isNaN(clamped),
210
+ prevMaxParticipants: maxParticipants,
211
+ prevMaxParticipantsType: typeof maxParticipants,
212
+ },
213
+ timestamp: Date.now(),
214
+ }),
215
+ }).catch(() => {});
216
+ // #endregion
217
  setMaxParticipants(clamped);
218
  if (selectedIds.length > clamped) {
219
  setSelectedIds(prev => prev.slice(0, clamped));
220
  }
221
+ }, [selectedIds, maxParticipants]);
222
  const handleModelAssignmentChange = useCallback((participantId, modelId) => {
223
  setModelAssignments(prev => {
224
  const next = { ...prev };
frontend/src/components/DevMenu.js CHANGED
@@ -1,21 +1,39 @@
1
  import React, { useState, useMemo, useRef, useEffect } from 'react';
2
  import {
3
- ChevronRight, Download, Settings2, Search,
4
- Square, CheckSquare, UserPlus, Table2, ScrollText, SlidersHorizontal,
5
  BookOpen,
6
  } from 'lucide-react';
7
 
8
  /**
9
- * Settings menu, structurally identical to LLMChats3 but populated with
10
- * CCAI controls:
11
- * - Orchestrator model (searchable)
12
- * - Summarizer model (searchable, with "Same as Orchestrator" default)
13
- * - Max participants (3-9, default 5)
14
- * - Per-participant model assignments
15
- * - "Create Expert Persona..." shortcut
16
- * - Display options + downloads (txt / md / csv-table / api-log)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  */
18
  export default function DevMenu({
 
 
19
  allModels,
20
  orchestratorModel,
21
  onOrchestratorChange,
@@ -31,7 +49,6 @@ export default function DevMenu({
31
  modelAssignments,
32
  onModelAssignmentChange,
33
  onOpenExpertModal,
34
- onShowTableView,
35
  onShowCredentials,
36
  hasCredentials,
37
  onShowPromptCatalog,
@@ -40,8 +57,6 @@ export default function DevMenu({
40
  onDownloadChatTxt,
41
  onDownloadChatMd,
42
  onDownloadCsvTable,
43
- onDownloadApiLog,
44
- hasApiLog,
45
  hasChat,
46
  }) {
47
  const [open, setOpen] = useState(false);
@@ -75,6 +90,31 @@ export default function DevMenu({
75
  });
76
  }, [allModels, q]);
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  const nameForModel = (id) => {
79
  if (!id) return null;
80
  const m = allModels.find(x => x.id === id);
@@ -93,31 +133,6 @@ export default function DevMenu({
93
 
94
  return (
95
  <div className="dev-wrap" ref={wrapRef}>
96
- <div className="dev-download-btns">
97
- <button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatTxt}>
98
- <Download size={14} /> .txt
99
- </button>
100
- <button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatMd}>
101
- <Download size={14} /> .md
102
- </button>
103
- <button
104
- className="btn-sm btn-outline"
105
- disabled={!hasChat}
106
- onClick={onShowTableView}
107
- title="Open the conversation summary table"
108
- >
109
- <Table2 size={14} /> Table
110
- </button>
111
- <button
112
- className="btn-sm btn-outline"
113
- disabled={!hasChat}
114
- onClick={onDownloadCsvTable}
115
- title="Download the table view as CSV"
116
- >
117
- <Download size={14} /> .csv
118
- </button>
119
- </div>
120
-
121
  <div className="dev-dropdown-header">
122
  <button
123
  className="icon-btn"
@@ -128,41 +143,125 @@ export default function DevMenu({
128
  </button>
129
  {open && (
130
  <div className="dev-panel">
131
- <button onClick={() => { setActiveSub(s => s === 'orch' ? null : 'orch'); setQ(''); }}>
132
- Orchestrator model… <span className="dev-panel-hint">{orchName}</span>
133
- <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  </button>
135
- <button onClick={() => { setActiveSub(s => s === 'sum' ? null : 'sum'); setQ(''); }}>
136
- Summarizer model… <span className="dev-panel-hint">{sumName}</span>
137
- <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
 
 
 
 
 
 
138
  </button>
139
 
140
  <div className="dev-panel-divider" />
 
 
141
  <div className="dev-panel-label">Max participants ({maxParticipants})</div>
142
  <div className="ccai-stepper-row">
143
  <button
144
  className="btn-sm btn-outline ccai-stepper-btn"
145
  disabled={maxParticipants <= 3}
146
- onClick={() => onMaxParticipantsChange(Math.max(3, maxParticipants - 1))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  >−</button>
148
  <div className="ccai-stepper-val">{maxParticipants}</div>
149
  <button
150
  className="btn-sm btn-outline ccai-stepper-btn"
151
  disabled={maxParticipants >= 9}
152
- onClick={() => onMaxParticipantsChange(Math.min(9, maxParticipants + 1))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  >+</button>
154
  <span className="dev-panel-hint">3-9</span>
155
  </div>
156
 
157
  <div className="dev-panel-divider" />
 
 
158
  <div className="dev-panel-label">Participants</div>
159
- <button onClick={() => { onOpenExpertModal(null); setOpen(false); }}>
 
 
 
160
  <UserPlus size={14} className="dev-check-icon" />
161
  Create Expert Persona…
162
  </button>
163
- {(participants || []).length > 0 && (
164
- <div className="dev-panel-label">Per-participant model</div>
165
- )}
166
  {(participants || []).map(p => {
167
  const assigned = modelAssignments[p.participant_id];
168
  const labelName = assigned ? nameForModel(assigned)
@@ -170,15 +269,21 @@ export default function DevMenu({
170
  return (
171
  <button
172
  key={p.participant_id}
 
173
  onClick={() => { setActiveSub(s => s === p.participant_id ? null : p.participant_id); setQ(''); }}
174
  >
175
- {p.name}<span className="dev-panel-hint"> {labelName}</span>
176
- <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
 
 
 
177
  </button>
178
  );
179
  })}
180
 
181
  <div className="dev-panel-divider" />
 
 
182
  <div className="dev-panel-label">Display options</div>
183
  <button
184
  className={`dev-panel-choice ${showResponseTime ? 'dev-panel-choice-active' : ''}`}
@@ -196,8 +301,15 @@ export default function DevMenu({
196
  </button>
197
 
198
  <div className="dev-panel-divider" />
 
 
 
 
 
 
199
  <div className="dev-panel-label">Transparency</div>
200
  <button
 
201
  disabled={!hasCredentials}
202
  onClick={() => { onShowCredentials?.(); setOpen(false); }}
203
  title={
@@ -208,20 +320,22 @@ export default function DevMenu({
208
  >
209
  <ScrollText size={14} className="dev-check-icon" />
210
  View Credential Summary…
211
- <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
212
  </button>
213
  <button
 
214
  onClick={() => { onShowPromptCatalog?.(); setOpen(false); }}
215
  title="View every prompt template the orchestrator and participants use, grouped by phase."
216
  >
217
  <BookOpen size={14} className="dev-check-icon" />
218
  View current chat prompts…
219
- <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
220
  </button>
221
 
222
  <div className="dev-panel-divider" />
 
 
223
  <div className="dev-panel-label">Advanced</div>
224
  <button
 
225
  onClick={() => { onShowConversationLimits?.(); setOpen(false); }}
226
  title="View and adjust the per-phase repetition limits and failsafe pause points."
227
  >
@@ -239,21 +353,40 @@ export default function DevMenu({
239
  (custom)
240
  </span>
241
  )}
242
- <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
243
  </button>
244
 
245
  <div className="dev-panel-divider" />
246
- <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatTxt(); setOpen(false); }}>
247
- Download chat as .txt
248
- </button>
249
- <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatMd(); setOpen(false); }}>
250
- Download chat as .md
 
 
 
 
 
 
 
 
 
251
  </button>
252
- <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadCsvTable(); setOpen(false); }}>
253
- Download summary table as .csv
 
 
 
 
 
254
  </button>
255
- <button disabled={!hasApiLog} onClick={() => { onDownloadApiLog(); setOpen(false); }}>
256
- Download full API history
 
 
 
 
 
257
  </button>
258
  </div>
259
  )}
 
1
  import React, { useState, useMemo, useRef, useEffect } from 'react';
2
  import {
3
+ ChevronRight, Download, Settings2, Search, Sun, Moon,
4
+ Square, CheckSquare, UserPlus, ScrollText, SlidersHorizontal,
5
  BookOpen,
6
  } from 'lucide-react';
7
 
8
  /**
9
+ * Settings menu (gear-icon dropdown in the header).
10
+ *
11
+ * Layout, top-to-bottom, all category items indented under the small
12
+ * uppercase label that introduces them:
13
+ * - Theme (Sun / Moon toggle)
14
+ * - Models (Orchestrator / Summarizer — stacked rows
15
+ * with the currently-chosen model name on a
16
+ * second line, ellipsis-truncated)
17
+ * - Max participants (- / value / + stepper, 3-9)
18
+ * - Participants (Create Expert Persona…, then per-participant
19
+ * rows in the same stacked-row style)
20
+ * - Display options (toggles)
21
+ * - Transparency (Credential Summary, Prompt Catalog — no
22
+ * right-side chevrons; just the labelled
23
+ * buttons)
24
+ * - Advanced (Conversation limits…)
25
+ * - Downloads (chat .txt / .md / summary .csv — same items
26
+ * are also reachable from the header
27
+ * DownloadMenu; the "Full API history" item
28
+ * is *only* in DownloadMenu)
29
+ *
30
+ * The header download strip that used to live in this component has
31
+ * moved into a sibling DownloadMenu component, so this file no longer
32
+ * renders any header-bar buttons; just the gear and its panel.
33
  */
34
  export default function DevMenu({
35
+ theme,
36
+ onToggleTheme,
37
  allModels,
38
  orchestratorModel,
39
  onOrchestratorChange,
 
49
  modelAssignments,
50
  onModelAssignmentChange,
51
  onOpenExpertModal,
 
52
  onShowCredentials,
53
  hasCredentials,
54
  onShowPromptCatalog,
 
57
  onDownloadChatTxt,
58
  onDownloadChatMd,
59
  onDownloadCsvTable,
 
 
60
  hasChat,
61
  }) {
62
  const [open, setOpen] = useState(false);
 
90
  });
91
  }, [allModels, q]);
92
 
93
+ // #region agent log
94
+ useEffect(() => {
95
+ if (!open) return;
96
+ fetch('http://127.0.0.1:7660/ingest/b27d4bb5-c1ab-4767-aa98-1cdf1e8fb0ae', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '896623' },
99
+ body: JSON.stringify({
100
+ sessionId: '896623',
101
+ runId: 'maxparticipants-repro',
102
+ hypothesisId: 'H1',
103
+ location: 'DevMenu.js:render',
104
+ message: 'DevMenu render snapshot (panel open)',
105
+ data: {
106
+ maxParticipants,
107
+ maxParticipantsType: typeof maxParticipants,
108
+ onMaxParticipantsChangeType: typeof onMaxParticipantsChange,
109
+ ge9: maxParticipants >= 9,
110
+ le3: maxParticipants <= 3,
111
+ },
112
+ timestamp: Date.now(),
113
+ }),
114
+ }).catch(() => {});
115
+ }, [open, maxParticipants, onMaxParticipantsChange]);
116
+ // #endregion
117
+
118
  const nameForModel = (id) => {
119
  if (!id) return null;
120
  const m = allModels.find(x => x.id === id);
 
133
 
134
  return (
135
  <div className="dev-wrap" ref={wrapRef}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  <div className="dev-dropdown-header">
137
  <button
138
  className="icon-btn"
 
143
  </button>
144
  {open && (
145
  <div className="dev-panel">
146
+
147
+ {/* ── Theme ─────────────────────────────────────────── */}
148
+ <div className="dev-panel-label">Theme</div>
149
+ <button
150
+ className="dev-panel-row"
151
+ onClick={onToggleTheme}
152
+ title="Toggle light/dark mode"
153
+ >
154
+ {theme === 'light'
155
+ ? <Moon size={14} className="dev-check-icon" />
156
+ : <Sun size={14} className="dev-check-icon" />}
157
+ {theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
158
+ </button>
159
+
160
+ <div className="dev-panel-divider" />
161
+
162
+ {/* ── Models ────────────────────────────────────────── */}
163
+ <div className="dev-panel-label">Models</div>
164
+ <button
165
+ className="dev-panel-row dev-panel-row-stack"
166
+ onClick={() => { setActiveSub(s => s === 'orch' ? null : 'orch'); setQ(''); }}
167
+ >
168
+ <div className="dev-panel-row-text">
169
+ <div className="dev-panel-row-name">Orchestrator model…</div>
170
+ <div className="dev-panel-row-sub">{orchName}</div>
171
+ </div>
172
+ <ChevronRight size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
173
  </button>
174
+ <button
175
+ className="dev-panel-row dev-panel-row-stack"
176
+ onClick={() => { setActiveSub(s => s === 'sum' ? null : 'sum'); setQ(''); }}
177
+ >
178
+ <div className="dev-panel-row-text">
179
+ <div className="dev-panel-row-name">Summarizer model…</div>
180
+ <div className="dev-panel-row-sub">{sumName}</div>
181
+ </div>
182
+ <ChevronRight size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
183
  </button>
184
 
185
  <div className="dev-panel-divider" />
186
+
187
+ {/* ── Max participants ──────────────────────────────── */}
188
  <div className="dev-panel-label">Max participants ({maxParticipants})</div>
189
  <div className="ccai-stepper-row">
190
  <button
191
  className="btn-sm btn-outline ccai-stepper-btn"
192
  disabled={maxParticipants <= 3}
193
+ onClick={() => {
194
+ const next = Math.max(3, maxParticipants - 1);
195
+ // #region agent log
196
+ fetch('http://127.0.0.1:7660/ingest/b27d4bb5-c1ab-4767-aa98-1cdf1e8fb0ae', {
197
+ method: 'POST',
198
+ headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '896623' },
199
+ body: JSON.stringify({
200
+ sessionId: '896623',
201
+ runId: 'maxparticipants-repro',
202
+ hypothesisId: 'H1',
203
+ location: 'DevMenu.js:onMinusClick',
204
+ message: 'Minus button clicked',
205
+ data: {
206
+ maxParticipants,
207
+ maxParticipantsType: typeof maxParticipants,
208
+ next,
209
+ nextType: typeof next,
210
+ nextIsNaN: Number.isNaN(next),
211
+ onMaxParticipantsChangeType: typeof onMaxParticipantsChange,
212
+ },
213
+ timestamp: Date.now(),
214
+ }),
215
+ }).catch(() => {});
216
+ // #endregion
217
+ onMaxParticipantsChange(next);
218
+ }}
219
  >−</button>
220
  <div className="ccai-stepper-val">{maxParticipants}</div>
221
  <button
222
  className="btn-sm btn-outline ccai-stepper-btn"
223
  disabled={maxParticipants >= 9}
224
+ onClick={() => {
225
+ const next = Math.min(9, maxParticipants + 1);
226
+ // #region agent log
227
+ fetch('http://127.0.0.1:7660/ingest/b27d4bb5-c1ab-4767-aa98-1cdf1e8fb0ae', {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '896623' },
230
+ body: JSON.stringify({
231
+ sessionId: '896623',
232
+ runId: 'maxparticipants-repro',
233
+ hypothesisId: 'H1',
234
+ location: 'DevMenu.js:onPlusClick',
235
+ message: 'Plus button clicked',
236
+ data: {
237
+ maxParticipants,
238
+ maxParticipantsType: typeof maxParticipants,
239
+ next,
240
+ nextType: typeof next,
241
+ nextIsNaN: Number.isNaN(next),
242
+ onMaxParticipantsChangeType: typeof onMaxParticipantsChange,
243
+ },
244
+ timestamp: Date.now(),
245
+ }),
246
+ }).catch(() => {});
247
+ // #endregion
248
+ onMaxParticipantsChange(next);
249
+ }}
250
  >+</button>
251
  <span className="dev-panel-hint">3-9</span>
252
  </div>
253
 
254
  <div className="dev-panel-divider" />
255
+
256
+ {/* ── Participants ──────────────────────────────────── */}
257
  <div className="dev-panel-label">Participants</div>
258
+ <button
259
+ className="dev-panel-row"
260
+ onClick={() => { onOpenExpertModal(null); setOpen(false); }}
261
+ >
262
  <UserPlus size={14} className="dev-check-icon" />
263
  Create Expert Persona…
264
  </button>
 
 
 
265
  {(participants || []).map(p => {
266
  const assigned = modelAssignments[p.participant_id];
267
  const labelName = assigned ? nameForModel(assigned)
 
269
  return (
270
  <button
271
  key={p.participant_id}
272
+ className="dev-panel-row dev-panel-row-stack"
273
  onClick={() => { setActiveSub(s => s === p.participant_id ? null : p.participant_id); setQ(''); }}
274
  >
275
+ <div className="dev-panel-row-text">
276
+ <div className="dev-panel-row-name">{p.name}</div>
277
+ <div className="dev-panel-row-sub">{labelName}</div>
278
+ </div>
279
+ <ChevronRight size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
280
  </button>
281
  );
282
  })}
283
 
284
  <div className="dev-panel-divider" />
285
+
286
+ {/* ── Display options ───────────────────────────────── */}
287
  <div className="dev-panel-label">Display options</div>
288
  <button
289
  className={`dev-panel-choice ${showResponseTime ? 'dev-panel-choice-active' : ''}`}
 
301
  </button>
302
 
303
  <div className="dev-panel-divider" />
304
+
305
+ {/* ── Transparency ──────────────────────────────────── */}
306
+ {/* No right-side chevrons here: these buttons trigger a
307
+ modal and don't expand a sub-panel, so the chevron the
308
+ old layout had was misleading and (per user feedback)
309
+ wrapped onto a noisy second line. */}
310
  <div className="dev-panel-label">Transparency</div>
311
  <button
312
+ className="dev-panel-row"
313
  disabled={!hasCredentials}
314
  onClick={() => { onShowCredentials?.(); setOpen(false); }}
315
  title={
 
320
  >
321
  <ScrollText size={14} className="dev-check-icon" />
322
  View Credential Summary…
 
323
  </button>
324
  <button
325
+ className="dev-panel-row"
326
  onClick={() => { onShowPromptCatalog?.(); setOpen(false); }}
327
  title="View every prompt template the orchestrator and participants use, grouped by phase."
328
  >
329
  <BookOpen size={14} className="dev-check-icon" />
330
  View current chat prompts…
 
331
  </button>
332
 
333
  <div className="dev-panel-divider" />
334
+
335
+ {/* ── Advanced ──────────────────────────────────────── */}
336
  <div className="dev-panel-label">Advanced</div>
337
  <button
338
+ className="dev-panel-row"
339
  onClick={() => { onShowConversationLimits?.(); setOpen(false); }}
340
  title="View and adjust the per-phase repetition limits and failsafe pause points."
341
  >
 
353
  (custom)
354
  </span>
355
  )}
356
+ <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5, flexShrink: 0 }} />
357
  </button>
358
 
359
  <div className="dev-panel-divider" />
360
+
361
+ {/* ── Downloads ─────────────────────────────────────── */}
362
+ {/* These three are intentional duplicates of items in the
363
+ header DownloadMenu. The "Full API history" item lives
364
+ only in DownloadMenu (per UX request) and is therefore
365
+ not listed here. */}
366
+ <div className="dev-panel-label">Downloads</div>
367
+ <button
368
+ className="dev-panel-row"
369
+ disabled={!hasChat}
370
+ onClick={() => { onDownloadChatTxt(); setOpen(false); }}
371
+ >
372
+ <Download size={14} className="dev-check-icon" />
373
+ Chat as .txt
374
  </button>
375
+ <button
376
+ className="dev-panel-row"
377
+ disabled={!hasChat}
378
+ onClick={() => { onDownloadChatMd(); setOpen(false); }}
379
+ >
380
+ <Download size={14} className="dev-check-icon" />
381
+ Chat as .md
382
  </button>
383
+ <button
384
+ className="dev-panel-row"
385
+ disabled={!hasChat}
386
+ onClick={() => { onDownloadCsvTable(); setOpen(false); }}
387
+ >
388
+ <Download size={14} className="dev-check-icon" />
389
+ Summary table as .csv
390
  </button>
391
  </div>
392
  )}
frontend/src/components/DownloadMenu.js ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { Download, Table2, FileText, FileCode, FileSpreadsheet, History } from 'lucide-react';
3
+
4
+ /**
5
+ * Header dropdown that consolidates every "view this conversation as…"
6
+ * and "download this conversation as…" action behind a single Download
7
+ * icon. The same chat-as-.txt / .md / .csv items are *also* listed in
8
+ * the Settings menu's Downloads section (intentional duplication, per
9
+ * UX request); the only item that lives *exclusively* here is
10
+ * "Download full API history".
11
+ *
12
+ * Mirrors the open/close + outside-mousedown pattern used by DevMenu so
13
+ * both dropdowns feel and behave identically.
14
+ */
15
+ export default function DownloadMenu({
16
+ hasChat,
17
+ hasApiLog,
18
+ onShowTableView,
19
+ onDownloadChatTxt,
20
+ onDownloadChatMd,
21
+ onDownloadCsvTable,
22
+ onDownloadApiLog,
23
+ }) {
24
+ const [open, setOpen] = useState(false);
25
+ const wrapRef = useRef(null);
26
+
27
+ useEffect(() => {
28
+ function handleClickOutside(e) {
29
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) {
30
+ setOpen(false);
31
+ }
32
+ }
33
+ document.addEventListener('mousedown', handleClickOutside);
34
+ return () => document.removeEventListener('mousedown', handleClickOutside);
35
+ }, []);
36
+
37
+ const fire = (fn) => () => { fn?.(); setOpen(false); };
38
+
39
+ return (
40
+ <div className="dev-wrap" ref={wrapRef}>
41
+ <div className="dev-dropdown-header">
42
+ <button
43
+ className="icon-btn"
44
+ onClick={() => setOpen(o => !o)}
45
+ title="Downloads & exports"
46
+ aria-label="Downloads"
47
+ >
48
+ <Download size={16} />
49
+ </button>
50
+ {open && (
51
+ <div className="dev-panel">
52
+ <div className="dev-panel-label">View</div>
53
+ <button
54
+ className="dev-panel-row"
55
+ disabled={!hasChat}
56
+ onClick={fire(onShowTableView)}
57
+ title="Open the conversation summary table"
58
+ >
59
+ <Table2 size={14} className="dev-check-icon" />
60
+ Summary table…
61
+ </button>
62
+
63
+ <div className="dev-panel-divider" />
64
+ <div className="dev-panel-label">Downloads</div>
65
+ <button
66
+ className="dev-panel-row"
67
+ disabled={!hasChat}
68
+ onClick={fire(onDownloadChatTxt)}
69
+ >
70
+ <FileText size={14} className="dev-check-icon" />
71
+ Chat as .txt
72
+ </button>
73
+ <button
74
+ className="dev-panel-row"
75
+ disabled={!hasChat}
76
+ onClick={fire(onDownloadChatMd)}
77
+ >
78
+ <FileCode size={14} className="dev-check-icon" />
79
+ Chat as .md
80
+ </button>
81
+ <button
82
+ className="dev-panel-row"
83
+ disabled={!hasChat}
84
+ onClick={fire(onDownloadCsvTable)}
85
+ title="Download the summary table as CSV"
86
+ >
87
+ <FileSpreadsheet size={14} className="dev-check-icon" />
88
+ Summary table as .csv
89
+ </button>
90
+ <button
91
+ className="dev-panel-row"
92
+ disabled={!hasApiLog}
93
+ onClick={fire(onDownloadApiLog)}
94
+ title="Download the full backend API call history for this session"
95
+ >
96
+ <History size={14} className="dev-check-icon" />
97
+ Full API history
98
+ </button>
99
+ </div>
100
+ )}
101
+ </div>
102
+ </div>
103
+ );
104
+ }
frontend/src/components/Header.js CHANGED
@@ -1,13 +1,22 @@
1
  import React from 'react';
2
- import { Sun, Moon } from 'lucide-react';
3
  import AuthBadge from './AuthBadge';
4
  import ParticipantDropdown from './ParticipantDropdown';
 
5
  import DevMenu from './DevMenu';
6
 
7
  /**
8
- * Header bar: brand on the left; on the right, participant dropdown,
9
- * settings, exports, table-view toggle, and the rate-limit-aware auth
10
- * badge.
 
 
 
 
 
 
 
 
 
11
  */
12
  export default function Header({
13
  theme,
@@ -24,8 +33,37 @@ export default function Header({
24
  autoSelectMode,
25
  onToggleAutoSelectMode,
26
 
27
- // dev menu props passed straight through
28
- ...devProps
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  }) {
30
  return (
31
  <header className="app-header">
@@ -51,16 +89,42 @@ export default function Header({
51
  autoSelectMode={autoSelectMode}
52
  onToggleAutoSelectMode={onToggleAutoSelectMode}
53
  />
54
- <button
55
- className="icon-btn"
56
- onClick={onToggleTheme}
57
- title="Toggle theme"
58
- >
59
- {theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
60
- </button>
 
 
61
  <DevMenu
62
- {...devProps}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  onOpenExpertModal={onOpenExpertModal}
 
 
 
 
 
 
 
 
 
64
  />
65
  </div>
66
  </header>
 
1
  import React from 'react';
 
2
  import AuthBadge from './AuthBadge';
3
  import ParticipantDropdown from './ParticipantDropdown';
4
+ import DownloadMenu from './DownloadMenu';
5
  import DevMenu from './DevMenu';
6
 
7
  /**
8
+ * Header bar: brand on the left; on the right, auth badge, participant
9
+ * dropdown, downloads dropdown, settings (gear) dropdown.
10
+ *
11
+ * The standalone Sun/Moon theme toggle that used to live here has moved
12
+ * inside the DevMenu (Theme is the top item in the settings panel).
13
+ *
14
+ * NOTE: every prop that goes to DevMenu is forwarded *explicitly* below
15
+ * (no `...devProps` rest spread). The previous spread pattern hid the
16
+ * `maxParticipants` value because Header destructured it for its own
17
+ * use, which silently stripped it from the spread that fed DevMenu;
18
+ * the Max Participants stepper then received `undefined` and produced
19
+ * NaN on every click, which manifested as "the +/- buttons do nothing".
20
  */
21
  export default function Header({
22
  theme,
 
33
  autoSelectMode,
34
  onToggleAutoSelectMode,
35
 
36
+ // Models / display
37
+ allModels,
38
+ orchestratorModel,
39
+ onOrchestratorChange,
40
+ summarizerModel,
41
+ onSummarizerChange,
42
+ showResponseTime,
43
+ onShowResponseTimeChange,
44
+ showChatStats,
45
+ onShowChatStatsChange,
46
+ onMaxParticipantsChange,
47
+
48
+ participants,
49
+ modelAssignments,
50
+ onModelAssignmentChange,
51
+
52
+ // Modals / transparency
53
+ onShowTableView,
54
+ onShowCredentials,
55
+ hasCredentials,
56
+ onShowPromptCatalog,
57
+ onShowConversationLimits,
58
+ conversationLimitsOverridden,
59
+
60
+ // Downloads
61
+ onDownloadChatTxt,
62
+ onDownloadChatMd,
63
+ onDownloadCsvTable,
64
+ onDownloadApiLog,
65
+ hasApiLog,
66
+ hasChat,
67
  }) {
68
  return (
69
  <header className="app-header">
 
89
  autoSelectMode={autoSelectMode}
90
  onToggleAutoSelectMode={onToggleAutoSelectMode}
91
  />
92
+ <DownloadMenu
93
+ hasChat={hasChat}
94
+ hasApiLog={hasApiLog}
95
+ onShowTableView={onShowTableView}
96
+ onDownloadChatTxt={onDownloadChatTxt}
97
+ onDownloadChatMd={onDownloadChatMd}
98
+ onDownloadCsvTable={onDownloadCsvTable}
99
+ onDownloadApiLog={onDownloadApiLog}
100
+ />
101
  <DevMenu
102
+ theme={theme}
103
+ onToggleTheme={onToggleTheme}
104
+ allModels={allModels}
105
+ orchestratorModel={orchestratorModel}
106
+ onOrchestratorChange={onOrchestratorChange}
107
+ summarizerModel={summarizerModel}
108
+ onSummarizerChange={onSummarizerChange}
109
+ showResponseTime={showResponseTime}
110
+ onShowResponseTimeChange={onShowResponseTimeChange}
111
+ showChatStats={showChatStats}
112
+ onShowChatStatsChange={onShowChatStatsChange}
113
+ maxParticipants={maxParticipants}
114
+ onMaxParticipantsChange={onMaxParticipantsChange}
115
+ participants={participants}
116
+ modelAssignments={modelAssignments}
117
+ onModelAssignmentChange={onModelAssignmentChange}
118
  onOpenExpertModal={onOpenExpertModal}
119
+ onShowCredentials={onShowCredentials}
120
+ hasCredentials={hasCredentials}
121
+ onShowPromptCatalog={onShowPromptCatalog}
122
+ onShowConversationLimits={onShowConversationLimits}
123
+ conversationLimitsOverridden={conversationLimitsOverridden}
124
+ onDownloadChatTxt={onDownloadChatTxt}
125
+ onDownloadChatMd={onDownloadChatMd}
126
+ onDownloadCsvTable={onDownloadCsvTable}
127
+ hasChat={hasChat}
128
  />
129
  </div>
130
  </header>
frontend/src/styles/ccai.css CHANGED
@@ -982,7 +982,10 @@
982
  display: flex;
983
  align-items: center;
984
  gap: 8px;
985
- padding: 4px 10px 8px;
 
 
 
986
  }
987
 
988
  .ccai-stepper-btn {
 
982
  display: flex;
983
  align-items: center;
984
  gap: 8px;
985
+ /* 22px left padding matches .dev-panel-row indentation so the
986
+ stepper hangs under the "Max participants" category label
987
+ consistently with every other category's items. */
988
+ padding: 4px 10px 8px 22px;
989
  }
990
 
991
  .ccai-stepper-btn {
frontend/src/styles/components.css CHANGED
@@ -769,7 +769,10 @@
769
  padding: 8px 10px;
770
  border: none;
771
  background: transparent;
772
- color: var(--text-secondary);
 
 
 
773
  border-radius: 6px;
774
  cursor: pointer;
775
  font-size: 13px;
@@ -782,13 +785,17 @@
782
  }
783
 
784
  .dev-panel button:disabled {
785
- opacity: 0.4;
 
 
786
  cursor: not-allowed;
787
  }
788
 
789
  .dev-panel-hint {
790
  font-size: 10px;
791
- color: var(--text-muted);
 
 
792
  margin-left: 6px;
793
  font-style: italic;
794
  }
@@ -804,10 +811,55 @@
804
  font-weight: 600;
805
  text-transform: uppercase;
806
  letter-spacing: 0.05em;
807
- color: var(--text-muted);
 
808
  padding: 4px 10px 2px;
809
  }
810
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811
  .dev-panel-choice {
812
  padding-left: 22px !important;
813
  display: flex !important;
@@ -1132,12 +1184,11 @@
1132
 
1133
  /* ── Small tablet / large phone (<600px) ──────────────────────── */
1134
  @media (max-width: 600px) {
1135
- .dev-download-btns {
1136
- display: none;
1137
- }
1138
- .dev-panel-download-item {
1139
- display: block !important;
1140
- }
1141
  .dev-sub-panel {
1142
  right: 0;
1143
  top: 100%;
 
769
  padding: 8px 10px;
770
  border: none;
771
  background: transparent;
772
+ /* Higher-contrast text: was --text-secondary (#4B5563) which read as
773
+ low-contrast against the white panel. --text-primary (#111827) is
774
+ near-black and matches the contrast of the rest of the chat UI. */
775
+ color: var(--text-primary);
776
  border-radius: 6px;
777
  cursor: pointer;
778
  font-size: 13px;
 
785
  }
786
 
787
  .dev-panel button:disabled {
788
+ /* Slightly less aggressive than 0.4 because the base text is now darker;
789
+ 0.55 keeps disabled items legible-but-distinguishable. */
790
+ opacity: 0.55;
791
  cursor: not-allowed;
792
  }
793
 
794
  .dev-panel-hint {
795
  font-size: 10px;
796
+ /* Bumped from --text-muted (#9CA3AF) to --text-secondary (#4B5563)
797
+ so inline metadata is actually readable. */
798
+ color: var(--text-secondary);
799
  margin-left: 6px;
800
  font-style: italic;
801
  }
 
811
  font-weight: 600;
812
  text-transform: uppercase;
813
  letter-spacing: 0.05em;
814
+ /* Bumped from --text-muted to --text-secondary for the same reason. */
815
+ color: var(--text-secondary);
816
  padding: 4px 10px 2px;
817
  }
818
 
819
+ /* Indented row used for every actionable item inside a category, so
820
+ labels (left-aligned at 10px) read as clear group headers and the
821
+ items below them sit at a 22px hang. Matches the look of the
822
+ original .dev-panel-choice rows. */
823
+ .dev-panel-row {
824
+ padding-left: 22px !important;
825
+ display: flex !important;
826
+ align-items: center;
827
+ gap: 8px;
828
+ }
829
+
830
+ /* Two-line variant: name on top, subtitle (model name) on bottom,
831
+ chevron right-aligned. The middle text column is flex:1, min-width:0
832
+ so the subtitle can ellipsis-truncate when it overflows. */
833
+ .dev-panel-row-stack {
834
+ align-items: stretch;
835
+ }
836
+
837
+ .dev-panel-row-text {
838
+ flex: 1;
839
+ min-width: 0;
840
+ display: flex;
841
+ flex-direction: column;
842
+ align-items: flex-start;
843
+ gap: 2px;
844
+ }
845
+
846
+ .dev-panel-row-name {
847
+ font-size: 13px;
848
+ color: var(--text-primary);
849
+ line-height: 1.25;
850
+ }
851
+
852
+ .dev-panel-row-sub {
853
+ font-size: 11px;
854
+ color: var(--text-tertiary);
855
+ font-style: italic;
856
+ white-space: nowrap;
857
+ overflow: hidden;
858
+ text-overflow: ellipsis;
859
+ max-width: 100%;
860
+ line-height: 1.2;
861
+ }
862
+
863
  .dev-panel-choice {
864
  padding-left: 22px !important;
865
  display: flex !important;
 
1184
 
1185
  /* ── Small tablet / large phone (<600px) ──────────────────────── */
1186
  @media (max-width: 600px) {
1187
+ /* The standalone .dev-download-btns header strip and the
1188
+ .dev-panel-download-item mobile fallback inside the settings menu
1189
+ have been replaced by a dedicated <DownloadMenu> dropdown that
1190
+ works on all viewports, so the responsive fallback logic that used
1191
+ to live here is no longer needed. */
 
1192
  .dev-sub-panel {
1193
  right: 0;
1194
  top: 100%;