Skip to content

Commit e2ed50c

Browse files
committed
feat(quickstart): scenario catalog grid with run counts + ages + one-click switch+run
1 parent 4643d30 commit e2ed50c

4 files changed

Lines changed: 595 additions & 1 deletion

File tree

src/cli/dashboard/src/components/quickstart/QuickstartView.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Scenario catalog grid for Quickstart. Sits between the
2+
// LoadedScenarioCTA card (above) and the SeedInput / paste-new-
3+
// scenario form (below), so users browsing the catalog flow
4+
// naturally from "load the active one" → "browse alternatives" →
5+
// "compile a brand new one."
6+
7+
.section {
8+
margin: 24px 0;
9+
}
10+
11+
.header {
12+
display: flex;
13+
align-items: center;
14+
justify-content: space-between;
15+
flex-wrap: wrap;
16+
gap: 12px;
17+
margin-bottom: 12px;
18+
}
19+
20+
.heading {
21+
font-family: var(--mono);
22+
font-size: var(--font-md);
23+
font-weight: 700;
24+
letter-spacing: 0.06em;
25+
text-transform: uppercase;
26+
color: var(--amber);
27+
margin: 0;
28+
}
29+
30+
.count {
31+
color: var(--text-3);
32+
font-weight: 400;
33+
font-size: var(--font-sm);
34+
letter-spacing: 0;
35+
text-transform: none;
36+
}
37+
38+
.statusLine {
39+
font-family: var(--mono);
40+
font-size: var(--font-sm);
41+
color: var(--text-3);
42+
margin: 0;
43+
}
44+
45+
.error {
46+
color: var(--rust);
47+
}
48+
49+
.actorRow {
50+
display: flex;
51+
align-items: center;
52+
gap: 12px;
53+
font-family: var(--mono);
54+
font-size: var(--font-xs);
55+
color: var(--text-2);
56+
flex-wrap: nowrap;
57+
min-width: 240px;
58+
}
59+
60+
.actorLabel {
61+
flex-shrink: 0;
62+
color: var(--text-3);
63+
letter-spacing: 0.06em;
64+
text-transform: uppercase;
65+
font-weight: 700;
66+
font-size: var(--font-3xs);
67+
}
68+
69+
.actorSlider {
70+
flex: 1;
71+
min-width: 80px;
72+
}
73+
74+
.actorValue {
75+
flex-shrink: 0;
76+
color: var(--text-1);
77+
font-weight: 700;
78+
min-width: 28px;
79+
text-align: right;
80+
}
81+
82+
// Auto-fit grid: cards take their natural minimum width, the row
83+
// flows to fit. On narrow viewports it collapses to one column.
84+
.grid {
85+
display: grid;
86+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
87+
gap: 12px;
88+
list-style: none;
89+
padding: 0;
90+
margin: 0;
91+
}
92+
93+
.card {
94+
display: flex;
95+
flex-direction: column;
96+
gap: 8px;
97+
background: var(--bg-panel);
98+
border: 1px solid var(--border);
99+
border-radius: 8px;
100+
padding: 14px;
101+
transition: border-color 120ms ease, transform 120ms ease;
102+
103+
&:hover {
104+
border-color: var(--amber);
105+
transform: translateY(-1px);
106+
}
107+
}
108+
109+
// Active scenario gets the amber halo so users can spot the loaded
110+
// one at a glance without scanning every badge.
111+
.cardActive {
112+
border-color: var(--amber);
113+
box-shadow: 0 0 0 1px var(--amber);
114+
}
115+
116+
.cardHeader {
117+
display: flex;
118+
align-items: center;
119+
justify-content: space-between;
120+
gap: 8px;
121+
}
122+
123+
.sourceBadge {
124+
font-family: var(--mono);
125+
font-size: var(--font-3xs);
126+
font-weight: 700;
127+
letter-spacing: 0.08em;
128+
text-transform: uppercase;
129+
padding: 2px 6px;
130+
border-radius: 3px;
131+
border: 1px solid currentColor;
132+
}
133+
134+
// Per-source tinting: the badge colors signal provenance at a
135+
// glance — green for builtins, blue for disk-curated, amber for
136+
// compile-from-seed customs.
137+
.badge_builtin { color: var(--green, #22c55e); }
138+
.badge_disk { color: #3b82f6; }
139+
.badge_compiled, .badge_memory { color: var(--amber); }
140+
.badge_other { color: var(--text-3); }
141+
142+
.activeBadge {
143+
font-family: var(--mono);
144+
font-size: var(--font-3xs);
145+
font-weight: 700;
146+
letter-spacing: 0.08em;
147+
text-transform: uppercase;
148+
color: var(--bg-deep);
149+
background: var(--amber);
150+
padding: 2px 6px;
151+
border-radius: 3px;
152+
}
153+
154+
.cardName {
155+
font-family: var(--sans);
156+
font-size: var(--font-md);
157+
font-weight: 700;
158+
color: var(--text-1);
159+
margin: 0;
160+
line-height: 1.3;
161+
overflow-wrap: anywhere;
162+
}
163+
164+
.cardSeed {
165+
font-family: var(--mono);
166+
font-size: var(--font-2xs);
167+
color: var(--text-2);
168+
font-style: italic;
169+
line-height: 1.4;
170+
margin: 0;
171+
// Cap the visual height so a long seed doesn't blow out the card
172+
// alongside compact ones; user can hover for the full title.
173+
max-height: 4.5em;
174+
overflow: hidden;
175+
}
176+
177+
.cardStats {
178+
display: flex;
179+
gap: 12px;
180+
flex-wrap: wrap;
181+
margin: 0;
182+
}
183+
184+
.stat {
185+
display: flex;
186+
flex-direction: column;
187+
font-family: var(--mono);
188+
font-size: var(--font-3xs);
189+
}
190+
191+
.statKey {
192+
color: var(--text-3);
193+
letter-spacing: 0.06em;
194+
text-transform: uppercase;
195+
margin: 0;
196+
}
197+
198+
.statValue {
199+
color: var(--text-1);
200+
font-size: var(--font-xs);
201+
margin: 0;
202+
font-weight: 700;
203+
}
204+
205+
.runButton {
206+
margin-top: auto;
207+
background: linear-gradient(135deg, var(--amber), #c8952e);
208+
color: var(--bg-deep);
209+
border: none;
210+
padding: 8px 14px;
211+
border-radius: 4px;
212+
font-size: var(--font-xs);
213+
font-family: var(--mono);
214+
font-weight: 700;
215+
letter-spacing: 0.06em;
216+
text-transform: uppercase;
217+
cursor: pointer;
218+
transition: filter 120ms ease;
219+
220+
&:hover:not(:disabled) {
221+
filter: brightness(1.1);
222+
}
223+
224+
&:disabled {
225+
opacity: 0.5;
226+
cursor: not-allowed;
227+
}
228+
}
229+
230+
@media (max-width: 480px) {
231+
.grid {
232+
// Tighter min cell on phones so two cards fit a typical 360px
233+
// viewport without forcing horizontal scroll.
234+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
235+
}
236+
237+
.actorRow {
238+
min-width: 0;
239+
width: 100%;
240+
}
241+
}

0 commit comments

Comments
 (0)