@@ -135,6 +135,17 @@ export function QuickstartView({ sse, sessionId, onRunStarted, onInterventionRes
135135 // stage card can render real citation events.
136136 const [ groundingSummary , setGroundingSummary ] = useState < GroundingSummary | null > ( null ) ;
137137
138+ /**
139+ * Catalog-grid handoff key. When the user clicks Run on a card for
140+ * a scenario that isn't currently active, we POST /scenario/switch
141+ * and reload (so useScenario re-fetches /scenario, ScenarioContext
142+ * re-binds, and every downstream surface picks up the new active
143+ * scenario consistently). The reload destroys component state, so
144+ * we stash the actor-count + intent in sessionStorage and re-run
145+ * automatically on mount.
146+ */
147+ const CATALOG_PENDING_KEY = 'paracosm:catalogPendingRun' ;
148+
138149 /**
139150 * One-click run path: when the user clicks LoadedScenarioCTA, run
140151 * the loaded scenario directly. With presets ≥ requested actorCount,
@@ -314,6 +325,76 @@ export function QuickstartView({ sse, sessionId, onRunStarted, onInterventionRes
314325 }
315326 } , [ scenario , sse , onRunStarted , toast ] ) ;
316327
328+ /**
329+ * Catalog-grid handoff: clicking Run on a scenario card switches the
330+ * server's active scenario (if needed) and runs it. When the picked
331+ * scenario is already active we just route through the same code
332+ * path the LoadedScenarioCTA uses. When it's NOT active, we POST
333+ * /scenario/switch + reload so useScenario re-fetches /scenario and
334+ * ScenarioContext re-binds with the new labels/presets/policies; a
335+ * sessionStorage handoff carries the actor-count across the reload
336+ * so the run kicks off automatically on the next mount.
337+ */
338+ const handleCatalogRun = useCallback ( async ( id : string , actorCount : number ) => {
339+ if ( id === scenario . id ) {
340+ void handleLoadedScenarioRun ( actorCount ) ;
341+ return ;
342+ }
343+ setErrorBanner ( null ) ;
344+ onRunStarted ?.( ) ;
345+ try {
346+ try {
347+ sessionStorage . setItem ( CATALOG_PENDING_KEY , JSON . stringify ( { actorCount, ts : Date . now ( ) } ) ) ;
348+ } catch {
349+ // Private mode / quota: continue with the switch but the
350+ // post-reload auto-run won't fire. User clicks Run again on
351+ // the (now-active) CTA.
352+ }
353+ const res = await fetch ( '/scenario/switch' , {
354+ method : 'POST' ,
355+ headers : { 'Content-Type' : 'application/json' } ,
356+ body : JSON . stringify ( { id } ) ,
357+ } ) ;
358+ if ( ! res . ok ) {
359+ try { sessionStorage . removeItem ( CATALOG_PENDING_KEY ) ; } catch { /* silent */ }
360+ const body = await res . json ( ) . catch ( ( ) => ( { } as { error ?: string } ) ) ;
361+ throw new Error ( body . error ?? `Scenario switch failed: HTTP ${ res . status } ` ) ;
362+ }
363+ window . location . reload ( ) ;
364+ } catch ( err ) {
365+ const friendly = mapLaunchErrorToMessage ( ( err as Error ) ?. message ?? String ( err ) ) ;
366+ setErrorBanner ( friendly ) ;
367+ toast ( 'error' , 'Scenario switch failed' , friendly , 8000 ) ;
368+ }
369+ } , [ scenario . id , handleLoadedScenarioRun , onRunStarted , toast ] ) ;
370+
371+ // Auto-resume after a /scenario/switch reload. The catalog-grid Run
372+ // path stashes { actorCount } in sessionStorage; on mount we read it
373+ // back and trigger handleLoadedScenarioRun once. The flag is
374+ // consumed (removed) on read so a manual reload after an unrelated
375+ // click never re-triggers a stale auto-run.
376+ useEffect ( ( ) => {
377+ let pending : { actorCount ?: unknown } | null = null ;
378+ try {
379+ const raw = sessionStorage . getItem ( CATALOG_PENDING_KEY ) ;
380+ if ( ! raw ) return ;
381+ sessionStorage . removeItem ( CATALOG_PENDING_KEY ) ;
382+ pending = JSON . parse ( raw ) as { actorCount ?: unknown } ;
383+ } catch {
384+ return ;
385+ }
386+ const count = typeof pending ?. actorCount === 'number' && pending . actorCount > 0
387+ ? Math . min ( 300 , Math . max ( 1 , pending . actorCount ) )
388+ : null ;
389+ if ( count !== null ) {
390+ void handleLoadedScenarioRun ( count ) ;
391+ }
392+ // Run exactly once per mount. handleLoadedScenarioRun is stable
393+ // enough that the missing dep here doesn't double-fire — we never
394+ // want this effect to re-run anyway.
395+ // eslint-disable-next-line react-hooks/exhaustive-deps
396+ } , [ ] ) ;
397+
317398 const handleSeedReady = useCallback ( async ( payload : { seedText : string ; sourceUrl ?: string ; domainHint ?: string ; actorCount ?: number } ) => {
318399 setErrorBanner ( null ) ;
319400 // Flip the user-triggered-run gate before any UI changes so the
@@ -527,6 +608,7 @@ export function QuickstartView({ sse, sessionId, onRunStarted, onInterventionRes
527608 < SeedInput
528609 onSeedReady = { handleSeedReady }
529610 onLoadedScenarioRunStart = { handleLoadedScenarioRun }
611+ onCatalogRunStart = { handleCatalogRun }
530612 />
531613 { /* Digital-twin demo lives BELOW the seed input as a
532614 secondary path. Quickstart's primary use case is
0 commit comments