Skip to content

Commit bd39888

Browse files
committed
feat(settings): N-actor cohort config with add/remove rows replaces hardcoded leaderA/B pair
1 parent 7f70a5b commit bd39888

3 files changed

Lines changed: 237 additions & 52 deletions

File tree

src/dashboard/src/components/settings/LoadPriorRunsCTA.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useMemo, useRef } from 'react';
22
import { useSessions } from '../../hooks/useSessions';
3+
import { formatRoster } from '../layout/ReplayBanner';
34
import styles from './LoadPriorRunsCTA.module.scss';
45

56
interface LoadPriorRunsCTAProps {
@@ -105,7 +106,7 @@ export function LoadPriorRunsCTA({ hideWhenUnavailable = true }: LoadPriorRunsCT
105106
<span className={styles.tileMeta}>
106107
{s.title && s.scenarioName ? `${s.scenarioName} · ` : ''}
107108
{typeof s.turnCount === 'number' ? `${s.turnCount} turns · ` : ''}
108-
{s.leaderA && s.leaderB ? `${s.leaderA} vs ${s.leaderB} · ` : ''}
109+
{formatRoster(s) ? `${formatRoster(s)} · ` : ''}
109110
{formatCreatedAt(s.createdAt)}
110111
</span>
111112
{typeof s.totalCostUSD === 'number' && s.totalCostUSD > 0 && (

src/dashboard/src/components/settings/SettingsPanel.module.scss

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,105 @@
102102

103103
.leadersGrid {
104104
display: grid;
105+
// Pair runs (`responsive-grid-2` class) lock to 2 columns. Cohort
106+
// runs (`responsive-grid-cohort` class added in the JSX) auto-fit a
107+
// 2-column-on-desktop / 1-column-on-narrow layout via the
108+
// grid-template-columns rule on the global utility below.
105109
grid-template-columns: 1fr 1fr;
106110
gap: 12px;
107-
margin-bottom: 16px;
111+
margin-bottom: 8px;
112+
}
113+
114+
/* Global utility for the cohort N-actor settings grid. Goes on the
115+
* leadersGrid wrapper alongside .leadersGrid. Uses `:global` to
116+
* escape the CSS module scope so the className applied from JSX
117+
* (`responsive-grid-cohort`) matches without needing to import. */
118+
:global(.responsive-grid-cohort) {
119+
display: grid;
120+
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
121+
gap: 12px;
122+
}
123+
124+
.leaderCardWrap {
125+
position: relative;
126+
}
127+
128+
.leaderRemoveBtn {
129+
position: absolute;
130+
top: 4px;
131+
right: 4px;
132+
width: 28px;
133+
height: 28px;
134+
display: flex;
135+
align-items: center;
136+
justify-content: center;
137+
background: var(--bg-deep);
138+
border: 1px solid var(--border);
139+
color: var(--text-3);
140+
border-radius: 4px;
141+
cursor: pointer;
142+
font-size: 14px;
143+
line-height: 1;
144+
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
145+
z-index: 1;
146+
147+
&:hover {
148+
color: var(--rust);
149+
border-color: var(--rust);
150+
background: color-mix(in srgb, var(--rust) 8%, var(--bg-deep));
151+
}
152+
153+
&:focus-visible {
154+
outline: 2px solid var(--amber);
155+
outline-offset: 2px;
156+
}
157+
}
158+
159+
.leadersAddRow {
160+
display: flex;
161+
align-items: center;
162+
justify-content: space-between;
163+
flex-wrap: wrap;
164+
gap: 8px;
165+
margin: 0 0 16px;
166+
}
167+
168+
.leaderAddBtn {
169+
font-family: var(--mono);
170+
font-size: var(--font-xs);
171+
font-weight: 700;
172+
letter-spacing: 0.06em;
173+
text-transform: uppercase;
174+
color: var(--amber);
175+
background: transparent;
176+
border: 1px dashed var(--amber);
177+
border-radius: 4px;
178+
padding: 8px 14px;
179+
cursor: pointer;
180+
transition: background 0.15s ease, color 0.15s ease;
181+
182+
&:hover {
183+
background: color-mix(in srgb, var(--amber) 10%, transparent);
184+
}
185+
186+
&:focus-visible {
187+
outline: 2px solid var(--amber);
188+
outline-offset: 2px;
189+
}
190+
191+
@media (max-width: 480px) {
192+
padding: 12px 14px;
193+
min-height: 44px;
194+
flex: 1 1 auto;
195+
}
196+
}
197+
198+
.leaderAddCap {
199+
font-family: var(--mono);
200+
font-size: var(--font-xs);
201+
color: var(--text-3);
202+
letter-spacing: 0.04em;
203+
line-height: 1.55;
108204
}
109205

110206
.fieldset {

src/dashboard/src/components/settings/SettingsPanel.tsx

Lines changed: 138 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useCallback, useEffect } from 'react';
22
import { useDashboardNavigation, useScenarioContext } from '../../App';
33
import { useScenarioLabels } from '../../hooks/useScenarioLabels';
4+
import { getActorColorVar } from '../../hooks/useGameState';
45
import { ActorConfig, type ActorFormData } from './ActorConfig';
56
import { ScenarioEditor } from './ScenarioEditor';
67
import { LoadPriorRunsCTA } from './LoadPriorRunsCTA';
@@ -84,16 +85,46 @@ const TIER_LABELS: Record<ModelTier, { label: string; help: string }> = {
8485
agentReactions: { label: 'Agent Reactions', help: 'One to two sentences per colonist per turn. Highest volume — pick cheapest.' },
8586
};
8687

88+
/**
89+
* Generic per-slot defaults so the Settings panel can render N actor
90+
* forms instead of just the original two. Indexes 0/1 keep their
91+
* legacy names (Actor A / Actor B + Visionary / Engineer archetypes
92+
* + Colony Alpha / Beta units) so pair-mode runs feel identical to
93+
* the pre-cohort UX; cohort slots fall through to numbered defaults.
94+
*/
95+
const PHONETIC_UNITS = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta'];
96+
const COHORT_ARCHETYPES = [
97+
'The Visionary',
98+
'The Engineer',
99+
'The Diplomat',
100+
'The Maverick',
101+
'The Guardian',
102+
'The Strategist',
103+
'The Steward',
104+
'The Innovator',
105+
];
106+
87107
function defaultLeader(idx: number): ActorFormData {
108+
const slotLetter = String.fromCharCode(65 + (idx % 26));
109+
const phonetic = PHONETIC_UNITS[idx] ?? `Group ${idx + 1}`;
88110
return {
89-
name: idx === 0 ? 'Actor A' : 'Actor B',
90-
archetype: idx === 0 ? 'The Visionary' : 'The Engineer',
91-
unit: idx === 0 ? 'Colony Alpha' : 'Colony Beta',
111+
name: `Actor ${slotLetter}`,
112+
archetype: COHORT_ARCHETYPES[idx] ?? 'The Strategist',
113+
unit: `Colony ${phonetic}`,
92114
instructions: '',
93115
hexaco: { ...DEFAULT_HEXACO },
94116
};
95117
}
96118

119+
/** Lower bound — Sim API rejects under 2 actors (non-fork paths). */
120+
const MIN_ACTORS = 2;
121+
/** Upper bound for the Settings panel UI. The server accepts up to
122+
* 300 (matches the Quickstart slider), but past ~8 the per-actor
123+
* HEXACO form scroll becomes a lot to manage from this surface.
124+
* Users wanting larger cohorts should use Quickstart's generate-N
125+
* flow which produces actor configs from a single prompt. */
126+
const MAX_ACTORS = 8;
127+
97128
export interface SettingsPanelProps {
98129
/** SSE events to feed the embedded EventLogPanel sub-tab. Optional
99130
* so callers that don't care about Log (or mount Settings before the
@@ -134,49 +165,67 @@ export function SettingsPanel({ events = [], initialSubTab = 'config' }: Setting
134165
const presetLeaders = defaultPreset?.leaders ?? defaultPreset?.actors;
135166
const persistedActors =
136167
typeof window !== 'undefined' ? readActiveRunActors(window.localStorage) : null;
137-
const fallbackA =
138-
presetLeaders?.[0] ??
139-
(persistedActors?.[0] as { name?: string; archetype?: string; instructions?: string; hexaco?: Record<string, number> } | undefined);
140-
const fallbackB =
141-
presetLeaders?.[1] ??
142-
(persistedActors?.[1] as { name?: string; archetype?: string; instructions?: string; hexaco?: Record<string, number> } | undefined);
143-
// Spread the hexaco object so the form's per-trait edits don't mutate
144-
// the preset that lives in the scenario context (which is shared with
145-
// every other consumer that reads scenario.presets).
146-
const initLeaderA: ActorFormData = fallbackA?.name
147-
? { name: fallbackA.name, archetype: fallbackA.archetype ?? '', unit: 'Colony Alpha', instructions: fallbackA.instructions ?? '', hexaco: { ...(fallbackA.hexaco ?? {}) } }
148-
: defaultLeader(0);
149-
const initLeaderB: ActorFormData = fallbackB?.name
150-
? { name: fallbackB.name, archetype: fallbackB.archetype ?? '', unit: 'Colony Beta', instructions: fallbackB.instructions ?? '', hexaco: { ...(fallbackB.hexaco ?? {}) } }
151-
: defaultLeader(1);
152-
153-
const [leaderA, setLeaderA] = useState<ActorFormData>(initLeaderA);
154-
const [leaderB, setLeaderB] = useState<ActorFormData>(initLeaderB);
168+
// Cohort-aware initial state: merge presets, persisted launch config,
169+
// and per-slot defaults. The Settings panel renders up to MAX_ACTORS
170+
// actor forms; runs that previously launched with 3+ actors via
171+
// Quickstart resume here with their full roster intact instead of
172+
// collapsing back to the legacy pair view. Pair-only sources (a
173+
// 2-actor preset on a scenario built before cohorts) still hydrate
174+
// the first two slots and leave the rest at their slot defaults.
175+
const presetActorCount = Math.max(
176+
MIN_ACTORS,
177+
Math.min(MAX_ACTORS, presetLeaders?.length ?? persistedActors?.length ?? MIN_ACTORS),
178+
);
179+
const initActors: ActorFormData[] = Array.from({ length: presetActorCount }, (_, idx) => {
180+
const fallback =
181+
presetLeaders?.[idx] ??
182+
(persistedActors?.[idx] as { name?: string; archetype?: string; unit?: string; instructions?: string; hexaco?: Record<string, number> } | undefined);
183+
if (!fallback?.name) return defaultLeader(idx);
184+
return {
185+
name: fallback.name,
186+
archetype: fallback.archetype ?? '',
187+
unit: fallback.unit ?? `Colony ${PHONETIC_UNITS[idx] ?? `${idx + 1}`}`,
188+
instructions: fallback.instructions ?? '',
189+
// Spread so the form's per-trait edits don't mutate the preset
190+
// shared via the scenario context.
191+
hexaco: { ...(fallback.hexaco ?? DEFAULT_HEXACO) },
192+
};
193+
});
194+
195+
const [actors, setActors] = useState<ActorFormData[]>(initActors);
155196

156197
// Re-populate from presets when scenario data loads (async fetch).
157-
// Depend on presets length because the fallback has presets:[] but same id.
198+
// Depend on presets length because the fallback has presets:[] but
199+
// the same id. Preserves the user's actor count when the preset has
200+
// fewer entries than the current form (no shrink on scenario load).
158201
useEffect(() => {
159202
const p = scenario.presets.find(p => p.id === 'default');
160203
const leaders = p?.leaders ?? p?.actors;
161-
if (leaders?.[0]) {
162-
setLeaderA({
163-
name: leaders[0].name,
164-
archetype: leaders[0].archetype,
165-
unit: 'Colony Alpha',
166-
instructions: leaders[0].instructions,
167-
hexaco: { ...leaders[0].hexaco },
168-
});
169-
}
170-
if (leaders?.[1]) {
171-
setLeaderB({
172-
name: leaders[1].name,
173-
archetype: leaders[1].archetype,
174-
unit: 'Colony Beta',
175-
instructions: leaders[1].instructions,
176-
hexaco: { ...leaders[1].hexaco },
177-
});
178-
}
204+
if (!leaders || leaders.length === 0) return;
205+
setActors(prev => prev.map((existing, idx) => {
206+
const preset = leaders[idx];
207+
if (!preset) return existing;
208+
return {
209+
name: preset.name,
210+
archetype: preset.archetype,
211+
unit: existing.unit || `Colony ${PHONETIC_UNITS[idx] ?? `${idx + 1}`}`,
212+
instructions: preset.instructions,
213+
hexaco: { ...preset.hexaco },
214+
};
215+
}));
179216
}, [scenario.id, scenario.presets.length]);
217+
218+
const updateActor = useCallback((idx: number, next: ActorFormData) => {
219+
setActors(prev => prev.map((a, i) => (i === idx ? next : a)));
220+
}, []);
221+
222+
const addActor = useCallback(() => {
223+
setActors(prev => (prev.length < MAX_ACTORS ? [...prev, defaultLeader(prev.length)] : prev));
224+
}, []);
225+
226+
const removeActor = useCallback((idx: number) => {
227+
setActors(prev => (prev.length > MIN_ACTORS ? prev.filter((_, i) => i !== idx) : prev));
228+
}, []);
180229
const [turns, setTurns] = useState(scenario.setup.defaultTurns);
181230
const [seed, setSeed] = useState(scenario.setup.defaultSeed);
182231
const [startTime, setStartTime] = useState(scenario.setup.defaultStartTime);
@@ -306,10 +355,7 @@ export function SettingsPanel({ events = [], initialSubTab = 'config' }: Setting
306355
setStatus('Starting...');
307356
try {
308357
const config: Record<string, unknown> = {
309-
actors: [
310-
{ ...leaderA, hexaco: leaderA.hexaco },
311-
{ ...leaderB, hexaco: leaderB.hexaco },
312-
],
358+
actors: actors.map(a => ({ ...a, hexaco: a.hexaco })),
313359
provider, turns, seed, startTime, timePerTurn: timePerTurn || undefined, population, liveSearch,
314360
activeDepartments: scenario.departments.map(d => d.id),
315361
economics: { profileId: effectiveEconomicsProfile },
@@ -326,7 +372,7 @@ export function SettingsPanel({ events = [], initialSubTab = 'config' }: Setting
326372
// header has names available during the SSE connect-and-replay
327373
// window. Without this, compiled-scenario runs render the
328374
// alphabetic placeholder until status:parallel lands.
329-
writeActiveRunActors(window.localStorage, [leaderA, leaderB]);
375+
writeActiveRunActors(window.localStorage, actors);
330376
// Attach any user-provided key overrides (never sends .env values)
331377
if (keyOverrides.openai) config.apiKey = keyOverrides.openai;
332378
if (keyOverrides.anthropic) config.anthropicKey = keyOverrides.anthropic;
@@ -366,7 +412,7 @@ export function SettingsPanel({ events = [], initialSubTab = 'config' }: Setting
366412
setStatus(`Failed: ${err}`);
367413
setLaunching(false);
368414
}
369-
}, [leaderA, leaderB, turns, seed, startTime, timePerTurn, population, provider, liveSearch, navigateTab, scenario, keyOverrides, tierModels, hasUserLlmKey, effectiveEconomicsProfile]);
415+
}, [actors, turns, seed, startTime, timePerTurn, population, provider, liveSearch, navigateTab, scenario, keyOverrides, tierModels, hasUserLlmKey, effectiveEconomicsProfile]);
370416

371417
const inputCls = (locked: boolean) =>
372418
[styles.input, locked ? styles.locked : ''].filter(Boolean).join(' ');
@@ -442,10 +488,52 @@ export function SettingsPanel({ events = [], initialSubTab = 'config' }: Setting
442488
Server mode: <strong className={styles.leadStrong}>{serverModeInfo.label}</strong>. {serverModeInfo.description}
443489
</p>
444490

445-
{/* Leaders grid */}
446-
<div className={`responsive-grid-2 ${styles.leadersGrid}`}>
447-
<ActorConfig label="Commander A" sideColor="var(--vis)" data={leaderA} onChange={setLeaderA} />
448-
<ActorConfig label="Commander B" sideColor="var(--eng)" data={leaderB} onChange={setLeaderB} />
491+
{/* Leaders grid. Renders N actor forms (2 ≤ N ≤ MAX_ACTORS).
492+
Pair-mode runs (2 actors) layout in the responsive-grid-2
493+
column pair; cohort runs (3+) flow into a responsive grid so
494+
larger cohorts stack two-per-row instead of one column per
495+
slot. Each card carries a Remove button when the cohort is
496+
past MIN_ACTORS; the bottom of the section has an "Add
497+
actor" CTA that pushes a slot-defaulted new actor until the
498+
cohort hits MAX_ACTORS. */}
499+
<div className={`${actors.length > 2 ? 'responsive-grid-cohort' : 'responsive-grid-2'} ${styles.leadersGrid}`}>
500+
{actors.map((actor, idx) => (
501+
<div key={idx} className={styles.leaderCardWrap} style={{ position: 'relative' }}>
502+
<ActorConfig
503+
label={`Commander ${String.fromCharCode(65 + (idx % 26))}`}
504+
sideColor={getActorColorVar(idx)}
505+
data={actor}
506+
onChange={(next) => updateActor(idx, next)}
507+
/>
508+
{actors.length > MIN_ACTORS && (
509+
<button
510+
type="button"
511+
onClick={() => removeActor(idx)}
512+
className={styles.leaderRemoveBtn}
513+
aria-label={`Remove ${actor.name || `actor ${idx + 1}`} from the cohort`}
514+
title="Remove this actor"
515+
>
516+
517+
</button>
518+
)}
519+
</div>
520+
))}
521+
</div>
522+
<div className={styles.leadersAddRow}>
523+
{actors.length < MAX_ACTORS ? (
524+
<button
525+
type="button"
526+
onClick={addActor}
527+
className={styles.leaderAddBtn}
528+
aria-label="Add another actor to the cohort"
529+
>
530+
+ Add actor ({actors.length}/{MAX_ACTORS})
531+
</button>
532+
) : (
533+
<span className={styles.leaderAddCap}>
534+
Max {MAX_ACTORS} actors from Settings · use Quickstart's generate-N flow for larger cohorts.
535+
</span>
536+
)}
449537
</div>
450538

451539
{/* Simulation config */}

0 commit comments

Comments
 (0)