11import { useState , useCallback , useEffect } from 'react' ;
22import { useDashboardNavigation , useScenarioContext } from '../../App' ;
33import { useScenarioLabels } from '../../hooks/useScenarioLabels' ;
4+ import { getActorColorVar } from '../../hooks/useGameState' ;
45import { ActorConfig , type ActorFormData } from './ActorConfig' ;
56import { ScenarioEditor } from './ScenarioEditor' ;
67import { 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+
87107function 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+
97128export 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