misack Claude Opus 4.7 (1M context) commited on
Commit
ecf16e0
·
1 Parent(s): aff2409

Round 5b: frontend wiring — live progress, pause/resume, role overrides, doc upload, constraint manager

Browse files

Two parallel streams merged cleanly via separate banners in api.ts:

LIVE UX (live-ux teammate):
- LiveProgress.tsx — SSE-driven step rail (Deliberation → Critic →
Pre-mortem → Adjudication → Evaluator → World update → Next briefing),
live deliberation feed, verdict cards coloured by outcome, rAF-debounced
cost meter, paused banner, error toast, 2s reconnect-after-double-fail.
- GameView replaces the fake-ticker terminal with <LiveProgress> while
advance is in flight or status==paused. Pause button mid-advance,
Resume button when paused. Handles /advance status="paused" the same
as the SSE paused event.
- api.ts: pauseGame, resumeGame, streamUrl, PhaseEvent union.
- frontend/scripts/sse-smoke.mjs + README — stdlib-fetch SSE smoke client.

FORMS (forms teammate):
- NewGame.tsx — collapsible "Per-role model routing" panel iterating
models.roles with fallback list. Role overrides flow through createGame.
- SetupCase.tsx — drag-and-drop "Grounding documents" zone (10 docs cap,
25 MB each, pdf/docx/md/txt). Inline error rendering, refetch after
add/remove.
- GameView.tsx — collapsed "Hard constraints" panel under MetricStrip
with HARD/SOFT and SCENARIO/DOCUMENT/MANUAL badges, inline add form,
forced confirmation for non-manual deletes. Preflight checkAction
before submitAction; 409 detail.violations rendered inline above
submit and disable the button.
- api.ts: role_model_overrides on createGame, listConstraints, addConstraint,
deleteConstraint, checkAction, listDocuments, uploadDocument,
deleteDocument; ModelsResponse.roles.

Backend untouched. 288 tests pass; frontend npm run build succeeds
(293 modules, 0 TS errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

frontend/README.md CHANGED
@@ -43,6 +43,7 @@ src/
43
  App.tsx # hash router + shell
44
  main.tsx # entry
45
  components/Shell.tsx # TopBar, PhaseRail, Dossier, Panel, Spinner
 
46
  screens/GamesList.tsx # GET /games + GET /scenarios
47
  screens/NewGame.tsx # POST /games
48
  screens/GameView.tsx # GET /state, /briefing; POST /recon, /actions, /advance
@@ -60,7 +61,10 @@ src/
60
  | Game view · briefing| `GET /games/{id}/briefing`, `GET /games/{id}/state` | loaded together on mount |
61
  | Game view · recon | `POST /games/{id}/recon` | consumes 1 of 3 budget |
62
  | Game view · action | `POST /games/{id}/actions` | 3 anchors required |
63
- | Game view · advance | `POST /games/{id}/advance` | 2–5 min; live terminal polled via ticker |
 
 
 
64
  | Debrief | `GET /games/{id}/debrief` | markdown |
65
 
66
  ## Error handling
@@ -75,6 +79,21 @@ npm run preview # serve dist on :4173
75
  npm run lint # tsc --noEmit
76
  ```
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  ## Design system
79
 
80
  Tokens live in `src/styles/colors_and_type.css`. The visual language is inherited from the prototype: paper cream canvas (`--paper`), oxblood accent (`--oxblood`), Fraunces serif for headlines, Inter Tight for body, JetBrains Mono for metadata. Class names are prefixed `nh-` (Norhard) for isolation.
 
43
  App.tsx # hash router + shell
44
  main.tsx # entry
45
  components/Shell.tsx # TopBar, PhaseRail, Dossier, Panel, Spinner
46
+ components/LiveProgress.tsx # Round 5b — SSE-driven in-turn UX (step rail, deliberation feed, verdict cards, cost meter)
47
  screens/GamesList.tsx # GET /games + GET /scenarios
48
  screens/NewGame.tsx # POST /games
49
  screens/GameView.tsx # GET /state, /briefing; POST /recon, /actions, /advance
 
61
  | Game view · briefing| `GET /games/{id}/briefing`, `GET /games/{id}/state` | loaded together on mount |
62
  | Game view · recon | `POST /games/{id}/recon` | consumes 1 of 3 budget |
63
  | Game view · action | `POST /games/{id}/actions` | 3 anchors required |
64
+ | Game view · advance | `POST /games/{id}/advance` | 2–5 min; SSE-driven step rail + cost meter via `LiveProgress` |
65
+ | Game view · pause | `POST /games/{id}/pause` | mid-turn checkpoint; confirmed via `paused` SSE event |
66
+ | Game view · resume | `POST /games/{id}/resume` | re-enters `loop.advance`; same shape as advance |
67
+ | Game view · stream | `GET /games/{id}/stream` (SSE) | `LiveProgress` subscribes; `PhaseEvent` envelopes per `frontend/CONTRACT.md` |
68
  | Debrief | `GET /games/{id}/debrief` | markdown |
69
 
70
  ## Error handling
 
79
  npm run lint # tsc --noEmit
80
  ```
81
 
82
+ ## SSE smoke test (Round 5b)
83
+
84
+ `scripts/sse-smoke.mjs` connects to `/games/<id>/stream` and prints each
85
+ `PhaseEvent`'s kind + label as it lands — the same one-liner check the
86
+ backend's `wargame stream` CLI provides. Useful for sanity-checking the
87
+ backend feed without spinning up the React app.
88
+
89
+ ```bash
90
+ node scripts/sse-smoke.mjs <game-id> # default backend on :8080
91
+ node scripts/sse-smoke.mjs <game-id> http://host:port/api # override base URL
92
+ ```
93
+
94
+ The script falls back to a stdlib `fetch` parser if the optional
95
+ `eventsource` polyfill isn't installed.
96
+
97
  ## Design system
98
 
99
  Tokens live in `src/styles/colors_and_type.css`. The visual language is inherited from the prototype: paper cream canvas (`--paper`), oxblood accent (`--oxblood`), Fraunces serif for headlines, Inter Tight for body, JetBrains Mono for metadata. Class names are prefixed `nh-` (Norhard) for isolation.
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/scripts/sse-smoke.mjs ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ // sse-smoke.mjs — connect to /games/<id>/stream and print every event's
3
+ // kind + label as it lands. Mirrors the backend `wargame stream` CLI smoke.
4
+ //
5
+ // Usage:
6
+ // node scripts/sse-smoke.mjs <game-id> [base-url]
7
+ //
8
+ // `base-url` defaults to http://localhost:8080/api. Press Ctrl+C to exit.
9
+
10
+ const [, , gameId, rawBase] = process.argv;
11
+ if (!gameId) {
12
+ console.error('usage: node scripts/sse-smoke.mjs <game-id> [base-url]');
13
+ process.exit(1);
14
+ }
15
+ const base = (rawBase ?? 'http://localhost:8080/api').replace(/\/$/, '');
16
+ const url = `${base}/games/${gameId}/stream`;
17
+ console.error(`# connecting to ${url}`);
18
+
19
+ // Prefer the optional `eventsource` polyfill if it's already installed
20
+ // alongside the Vite dev deps. Otherwise fall back to a raw fetch +
21
+ // readable-stream parser — Node 18+ has streaming fetch built in.
22
+ let useFetchFallback = true;
23
+ try {
24
+ const mod = await import('eventsource');
25
+ globalThis.EventSource ??= mod.default ?? mod.EventSource ?? mod;
26
+ useFetchFallback = false;
27
+ } catch {
28
+ // eventsource not installed — use raw fetch parser.
29
+ }
30
+
31
+ if (!useFetchFallback && typeof globalThis.EventSource === 'function') {
32
+ const es = new globalThis.EventSource(url);
33
+ const KINDS = [
34
+ 'phase_started', 'phase_completed', 'deliberation_complete',
35
+ 'critic_complete', 'premortem_complete', 'verdict_complete',
36
+ 'evaluator_complete', 'world_updated', 'briefing_ready',
37
+ 'usage_tick', 'paused', 'resumed', 'error',
38
+ ];
39
+ for (const k of KINDS) {
40
+ es.addEventListener(k, (e) => {
41
+ try {
42
+ const ev = JSON.parse(e.data);
43
+ console.log(`${ev.kind.padEnd(22)} ${ev.label}`);
44
+ } catch (err) {
45
+ console.log(`${k.padEnd(22)} <unparseable: ${err.message}>`);
46
+ }
47
+ });
48
+ }
49
+ es.onerror = (e) => {
50
+ console.error('# stream error', e?.message ?? '');
51
+ };
52
+ } else {
53
+ // Raw fetch + line-buffered SSE parser. Spec: events are separated by
54
+ // a blank line; each event has lines like `event: <kind>` and
55
+ // `data: <json>`. Comments start with `:`.
56
+ const res = await fetch(url, { headers: { Accept: 'text/event-stream' } });
57
+ if (!res.ok || !res.body) {
58
+ console.error(`# stream connect failed: ${res.status} ${res.statusText}`);
59
+ process.exit(2);
60
+ }
61
+ const decoder = new TextDecoder('utf-8');
62
+ let buffer = '';
63
+ let kind = '';
64
+ let dataLines = [];
65
+ for await (const chunk of res.body) {
66
+ buffer += decoder.decode(chunk, { stream: true });
67
+ let nl;
68
+ while ((nl = buffer.indexOf('\n')) !== -1) {
69
+ const line = buffer.slice(0, nl).replace(/\r$/, '');
70
+ buffer = buffer.slice(nl + 1);
71
+ if (line === '') {
72
+ if (kind && dataLines.length) {
73
+ try {
74
+ const ev = JSON.parse(dataLines.join('\n'));
75
+ console.log(`${kind.padEnd(22)} ${ev.label}`);
76
+ } catch (err) {
77
+ console.log(`${kind.padEnd(22)} <unparseable: ${err.message}>`);
78
+ }
79
+ }
80
+ kind = ''; dataLines = [];
81
+ continue;
82
+ }
83
+ if (line.startsWith(':')) continue;
84
+ if (line.startsWith('event:')) kind = line.slice(6).trim();
85
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
86
+ }
87
+ }
88
+ }
frontend/src/api.ts CHANGED
@@ -20,11 +20,21 @@ export const api = {
20
  listModels: () => req<ModelsResponse>('/models'),
21
  listAgents: (scenario_id: string) =>
22
  req<AgentsResponse>(`/scenarios/${scenario_id}/agents`),
23
- createGame: (scenario_id: string, model?: string) =>
24
- req<{ game_id: string; turn: number; briefing: Briefing }>(`/games`, {
 
 
 
 
 
 
 
 
 
25
  method: 'POST',
26
- body: JSON.stringify(model ? { scenario_id, model } : { scenario_id }),
27
- }),
 
28
  getState: (id: string) => req<StateResponse>(`/games/${id}/state`),
29
  getBriefing: (id: string) => req<BriefingResponse>(`/games/${id}/briefing`),
30
  recon: (id: string, agent_id: string, question: string) =>
@@ -86,6 +96,58 @@ export const api = {
86
  req<ActionAcceptedResponse>(`/games/${game_id}/actions/coach/${session_id}/commit`, {
87
  method: 'POST',
88
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  };
90
 
91
  export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
@@ -95,7 +157,7 @@ export interface ActionAcceptedResponse { game_id: string; turn: number; action:
95
  export interface Scenario { scenario_id: string; company_name: string; rival_name: string; industry: string; player_role: string; agent_count: number }
96
  export interface GameSummary { id: string; scenario_id: string; turn: number; max_turns: number; status: 'active' | 'complete'; updated_at: string; llm_model?: string | null }
97
  export interface ModelInfo { id: string; display_name: string; provider: string }
98
- export interface ModelsResponse { models: ModelInfo[]; default: string }
99
  export interface AgentInfo { id: string; label: string; kind: string; posture: string }
100
  export interface AgentsResponse { scenario_id: string; agents: AgentInfo[] }
101
  export interface BriefingSection { heading: string; body: string; fog_of_war?: string | null }
@@ -116,6 +178,7 @@ export interface SetupSession {
116
  synthesized_brief: string | null;
117
  committed_scenario_id: string | null;
118
  messages: SetupMessage[];
 
119
  }
120
  export interface ScaffoldResponse {
121
  scenario_id: string;
@@ -194,3 +257,139 @@ export interface ActionCoachSession {
194
  committed: boolean;
195
  messages: CoachMessage[];
196
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  listModels: () => req<ModelsResponse>('/models'),
21
  listAgents: (scenario_id: string) =>
22
  req<AgentsResponse>(`/scenarios/${scenario_id}/agents`),
23
+ createGame: (
24
+ scenario_id: string,
25
+ model?: string,
26
+ role_model_overrides?: Record<string, string>,
27
+ ) => {
28
+ const body: Record<string, unknown> = { scenario_id };
29
+ if (model) body.model = model;
30
+ if (role_model_overrides && Object.keys(role_model_overrides).length > 0) {
31
+ body.role_model_overrides = role_model_overrides;
32
+ }
33
+ return req<{ game_id: string; turn: number; briefing: Briefing }>(`/games`, {
34
  method: 'POST',
35
+ body: JSON.stringify(body),
36
+ });
37
+ },
38
  getState: (id: string) => req<StateResponse>(`/games/${id}/state`),
39
  getBriefing: (id: string) => req<BriefingResponse>(`/games/${id}/briefing`),
40
  recon: (id: string, agent_id: string, question: string) =>
 
96
  req<ActionAcceptedResponse>(`/games/${game_id}/actions/coach/${session_id}/commit`, {
97
  method: 'POST',
98
  }),
99
+
100
+ // ---- forms (5b) ----
101
+ addConstraint: (game_id: string, body: HardConstraintInput) =>
102
+ req<HardConstraintListResponse>(`/games/${game_id}/constraints`, {
103
+ method: 'POST',
104
+ body: JSON.stringify(body),
105
+ }),
106
+ checkAction: (game_id: string, action: ActionRequest) =>
107
+ req<ConstraintCheckResponse>(`/games/${game_id}/check_action`, {
108
+ method: 'POST',
109
+ body: JSON.stringify(action),
110
+ }),
111
+ deleteConstraint: (game_id: string, constraint_id: string, force = false) =>
112
+ req<HardConstraintListResponse>(
113
+ `/games/${game_id}/constraints/${encodeURIComponent(constraint_id)}${force ? '?force=true' : ''}`,
114
+ { method: 'DELETE' },
115
+ ),
116
+ deleteDocument: (session_id: string, filename: string) =>
117
+ req<void>(
118
+ `/scenarios/setup/${session_id}/documents/${encodeURIComponent(filename)}`,
119
+ { method: 'DELETE' },
120
+ ),
121
+ listConstraints: (game_id: string) =>
122
+ req<HardConstraintListResponse>(`/games/${game_id}/constraints`),
123
+ listDocuments: (session_id: string) =>
124
+ req<DocumentListResponse>(`/scenarios/setup/${session_id}/documents`),
125
+ uploadDocument: (session_id: string, file: File) => {
126
+ const fd = new FormData();
127
+ fd.append('file', file);
128
+ // NOTE: don't set Content-Type — the browser fills in the multipart
129
+ // boundary. `req()` defaults to JSON, so we call fetch directly here.
130
+ return fetch(`${BASE}/scenarios/setup/${session_id}/documents`, {
131
+ method: 'POST',
132
+ body: fd,
133
+ }).then(async (res) => {
134
+ if (!res.ok) {
135
+ const body = await res.text().catch(() => '');
136
+ throw Object.assign(
137
+ new Error(`${res.status} ${res.statusText}: ${body}`),
138
+ { status: res.status },
139
+ );
140
+ }
141
+ return res.json() as Promise<UploadedDocument>;
142
+ });
143
+ },
144
+
145
+ // ---- live (5b) ----
146
+ pauseGame: (id: string) =>
147
+ req<StateResponse>(`/games/${id}/pause`, { method: 'POST' }),
148
+ resumeGame: (id: string) =>
149
+ req<AdvanceResponse>(`/games/${id}/resume`, { method: 'POST' }),
150
+ streamUrl: (id: string) => `${BASE}/games/${id}/stream`,
151
  };
152
 
153
  export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
 
157
  export interface Scenario { scenario_id: string; company_name: string; rival_name: string; industry: string; player_role: string; agent_count: number }
158
  export interface GameSummary { id: string; scenario_id: string; turn: number; max_turns: number; status: 'active' | 'complete'; updated_at: string; llm_model?: string | null }
159
  export interface ModelInfo { id: string; display_name: string; provider: string }
160
+ export interface ModelsResponse { models: ModelInfo[]; default: string; roles?: string[] }
161
  export interface AgentInfo { id: string; label: string; kind: string; posture: string }
162
  export interface AgentsResponse { scenario_id: string; agents: AgentInfo[] }
163
  export interface BriefingSection { heading: string; body: string; fog_of_war?: string | null }
 
178
  synthesized_brief: string | null;
179
  committed_scenario_id: string | null;
180
  messages: SetupMessage[];
181
+ doc_filenames?: string[];
182
  }
183
  export interface ScaffoldResponse {
184
  scenario_id: string;
 
257
  committed: boolean;
258
  messages: CoachMessage[];
259
  }
260
+
261
+ // ---- forms (5b) ----
262
+ // Hard constraints + uploaded documents + check-action shapes (Round 4 backend).
263
+ export type ConstraintSeverity = 'hard' | 'soft';
264
+ export type ConstraintSource = 'scenario' | 'document' | 'manual';
265
+ export interface HardConstraint {
266
+ id: string;
267
+ description: string;
268
+ rationale: string;
269
+ test_question: string;
270
+ severity: ConstraintSeverity;
271
+ source: ConstraintSource;
272
+ source_ref?: string | null;
273
+ }
274
+ export interface HardConstraintInput {
275
+ description: string;
276
+ rationale: string;
277
+ test_question: string;
278
+ severity?: ConstraintSeverity;
279
+ source_ref?: string | null;
280
+ }
281
+ export interface HardConstraintListResponse {
282
+ game_id: string;
283
+ constraints: HardConstraint[];
284
+ }
285
+ export interface ConstraintViolation {
286
+ constraint_id: string;
287
+ constraint_description: string;
288
+ reason: string;
289
+ }
290
+ export interface ConstraintCheckResult {
291
+ passes: boolean;
292
+ violations: ConstraintViolation[];
293
+ }
294
+ export interface ConstraintCheckResponse {
295
+ game_id: string;
296
+ result: ConstraintCheckResult;
297
+ }
298
+ export interface UploadedDocument {
299
+ filename: string;
300
+ bytes_stored: number;
301
+ }
302
+ export interface DocumentListResponse {
303
+ session_id: string;
304
+ documents: UploadedDocument[];
305
+ }
306
+
307
+ // ---- live (5b) ----
308
+ // PhaseEvent — server-sent events that fire on the engine bus during an
309
+ // advance. Discriminated by `kind`. Wire format documented in CONTRACT.md.
310
+ export type PhaseEventKind =
311
+ | 'phase_started'
312
+ | 'phase_completed'
313
+ | 'deliberation_complete'
314
+ | 'critic_complete'
315
+ | 'premortem_complete'
316
+ | 'verdict_complete'
317
+ | 'evaluator_complete'
318
+ | 'world_updated'
319
+ | 'briefing_ready'
320
+ | 'usage_tick'
321
+ | 'paused'
322
+ | 'resumed'
323
+ | 'error';
324
+
325
+ export type VerdictOutcome =
326
+ | 'effect_major'
327
+ | 'effect_minor'
328
+ | 'no_effect'
329
+ | 'backfires';
330
+
331
+ interface PhaseEventBase {
332
+ game_id: string;
333
+ turn: number;
334
+ label: string;
335
+ at: string;
336
+ payload: Record<string, unknown> & { dropped?: number };
337
+ }
338
+
339
+ export type PhaseEvent =
340
+ | (PhaseEventBase & { kind: 'phase_started'; payload: { dropped?: number } })
341
+ | (PhaseEventBase & { kind: 'phase_completed'; payload: { dropped?: number } })
342
+ | (PhaseEventBase & {
343
+ kind: 'deliberation_complete';
344
+ payload: { agent_id: string; expects: string; fears: string; dropped?: number };
345
+ })
346
+ | (PhaseEventBase & {
347
+ kind: 'critic_complete';
348
+ payload: { count: number; issue_count: number; dropped?: number };
349
+ })
350
+ | (PhaseEventBase & {
351
+ kind: 'premortem_complete';
352
+ payload: { count: number; dropped?: number };
353
+ })
354
+ | (PhaseEventBase & {
355
+ kind: 'verdict_complete';
356
+ payload: {
357
+ actor: string;
358
+ plausibility: number;
359
+ outcome: VerdictOutcome;
360
+ dropped?: number;
361
+ };
362
+ })
363
+ | (PhaseEventBase & {
364
+ kind: 'evaluator_complete';
365
+ payload: { scored: number; dropped?: number };
366
+ })
367
+ | (PhaseEventBase & {
368
+ kind: 'world_updated';
369
+ payload: { media_tone: string; public_narrative: string; dropped?: number };
370
+ })
371
+ | (PhaseEventBase & {
372
+ kind: 'briefing_ready';
373
+ payload: { turn: number; dropped?: number };
374
+ })
375
+ | (PhaseEventBase & {
376
+ kind: 'usage_tick';
377
+ payload: {
378
+ cost_usd_so_far: number;
379
+ input_tokens_so_far: number;
380
+ output_tokens_so_far: number;
381
+ dropped?: number;
382
+ };
383
+ })
384
+ | (PhaseEventBase & {
385
+ kind: 'paused';
386
+ payload: { turn: number; dropped?: number };
387
+ })
388
+ | (PhaseEventBase & {
389
+ kind: 'resumed';
390
+ payload: { dropped?: number };
391
+ })
392
+ | (PhaseEventBase & {
393
+ kind: 'error';
394
+ payload: Record<string, unknown> & { dropped?: number };
395
+ });
frontend/src/components/LiveProgress.tsx ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { api, PhaseEvent, VerdictOutcome } from '../api';
3
+ import { Spinner } from './Shell';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // LiveProgress — SSE-driven in-turn UX. Mounts an EventSource on the game's
7
+ // `/stream` endpoint, drives a horizontal step rail, a deliberation feed,
8
+ // verdict cards, and a live cost meter. Surfaces pause + briefing-ready
9
+ // callbacks for the parent to react to.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ type StepState = 'idle' | 'running' | 'done' | 'failed';
13
+
14
+ interface Step {
15
+ key: string;
16
+ label: string;
17
+ // Backend `phase_started`/`phase_completed` `label` values that map to
18
+ // this rail step. Match is case-insensitive substring.
19
+ match: string[];
20
+ }
21
+
22
+ const STEPS: Step[] = [
23
+ { key: 'deliberation', label: 'Deliberation', match: ['deliberation'] },
24
+ { key: 'critic', label: 'Critic', match: ['critic'] },
25
+ { key: 'premortem', label: 'Pre-mortem', match: ['premortem', 'pre-mortem'] },
26
+ { key: 'adjudication', label: 'Adjudication', match: ['adjudication'] },
27
+ { key: 'evaluator', label: 'Evaluator', match: ['evaluator'] },
28
+ { key: 'world_update', label: 'World update', match: ['world_update', 'world update'] },
29
+ { key: 'next_briefing', label: 'Next briefing', match: ['briefing'] },
30
+ ];
31
+
32
+ interface DeliberationRow {
33
+ id: string;
34
+ label: string;
35
+ expects: string;
36
+ fears: string;
37
+ }
38
+
39
+ interface VerdictCard {
40
+ id: string;
41
+ actor: string;
42
+ outcome: VerdictOutcome;
43
+ plausibility: number;
44
+ }
45
+
46
+ interface Props {
47
+ gameId: string;
48
+ onPaused?: () => void;
49
+ onBriefingReady?: (turn: number) => void;
50
+ onAdvanceComplete?: () => void;
51
+ }
52
+
53
+ export function LiveProgress({
54
+ gameId, onPaused, onBriefingReady, onAdvanceComplete,
55
+ }: Props) {
56
+ // Phase rail state — keyed by Step.key.
57
+ const [stepStates, setStepStates] = useState<Record<string, StepState>>(
58
+ () => Object.fromEntries(STEPS.map((s) => [s.key, 'idle'])),
59
+ );
60
+ const [deliberations, setDeliberations] = useState<DeliberationRow[]>([]);
61
+ const [verdicts, setVerdicts] = useState<VerdictCard[]>([]);
62
+ const [pausedAt, setPausedAt] = useState<string | null>(null);
63
+ const [streamErr, setStreamErr] = useState<string | null>(null);
64
+
65
+ // Cost meter — debounced via rAF to avoid React thrash on bursty ticks.
66
+ const [cost, setCost] = useState<number>(0);
67
+ const pendingCostRef = useRef<number | null>(null);
68
+ const rafRef = useRef<number | null>(null);
69
+
70
+ // Reconnect bookkeeping.
71
+ const failedReconnectsRef = useRef(0);
72
+ const reconnectTimerRef = useRef<number | null>(null);
73
+ const sseRef = useRef<EventSource | null>(null);
74
+
75
+ // Track whether we've seen the "next briefing" arrive so we can fire
76
+ // onAdvanceComplete once both world_updated and briefing_ready have
77
+ // landed. Either signal alone is not enough — world_updated fires
78
+ // before the next briefing is generated.
79
+ const completionRef = useRef({ world: false, briefing: false });
80
+
81
+ // Keep the latest callback refs so we don't tear down + remount the
82
+ // EventSource every time the parent rerenders.
83
+ const onPausedRef = useRef(onPaused);
84
+ const onBriefingReadyRef = useRef(onBriefingReady);
85
+ const onAdvanceCompleteRef = useRef(onAdvanceComplete);
86
+ useEffect(() => { onPausedRef.current = onPaused; }, [onPaused]);
87
+ useEffect(() => { onBriefingReadyRef.current = onBriefingReady; }, [onBriefingReady]);
88
+ useEffect(() => { onAdvanceCompleteRef.current = onAdvanceComplete; }, [onAdvanceComplete]);
89
+
90
+ function applyCostTick(usd: number) {
91
+ pendingCostRef.current = usd;
92
+ if (rafRef.current != null) return;
93
+ rafRef.current = requestAnimationFrame(() => {
94
+ rafRef.current = null;
95
+ if (pendingCostRef.current != null) {
96
+ setCost(pendingCostRef.current);
97
+ pendingCostRef.current = null;
98
+ }
99
+ });
100
+ }
101
+
102
+ function matchedStep(label: string): Step | null {
103
+ const lc = label.toLowerCase();
104
+ for (const s of STEPS) {
105
+ for (const m of s.match) {
106
+ if (lc.includes(m)) return s;
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function setStep(key: string, st: StepState) {
113
+ setStepStates((prev) => (prev[key] === st ? prev : { ...prev, [key]: st }));
114
+ }
115
+
116
+ function maybeFireComplete() {
117
+ if (completionRef.current.world && completionRef.current.briefing) {
118
+ onAdvanceCompleteRef.current?.();
119
+ }
120
+ }
121
+
122
+ function handleEvent(ev: PhaseEvent) {
123
+ switch (ev.kind) {
124
+ case 'phase_started': {
125
+ const s = matchedStep(ev.label);
126
+ if (s) setStep(s.key, 'running');
127
+ if (s?.key === 'deliberation') setDeliberations([]);
128
+ return;
129
+ }
130
+ case 'phase_completed': {
131
+ const s = matchedStep(ev.label);
132
+ if (s) setStep(s.key, 'done');
133
+ return;
134
+ }
135
+ case 'deliberation_complete': {
136
+ const p = ev.payload;
137
+ const row: DeliberationRow = {
138
+ id: `${ev.turn}:${p.agent_id}:${ev.at}`,
139
+ label: ev.label,
140
+ expects: String(p.expects ?? '').slice(0, 240),
141
+ fears: String(p.fears ?? '').slice(0, 240),
142
+ };
143
+ setDeliberations((rows) => [...rows, row]);
144
+ return;
145
+ }
146
+ case 'critic_complete':
147
+ setStep('critic', 'done');
148
+ return;
149
+ case 'premortem_complete':
150
+ setStep('premortem', 'done');
151
+ return;
152
+ case 'verdict_complete': {
153
+ const p = ev.payload;
154
+ const card: VerdictCard = {
155
+ id: `${ev.turn}:${p.actor}:${ev.at}`,
156
+ actor: p.actor,
157
+ outcome: p.outcome,
158
+ plausibility: p.plausibility,
159
+ };
160
+ setVerdicts((cards) => [...cards, card]);
161
+ return;
162
+ }
163
+ case 'evaluator_complete':
164
+ setStep('evaluator', 'done');
165
+ return;
166
+ case 'world_updated':
167
+ setStep('world_update', 'done');
168
+ completionRef.current.world = true;
169
+ maybeFireComplete();
170
+ return;
171
+ case 'briefing_ready':
172
+ setStep('next_briefing', 'done');
173
+ completionRef.current.briefing = true;
174
+ onBriefingReadyRef.current?.(ev.payload.turn);
175
+ maybeFireComplete();
176
+ return;
177
+ case 'usage_tick':
178
+ applyCostTick(Number(ev.payload.cost_usd_so_far ?? 0));
179
+ return;
180
+ case 'paused':
181
+ setPausedAt(ev.label);
182
+ onPausedRef.current?.();
183
+ return;
184
+ case 'resumed':
185
+ setPausedAt(null);
186
+ return;
187
+ case 'error':
188
+ setStreamErr(ev.label || 'Engine reported an error.');
189
+ return;
190
+ default:
191
+ return;
192
+ }
193
+ }
194
+
195
+ useEffect(() => {
196
+ let cancelled = false;
197
+
198
+ function open() {
199
+ if (cancelled) return;
200
+ const es = new EventSource(api.streamUrl(gameId));
201
+ sseRef.current = es;
202
+
203
+ const KINDS: PhaseEvent['kind'][] = [
204
+ 'phase_started', 'phase_completed', 'deliberation_complete',
205
+ 'critic_complete', 'premortem_complete', 'verdict_complete',
206
+ 'evaluator_complete', 'world_updated', 'briefing_ready',
207
+ 'usage_tick', 'paused', 'resumed', 'error',
208
+ ];
209
+ const listener = (e: MessageEvent) => {
210
+ try {
211
+ const data = JSON.parse(e.data) as PhaseEvent;
212
+ handleEvent(data);
213
+ // Reset reconnect counter on any successful event.
214
+ failedReconnectsRef.current = 0;
215
+ } catch {
216
+ // Drop — invalid JSON is best ignored.
217
+ }
218
+ };
219
+ KINDS.forEach((k) => es.addEventListener(k, listener as any));
220
+
221
+ es.onerror = () => {
222
+ // EventSource will auto-reconnect on most browsers. We treat the
223
+ // error as a hint that we should reset our local view if the
224
+ // browser fails twice in a row.
225
+ if (cancelled) return;
226
+ failedReconnectsRef.current += 1;
227
+ if (failedReconnectsRef.current >= 2) {
228
+ setStreamErr('Live stream disconnected — refresh to reconnect.');
229
+ es.close();
230
+ sseRef.current = null;
231
+ // Schedule a single retry after ~2 s. Browser handles intermediate
232
+ // reconnects; we only intervene on persistent failure.
233
+ if (reconnectTimerRef.current == null) {
234
+ reconnectTimerRef.current = window.setTimeout(() => {
235
+ reconnectTimerRef.current = null;
236
+ setStreamErr(null);
237
+ open();
238
+ }, 2000);
239
+ }
240
+ }
241
+ };
242
+ }
243
+
244
+ open();
245
+
246
+ return () => {
247
+ cancelled = true;
248
+ sseRef.current?.close();
249
+ sseRef.current = null;
250
+ if (reconnectTimerRef.current != null) {
251
+ window.clearTimeout(reconnectTimerRef.current);
252
+ reconnectTimerRef.current = null;
253
+ }
254
+ if (rafRef.current != null) {
255
+ cancelAnimationFrame(rafRef.current);
256
+ rafRef.current = null;
257
+ }
258
+ };
259
+ // eslint-disable-next-line react-hooks/exhaustive-deps
260
+ }, [gameId]);
261
+
262
+ return (
263
+ <section className="nh-live">
264
+ <div className="nh-live__hd">
265
+ <div>
266
+ <span className="eyebrow">Live · turn in flight</span>
267
+ <h3 className="nh-serif-h" style={{ margin: '4px 0 0' }}>
268
+ {pausedAt
269
+ ? `Paused — ${pausedAt}`
270
+ : 'Tracking deliberations and verdicts in real time.'}
271
+ </h3>
272
+ </div>
273
+ <div className="nh-live__cost mono" aria-label="cumulative cost">
274
+ ${cost.toFixed(4)} <span className="fg-3">spent</span>
275
+ </div>
276
+ </div>
277
+
278
+ {pausedAt && (
279
+ <div className="nh-live__paused">
280
+ <span className="eyebrow">Paused</span>
281
+ <span className="fg-2">Paused at end of {pausedAt}. Resume to continue.</span>
282
+ </div>
283
+ )}
284
+
285
+ {streamErr && (
286
+ <div className="nh-live__err mono">{streamErr}</div>
287
+ )}
288
+
289
+ <ol className="nh-live__rail" aria-label="phase progress">
290
+ {STEPS.map((s) => {
291
+ const st = stepStates[s.key];
292
+ return (
293
+ <li key={s.key} className={`nh-live__pill ${st}`}>
294
+ <span className="dot" aria-hidden />
295
+ <span className="t">{s.label}</span>
296
+ <span className="st mono">
297
+ {st === 'idle' && '—'}
298
+ {st === 'running' && <Spinner />}
299
+ {st === 'done' && '✓'}
300
+ {st === 'failed' && '!'}
301
+ </span>
302
+ </li>
303
+ );
304
+ })}
305
+ </ol>
306
+
307
+ <div className="nh-live__grid">
308
+ <div className="nh-live__panel">
309
+ <span className="eyebrow">Deliberations</span>
310
+ {deliberations.length === 0 ? (
311
+ <p className="fg-3 body-sm" style={{ marginTop: 8 }}>
312
+ {stepStates.deliberation === 'running'
313
+ ? <><Spinner /> Agents thinking…</>
314
+ : 'No agent deliberations yet.'}
315
+ </p>
316
+ ) : (
317
+ <ul className="nh-live__delibs">
318
+ {deliberations.map((d) => (
319
+ <li key={d.id}>
320
+ <div className="who mono">{d.label}</div>
321
+ {d.expects && (
322
+ <div className="ex"><span className="k mono">expects</span> {d.expects}</div>
323
+ )}
324
+ {d.fears && (
325
+ <div className="fe"><span className="k mono">fears</span> {d.fears}</div>
326
+ )}
327
+ </li>
328
+ ))}
329
+ </ul>
330
+ )}
331
+ </div>
332
+
333
+ <div className="nh-live__panel">
334
+ <span className="eyebrow">Verdicts</span>
335
+ {verdicts.length === 0 ? (
336
+ <p className="fg-3 body-sm" style={{ marginTop: 8 }}>
337
+ No verdicts adjudicated yet this turn.
338
+ </p>
339
+ ) : (
340
+ <ul className="nh-live__verdicts">
341
+ {verdicts.map((v) => (
342
+ <li key={v.id} className={`nh-live__verdict outcome-${outcomeToClass(v.outcome)}`}>
343
+ <div className="hd">
344
+ <span className="actor mono">{v.actor}</span>
345
+ <span className="pl mono">P{v.plausibility}</span>
346
+ </div>
347
+ <div className="oc mono">{outcomeLabel(v.outcome)}</div>
348
+ </li>
349
+ ))}
350
+ </ul>
351
+ )}
352
+ </div>
353
+ </div>
354
+ </section>
355
+ );
356
+ }
357
+
358
+ function outcomeToClass(o: VerdictOutcome): string {
359
+ switch (o) {
360
+ case 'effect_major': return 'major';
361
+ case 'effect_minor': return 'minor';
362
+ case 'no_effect': return 'noeffect';
363
+ case 'backfires': return 'backfire';
364
+ default: return 'noeffect';
365
+ }
366
+ }
367
+
368
+ function outcomeLabel(o: VerdictOutcome): string {
369
+ switch (o) {
370
+ case 'effect_major': return 'effect · major';
371
+ case 'effect_minor': return 'effect · minor';
372
+ case 'no_effect': return 'no effect';
373
+ case 'backfires': return 'backfires';
374
+ default: return o;
375
+ }
376
+ }
frontend/src/screens/GameView.tsx CHANGED
@@ -1,9 +1,14 @@
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
- import { api, Briefing, Anchor, AnchorKind, ScenarioMap, AgentInfo, ActionCoachSession, CoachMessage } from '../api';
 
 
 
 
5
  import { navigate } from '../App';
6
  import { Dossier, Panel, PhaseRail, Spinner } from '../components/Shell';
 
7
 
8
  interface Props {
9
  id: string;
@@ -59,6 +64,10 @@ export function GameView({ id, onMeta }: Props) {
59
  // Advance
60
  const [advanceBusy, setAdvanceBusy] = useState(false);
61
  const [advanceLog, setAdvanceLog] = useState<string[]>([]);
 
 
 
 
62
 
63
  // Orchestrator
64
  const [orchMap, setOrchMap] = useState<ScenarioMap | null>(null);
@@ -67,6 +76,13 @@ export function GameView({ id, onMeta }: Props) {
67
  const [forkBusy, setForkBusy] = useState<number | null>(null);
68
  const pollRef = useRef<number | null>(null);
69
 
 
 
 
 
 
 
 
70
  // Load state + briefing on mount / id change
71
  async function loadAll() {
72
  try {
@@ -77,6 +93,8 @@ export function GameView({ id, onMeta }: Props) {
77
  setTurn(br.turn);
78
  setMaxTurns((st?.turns?.length && 5) || 5);
79
  setPhase((st.current_phase as Phase) || 'briefing');
 
 
80
  if (st?.scenario_id) {
81
  try {
82
  const a = await api.listAgents(st.scenario_id);
@@ -95,6 +113,18 @@ export function GameView({ id, onMeta }: Props) {
95
  }
96
  useEffect(() => { loadAll(); /* eslint-disable-next-line */ }, [id]);
97
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  // Orchestrator hydrate + polling — /orchestrate/result is authoritative and
99
  // now returns partial maps while running, so clients can progressively render
100
  // vectors + branches as they land.
@@ -197,16 +227,44 @@ export function GameView({ id, onMeta }: Props) {
197
  async function submitAction() {
198
  if (!canSubmit) return;
199
  setSubmitBusy(true);
 
 
 
 
 
 
200
  try {
201
- await api.submitAction(id, {
202
- intent: intent.trim(),
203
- justification: why.trim(),
204
- anchors: anchors.filter((a) => a.detail.trim()),
205
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  setActionSent(true);
207
  setPhase('action');
208
- } catch (e) {
209
- setErr(String(e));
 
 
 
 
 
 
 
 
210
  }
211
  setSubmitBusy(false);
212
  }
@@ -245,7 +303,7 @@ export function GameView({ id, onMeta }: Props) {
245
 
246
  async function commitCoach() {
247
  if (!coachSession || coachSession.status !== 'ready' || coachBusy) return;
248
- setCoachBusy(true); setCoachErr(null);
249
  try {
250
  await api.actionCoachCommit(id, coachSession.session_id);
251
  // Mirror submitAction: flip to 'submitted' UI and let the user advance
@@ -253,8 +311,16 @@ export function GameView({ id, onMeta }: Props) {
253
  setPhase('action');
254
  // Clear the coach session locally (server still has it for reference)
255
  setCoachSession(null);
256
- } catch (e) {
257
- setCoachErr(String(e));
 
 
 
 
 
 
 
 
258
  }
259
  setCoachBusy(false);
260
  }
@@ -272,19 +338,53 @@ export function GameView({ id, onMeta }: Props) {
272
  }
273
  }, [coachSession?.messages.length, coachBusy]);
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  async function advance() {
276
  setAdvanceBusy(true);
 
277
  setPhase('deliberation');
278
  setAdvanceLog([`[${nowHM()}] Orchestrator: seeding agent deliberations for turn ${turn}…`]);
279
- const ticker = setInterval(() => {
280
- setAdvanceLog((L) => [...L, `[${nowHM()}] … still running (this takes 2–5 minutes)`]);
281
- setPhase((p) => (p === 'deliberation' ? 'adjudication' : p));
282
- }, 15000);
283
  try {
284
  const r = await api.advance(id);
285
- clearInterval(ticker);
286
  setAdvanceLog((L) => [...L, `[${nowHM()}] ✓ Turn ${r.turn_resolved} resolved`]);
 
 
 
 
 
 
 
287
  if (r.status === 'complete') {
 
288
  navigate(`/games/${id}/debrief`);
289
  return;
290
  }
@@ -302,13 +402,67 @@ export function GameView({ id, onMeta }: Props) {
302
  resetCoach();
303
  await loadAll();
304
  setAdvanceLog([]);
 
305
  } catch (e) {
306
- clearInterval(ticker);
307
  setErr(String(e));
 
308
  }
309
  setAdvanceBusy(false);
310
  }
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  if (err) {
313
  return (
314
  <Dossier tag="Error" title="Case could not be loaded">
@@ -331,11 +485,15 @@ export function GameView({ id, onMeta }: Props) {
331
  <span>Case <span className="mono">{id.slice(0, 8)}…</span></span>
332
  <span>·</span>
333
  <b>
334
- {phase === 'briefing' && 'Action phase open'}
335
- {phase === 'recon' && 'Recon phase'}
336
- {phase === 'action' && 'Action submitted — ready to advance'}
337
- {phase === 'deliberation' && 'Agents deliberating'}
338
- {phase === 'adjudication' && 'Adjudicating'}
 
 
 
 
339
  </b>
340
  </div>
341
  <h1>{briefing.headline}</h1>
@@ -351,6 +509,16 @@ export function GameView({ id, onMeta }: Props) {
351
  {/* World metrics */}
352
  {state?.world && <MetricStrip world={state.world} />}
353
 
 
 
 
 
 
 
 
 
 
 
354
  {/* MAIN GRID */}
355
  <div className="nh-grid">
356
  <div className="nh-narrative">
@@ -520,13 +688,44 @@ export function GameView({ id, onMeta }: Props) {
520
  </p>
521
  <div className="nh-submit">
522
  <span className="hint mono fg-3">Advancing is irreversible. Make sure your anchors are complete.</span>
523
- <button className="btn primary" onClick={advance} disabled={advanceBusy}>
524
- {advanceBusy ? <><Spinner /> Resolving turn…</> : 'Advance turn →'}
525
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  </div>
527
  </>
528
  ) : composerMode === 'coach' ? (
529
- <ActionCoachThread
 
 
 
 
 
 
 
530
  session={coachSession}
531
  input={coachInput}
532
  setInput={setCoachInput}
@@ -551,6 +750,7 @@ export function GameView({ id, onMeta }: Props) {
551
  }}
552
  turn={turn}
553
  />
 
554
  ) : (
555
  <>
556
  <div className="nh-formrow">
@@ -622,31 +822,43 @@ export function GameView({ id, onMeta }: Props) {
622
  )}
623
  </div>
624
 
 
 
 
 
 
 
 
625
  <div className="nh-submit">
626
  <span className="hint mono fg-3">
627
- Submitting locks your move for Turn {turn}. Advance after submission.
 
 
 
 
628
  </span>
629
  <button
630
  className="btn primary"
631
- disabled={!canSubmit || submitBusy}
632
  onClick={submitAction}
633
- >{submitBusy ? <><Spinner /> Submitting…</> : 'Submit action →'}</button>
 
 
 
 
634
  </div>
635
  </>
636
  )}
637
  </section>
638
 
639
- {/* ADVANCE LIVE TERMINAL */}
640
- {(phase === 'deliberation' || phase === 'adjudication' || advanceBusy) && advanceLog.length > 0 && (
641
- <div className="nh-terminal">
642
- <div className="nh-terminal__hd">
643
- <span className="tag mono">● LIVE · orchestrator</span>
644
- <span className="fg-3 mono">{phase === 'deliberation' ? 'deliberating' : 'adjudicating'}</span>
645
- </div>
646
- <div className="nh-terminal__body">
647
- {advanceLog.map((l, i) => <div key={i} className="line">{l}</div>)}
648
- </div>
649
- </div>
650
  )}
651
 
652
  {/* PRIOR TURN VERDICTS */}
@@ -1200,3 +1412,256 @@ function fmtCoachAt(at: string) {
1200
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
1201
  } catch { return ''; }
1202
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
+ import {
5
+ api, Briefing, Anchor, AnchorKind, ScenarioMap, AgentInfo,
6
+ ActionCoachSession, CoachMessage,
7
+ HardConstraint, ConstraintViolation, ConstraintSeverity,
8
+ } from '../api';
9
  import { navigate } from '../App';
10
  import { Dossier, Panel, PhaseRail, Spinner } from '../components/Shell';
11
+ import { LiveProgress } from '../components/LiveProgress';
12
 
13
  interface Props {
14
  id: string;
 
64
  // Advance
65
  const [advanceBusy, setAdvanceBusy] = useState(false);
66
  const [advanceLog, setAdvanceLog] = useState<string[]>([]);
67
+ // Pause / Resume (5b) — controlled by SSE events on the live stream.
68
+ const [pauseBusy, setPauseBusy] = useState(false);
69
+ const [resumeBusy, setResumeBusy] = useState(false);
70
+ const [showLive, setShowLive] = useState(false);
71
 
72
  // Orchestrator
73
  const [orchMap, setOrchMap] = useState<ScenarioMap | null>(null);
 
76
  const [forkBusy, setForkBusy] = useState<number | null>(null);
77
  const pollRef = useRef<number | null>(null);
78
 
79
+ // Hard constraints (Round 4)
80
+ const [constraints, setConstraints] = useState<HardConstraint[]>([]);
81
+ const [constraintsOpen, setConstraintsOpen] = useState(false);
82
+ const [constraintsErr, setConstraintsErr] = useState<string | null>(null);
83
+ const [violations, setViolations] = useState<ConstraintViolation[]>([]);
84
+ const [preflightBusy, setPreflightBusy] = useState(false);
85
+
86
  // Load state + briefing on mount / id change
87
  async function loadAll() {
88
  try {
 
93
  setTurn(br.turn);
94
  setMaxTurns((st?.turns?.length && 5) || 5);
95
  setPhase((st.current_phase as Phase) || 'briefing');
96
+ // Paused mid-turn — keep the actionSent UI mounted so Resume is visible.
97
+ if (st?.status === 'paused') setActionSent(true);
98
  if (st?.scenario_id) {
99
  try {
100
  const a = await api.listAgents(st.scenario_id);
 
113
  }
114
  useEffect(() => { loadAll(); /* eslint-disable-next-line */ }, [id]);
115
 
116
+ // Hard constraints — load at mount so the panel + preflight stay in sync.
117
+ async function loadConstraints() {
118
+ try {
119
+ const r = await api.listConstraints(id);
120
+ setConstraints(r.constraints);
121
+ } catch (e: any) {
122
+ // 404 is unexpected for an existing game; surface it but don't break the screen.
123
+ setConstraintsErr(String(e));
124
+ }
125
+ }
126
+ useEffect(() => { loadConstraints(); /* eslint-disable-next-line */ }, [id]);
127
+
128
  // Orchestrator hydrate + polling — /orchestrate/result is authoritative and
129
  // now returns partial maps while running, so clients can progressively render
130
  // vectors + branches as they land.
 
227
  async function submitAction() {
228
  if (!canSubmit) return;
229
  setSubmitBusy(true);
230
+ setViolations([]);
231
+ const draft = {
232
+ intent: intent.trim(),
233
+ justification: why.trim(),
234
+ anchors: anchors.filter((a) => a.detail.trim()),
235
+ };
236
  try {
237
+ // Preflight only when the game has constraints declared. Without
238
+ // constraints the backend short-circuits to passes=true anyway, but
239
+ // skipping the call locally also saves a round-trip.
240
+ if (constraints.length > 0) {
241
+ setPreflightBusy(true);
242
+ try {
243
+ const r = await api.checkAction(id, draft);
244
+ if (!r.result.passes && r.result.violations.length > 0) {
245
+ setViolations(r.result.violations);
246
+ setSubmitBusy(false);
247
+ setPreflightBusy(false);
248
+ return;
249
+ }
250
+ } finally {
251
+ setPreflightBusy(false);
252
+ }
253
+ }
254
+
255
+ await api.submitAction(id, draft);
256
  setActionSent(true);
257
  setPhase('action');
258
+ } catch (e: any) {
259
+ // 409 from /actions can carry violations the same way preflight does.
260
+ const status = e?.status;
261
+ const msg = String(e?.message ?? e);
262
+ const parsed = parseViolations(msg);
263
+ if (status === 409 && parsed.length > 0) {
264
+ setViolations(parsed);
265
+ } else {
266
+ setErr(msg);
267
+ }
268
  }
269
  setSubmitBusy(false);
270
  }
 
303
 
304
  async function commitCoach() {
305
  if (!coachSession || coachSession.status !== 'ready' || coachBusy) return;
306
+ setCoachBusy(true); setCoachErr(null); setViolations([]);
307
  try {
308
  await api.actionCoachCommit(id, coachSession.session_id);
309
  // Mirror submitAction: flip to 'submitted' UI and let the user advance
 
311
  setPhase('action');
312
  // Clear the coach session locally (server still has it for reference)
313
  setCoachSession(null);
314
+ } catch (e: any) {
315
+ const status = e?.status;
316
+ const msg = String(e?.message ?? e);
317
+ const parsed = parseViolations(msg);
318
+ if (status === 409 && parsed.length > 0) {
319
+ setViolations(parsed);
320
+ setCoachErr('Action blocked by hard constraints — see panel above the composer.');
321
+ } else {
322
+ setCoachErr(msg);
323
+ }
324
  }
325
  setCoachBusy(false);
326
  }
 
338
  }
339
  }, [coachSession?.messages.length, coachBusy]);
340
 
341
+ async function addConstraint(input: {
342
+ description: string; rationale: string; test_question: string; severity: ConstraintSeverity;
343
+ }) {
344
+ setConstraintsErr(null);
345
+ try {
346
+ const r = await api.addConstraint(id, input);
347
+ setConstraints(r.constraints);
348
+ } catch (e: any) {
349
+ setConstraintsErr(String(e?.message ?? e));
350
+ throw e;
351
+ }
352
+ }
353
+
354
+ async function deleteConstraint(constraint: HardConstraint) {
355
+ setConstraintsErr(null);
356
+ const force = constraint.source !== 'manual';
357
+ if (force) {
358
+ const ok = window.confirm(
359
+ `This constraint came from ${constraint.source === 'scenario' ? 'the scenario file' : 'an uploaded document'}.\n\nRemoving it bypasses an explicitly authored knowledge-base intent. Continue?`,
360
+ );
361
+ if (!ok) return;
362
+ }
363
+ try {
364
+ const r = await api.deleteConstraint(id, constraint.id, force);
365
+ setConstraints(r.constraints);
366
+ } catch (e: any) {
367
+ setConstraintsErr(String(e?.message ?? e));
368
+ }
369
+ }
370
+
371
  async function advance() {
372
  setAdvanceBusy(true);
373
+ setShowLive(true);
374
  setPhase('deliberation');
375
  setAdvanceLog([`[${nowHM()}] Orchestrator: seeding agent deliberations for turn ${turn}…`]);
 
 
 
 
376
  try {
377
  const r = await api.advance(id);
 
378
  setAdvanceLog((L) => [...L, `[${nowHM()}] ✓ Turn ${r.turn_resolved} resolved`]);
379
+ if (r.status === 'paused') {
380
+ // Engine hit a pause checkpoint; treat the same as a `paused` SSE
381
+ // event. Refresh state so status flips and the Resume button shows.
382
+ setAdvanceBusy(false);
383
+ await loadAll();
384
+ return;
385
+ }
386
  if (r.status === 'complete') {
387
+ setShowLive(false);
388
  navigate(`/games/${id}/debrief`);
389
  return;
390
  }
 
402
  resetCoach();
403
  await loadAll();
404
  setAdvanceLog([]);
405
+ setShowLive(false);
406
  } catch (e) {
 
407
  setErr(String(e));
408
+ setShowLive(false);
409
  }
410
  setAdvanceBusy(false);
411
  }
412
 
413
+ async function pauseAdvance() {
414
+ if (pauseBusy) return;
415
+ setPauseBusy(true);
416
+ try {
417
+ await api.pauseGame(id);
418
+ // Confirmation arrives via the `paused` SSE event; onLivePaused()
419
+ // clears the spinner and reloads state.
420
+ } catch (e) {
421
+ setErr(String(e));
422
+ setPauseBusy(false);
423
+ }
424
+ }
425
+
426
+ async function resumeAdvance() {
427
+ if (resumeBusy) return;
428
+ setResumeBusy(true);
429
+ setShowLive(true);
430
+ try {
431
+ const r = await api.resumeGame(id);
432
+ if (r.status === 'paused') {
433
+ await loadAll();
434
+ } else if (r.status === 'complete') {
435
+ setShowLive(false);
436
+ navigate(`/games/${id}/debrief`);
437
+ return;
438
+ } else {
439
+ setBriefing(r.briefing ?? null);
440
+ if (r.next_turn) setTurn(r.next_turn);
441
+ setActionSent(false);
442
+ setIntent(''); setWhy('');
443
+ setAnchors([
444
+ { kind: 'prior_analogue', detail: '' },
445
+ { kind: 'stated_capability', detail: '' },
446
+ { kind: 'resource_committed', detail: '' },
447
+ ]);
448
+ setReconLog([]);
449
+ setPhase('briefing');
450
+ resetCoach();
451
+ await loadAll();
452
+ setShowLive(false);
453
+ }
454
+ } catch (e) {
455
+ setErr(String(e));
456
+ setShowLive(false);
457
+ }
458
+ setResumeBusy(false);
459
+ }
460
+
461
+ async function onLivePaused() {
462
+ setPauseBusy(false);
463
+ try { await loadAll(); } catch { /* non-fatal */ }
464
+ }
465
+
466
  if (err) {
467
  return (
468
  <Dossier tag="Error" title="Case could not be loaded">
 
485
  <span>Case <span className="mono">{id.slice(0, 8)}…</span></span>
486
  <span>·</span>
487
  <b>
488
+ {state?.status === 'paused' ? 'Paused — ready to resume' : (
489
+ <>
490
+ {phase === 'briefing' && 'Action phase open'}
491
+ {phase === 'recon' && 'Recon phase'}
492
+ {phase === 'action' && 'Action submitted — ready to advance'}
493
+ {phase === 'deliberation' && 'Agents deliberating'}
494
+ {phase === 'adjudication' && 'Adjudicating'}
495
+ </>
496
+ )}
497
  </b>
498
  </div>
499
  <h1>{briefing.headline}</h1>
 
509
  {/* World metrics */}
510
  {state?.world && <MetricStrip world={state.world} />}
511
 
512
+ {/* HARD CONSTRAINTS (Round 4) */}
513
+ <ConstraintsPanel
514
+ constraints={constraints}
515
+ open={constraintsOpen}
516
+ err={constraintsErr}
517
+ onToggle={() => setConstraintsOpen((v) => !v)}
518
+ onAdd={addConstraint}
519
+ onDelete={deleteConstraint}
520
+ />
521
+
522
  {/* MAIN GRID */}
523
  <div className="nh-grid">
524
  <div className="nh-narrative">
 
688
  </p>
689
  <div className="nh-submit">
690
  <span className="hint mono fg-3">Advancing is irreversible. Make sure your anchors are complete.</span>
691
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
692
+ {state?.status === 'paused' ? (
693
+ <button
694
+ className="btn primary"
695
+ onClick={resumeAdvance}
696
+ disabled={resumeBusy}
697
+ >
698
+ {resumeBusy ? <><Spinner /> Resuming…</> : 'Resume turn →'}
699
+ </button>
700
+ ) : (
701
+ <>
702
+ {advanceBusy && (
703
+ <button
704
+ className="btn ghost sm"
705
+ onClick={pauseAdvance}
706
+ disabled={pauseBusy}
707
+ title="Stop at the next phase checkpoint"
708
+ >
709
+ {pauseBusy ? <><Spinner /> Pausing…</> : 'Pause'}
710
+ </button>
711
+ )}
712
+ <button className="btn primary" onClick={advance} disabled={advanceBusy}>
713
+ {advanceBusy ? <><Spinner /> Resolving turn…</> : 'Advance turn →'}
714
+ </button>
715
+ </>
716
+ )}
717
+ </div>
718
  </div>
719
  </>
720
  ) : composerMode === 'coach' ? (
721
+ <>
722
+ {violations.length > 0 && (
723
+ <ViolationsBanner
724
+ violations={violations}
725
+ onDismiss={() => setViolations([])}
726
+ />
727
+ )}
728
+ <ActionCoachThread
729
  session={coachSession}
730
  input={coachInput}
731
  setInput={setCoachInput}
 
750
  }}
751
  turn={turn}
752
  />
753
+ </>
754
  ) : (
755
  <>
756
  <div className="nh-formrow">
 
822
  )}
823
  </div>
824
 
825
+ {violations.length > 0 && (
826
+ <ViolationsBanner
827
+ violations={violations}
828
+ onDismiss={() => setViolations([])}
829
+ />
830
+ )}
831
+
832
  <div className="nh-submit">
833
  <span className="hint mono fg-3">
834
+ {violations.length > 0
835
+ ? 'Resolve the constraint violations above to unlock submit.'
836
+ : preflightBusy
837
+ ? 'Checking against hard constraints…'
838
+ : `Submitting locks your move for Turn ${turn}. Advance after submission.`}
839
  </span>
840
  <button
841
  className="btn primary"
842
+ disabled={!canSubmit || submitBusy || violations.length > 0}
843
  onClick={submitAction}
844
+ >
845
+ {submitBusy
846
+ ? <><Spinner /> {preflightBusy ? 'Checking…' : 'Submitting…'}</>
847
+ : 'Submit action →'}
848
+ </button>
849
  </div>
850
  </>
851
  )}
852
  </section>
853
 
854
+ {/* ADVANCE LIVE PROGRESS — SSE-driven step rail + deliberation feed +
855
+ verdict cards + cost meter. Mounted while an advance is in flight
856
+ OR while the game is paused mid-turn. */}
857
+ {(showLive || advanceBusy || state?.status === 'paused') && (
858
+ <LiveProgress
859
+ gameId={id}
860
+ onPaused={onLivePaused}
861
+ />
 
 
 
862
  )}
863
 
864
  {/* PRIOR TURN VERDICTS */}
 
1412
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
1413
  } catch { return ''; }
1414
  }
1415
+
1416
+ function ConstraintsPanel({
1417
+ constraints, open, err, onToggle, onAdd, onDelete,
1418
+ }: {
1419
+ constraints: HardConstraint[];
1420
+ open: boolean;
1421
+ err: string | null;
1422
+ onToggle: () => void;
1423
+ onAdd: (input: { description: string; rationale: string; test_question: string; severity: ConstraintSeverity }) => Promise<void>;
1424
+ onDelete: (constraint: HardConstraint) => void | Promise<void>;
1425
+ }) {
1426
+ const [adding, setAdding] = useState(false);
1427
+ const [draft, setDraft] = useState({
1428
+ description: '',
1429
+ rationale: '',
1430
+ test_question: '',
1431
+ severity: 'hard' as ConstraintSeverity,
1432
+ });
1433
+ const [busy, setBusy] = useState(false);
1434
+ const [localErr, setLocalErr] = useState<string | null>(null);
1435
+
1436
+ const draftValid =
1437
+ draft.description.trim().length >= 10 &&
1438
+ draft.rationale.trim().length >= 5 &&
1439
+ draft.test_question.trim().length >= 10;
1440
+
1441
+ async function submit() {
1442
+ if (!draftValid || busy) return;
1443
+ setBusy(true); setLocalErr(null);
1444
+ try {
1445
+ await onAdd({
1446
+ description: draft.description.trim(),
1447
+ rationale: draft.rationale.trim(),
1448
+ test_question: draft.test_question.trim(),
1449
+ severity: draft.severity,
1450
+ });
1451
+ setDraft({ description: '', rationale: '', test_question: '', severity: 'hard' });
1452
+ setAdding(false);
1453
+ } catch (e: any) {
1454
+ setLocalErr(String(e?.message ?? e));
1455
+ }
1456
+ setBusy(false);
1457
+ }
1458
+
1459
+ const hardCount = constraints.filter((c) => c.severity === 'hard').length;
1460
+ const softCount = constraints.length - hardCount;
1461
+
1462
+ return (
1463
+ <section className="nh-constraints">
1464
+ <button
1465
+ type="button"
1466
+ className="nh-constraints__hd"
1467
+ onClick={onToggle}
1468
+ aria-expanded={open}
1469
+ >
1470
+ <div>
1471
+ <span className="eyebrow">Hard constraints</span>
1472
+ <div className="hd">
1473
+ {constraints.length === 0
1474
+ ? 'No constraints declared on this game'
1475
+ : `${hardCount} hard${softCount > 0 ? ` · ${softCount} soft` : ''} · enforced at submit time`}
1476
+ </div>
1477
+ </div>
1478
+ <span className="mono fg-3">{open ? 'Hide ↑' : 'Show ↓'}</span>
1479
+ </button>
1480
+
1481
+ {open && (
1482
+ <div className="nh-constraints__body">
1483
+ {err && <p className="nh-constraints__err">{err}</p>}
1484
+ {constraints.length === 0 ? (
1485
+ <p className="fg-3" style={{ margin: '0 0 12px' }}>
1486
+ No hard constraints are active. Add one below, or upload grounding documents at setup time
1487
+ and let the setup agent extract them automatically.
1488
+ </p>
1489
+ ) : (
1490
+ <ul className="nh-constraint-list">
1491
+ {constraints.map((c) => (
1492
+ <ConstraintRow key={c.id} c={c} onDelete={() => onDelete(c)} />
1493
+ ))}
1494
+ </ul>
1495
+ )}
1496
+
1497
+ {adding ? (
1498
+ <div className="nh-constraint-add">
1499
+ <div className="nh-formrow">
1500
+ <label>Description <span className="ct mono">{draft.description.length} / 400</span></label>
1501
+ <textarea
1502
+ value={draft.description}
1503
+ maxLength={400}
1504
+ rows={2}
1505
+ onChange={(e) => setDraft({ ...draft, description: e.target.value })}
1506
+ placeholder="e.g. The Helvetia MSA forbids price increases above 4% YoY without 90-day notice."
1507
+ />
1508
+ </div>
1509
+ <div className="nh-formrow">
1510
+ <label>Rationale <span className="ct mono">{draft.rationale.length} / 400</span></label>
1511
+ <textarea
1512
+ value={draft.rationale}
1513
+ maxLength={400}
1514
+ rows={2}
1515
+ onChange={(e) => setDraft({ ...draft, rationale: e.target.value })}
1516
+ placeholder="e.g. Breach triggers a EUR 12M penalty clause and a public arbitration filing."
1517
+ />
1518
+ </div>
1519
+ <div className="nh-formrow">
1520
+ <label>Test question <span className="ct mono">{draft.test_question.length} / 300</span></label>
1521
+ <textarea
1522
+ value={draft.test_question}
1523
+ maxLength={300}
1524
+ rows={2}
1525
+ onChange={(e) => setDraft({ ...draft, test_question: e.target.value })}
1526
+ placeholder='e.g. "Does this action raise prices on the Helvetia contract by more than 4% without 90-day notice?"'
1527
+ />
1528
+ </div>
1529
+ <div className="nh-formrow">
1530
+ <label>Severity</label>
1531
+ <select
1532
+ value={draft.severity}
1533
+ onChange={(e) => setDraft({ ...draft, severity: e.target.value as ConstraintSeverity })}
1534
+ style={{ font: 'inherit' }}
1535
+ >
1536
+ <option value="hard">Hard - block any action that breaches</option>
1537
+ <option value="soft">Soft - flag but allow</option>
1538
+ </select>
1539
+ </div>
1540
+ {localErr && <p className="nh-constraints__err">{localErr}</p>}
1541
+ <div className="nh-constraint-add__ft">
1542
+ <button
1543
+ type="button"
1544
+ className="btn ghost sm"
1545
+ onClick={() => { setAdding(false); setLocalErr(null); }}
1546
+ disabled={busy}
1547
+ >Cancel</button>
1548
+ <button
1549
+ type="button"
1550
+ className="btn primary"
1551
+ onClick={submit}
1552
+ disabled={!draftValid || busy}
1553
+ >
1554
+ {busy ? <><Spinner /> Adding…</> : 'Add constraint →'}
1555
+ </button>
1556
+ </div>
1557
+ </div>
1558
+ ) : (
1559
+ <div className="nh-constraints__addbar">
1560
+ <span className="hint mono fg-3">
1561
+ Hard constraints block any action that breaches them at submission time.
1562
+ </span>
1563
+ <button type="button" className="btn ghost sm" onClick={() => setAdding(true)}>
1564
+ + Add constraint
1565
+ </button>
1566
+ </div>
1567
+ )}
1568
+ </div>
1569
+ )}
1570
+ </section>
1571
+ );
1572
+ }
1573
+
1574
+ function ConstraintRow({
1575
+ c, onDelete,
1576
+ }: {
1577
+ c: HardConstraint;
1578
+ onDelete: () => void | Promise<void>;
1579
+ }) {
1580
+ const [expanded, setExpanded] = useState(false);
1581
+ const long = c.description.length > 120;
1582
+ const display = expanded || !long ? c.description : `${c.description.slice(0, 117)}…`;
1583
+ return (
1584
+ <li className="nh-constraint">
1585
+ <div className="nh-constraint__row">
1586
+ <span className={`nh-badge sev sev-${c.severity}`}>{c.severity.toUpperCase()}</span>
1587
+ <span className={`nh-badge src src-${c.source}`}>{c.source}</span>
1588
+ <button
1589
+ type="button"
1590
+ className="nh-constraint__desc"
1591
+ onClick={() => long && setExpanded((v) => !v)}
1592
+ title={long ? (expanded ? 'Click to collapse' : 'Click to expand') : c.description}
1593
+ >
1594
+ {display}
1595
+ </button>
1596
+ <button
1597
+ type="button"
1598
+ className="nh-constraint__rm"
1599
+ onClick={onDelete}
1600
+ title="Remove constraint"
1601
+ >
1602
+ remove
1603
+ </button>
1604
+ </div>
1605
+ {expanded && (
1606
+ <div className="nh-constraint__detail">
1607
+ <div><span className="eyebrow">Rationale</span> {c.rationale}</div>
1608
+ <div><span className="eyebrow">Test question</span> <span className="mono">{c.test_question}</span></div>
1609
+ {c.source_ref && (
1610
+ <div><span className="eyebrow">Source ref</span> <span className="mono">{c.source_ref}</span></div>
1611
+ )}
1612
+ </div>
1613
+ )}
1614
+ </li>
1615
+ );
1616
+ }
1617
+
1618
+ function ViolationsBanner({
1619
+ violations, onDismiss,
1620
+ }: {
1621
+ violations: ConstraintViolation[];
1622
+ onDismiss: () => void;
1623
+ }) {
1624
+ return (
1625
+ <div className="nh-violations" role="alert">
1626
+ <div className="nh-violations__hd">
1627
+ <span className="eyebrow">Hard constraint violation · action blocked</span>
1628
+ <button type="button" className="nh-violations__rm" onClick={onDismiss}>dismiss</button>
1629
+ </div>
1630
+ <ul>
1631
+ {violations.map((v, i) => (
1632
+ <li key={i}>
1633
+ <div className="nh-violations__cd">{v.constraint_description}</div>
1634
+ <div className="nh-violations__rs">{v.reason}</div>
1635
+ </li>
1636
+ ))}
1637
+ </ul>
1638
+ <p className="hint mono fg-3" style={{ marginTop: 6 }}>
1639
+ Revise the action above and the next submit attempt will be re-checked.
1640
+ </p>
1641
+ </div>
1642
+ );
1643
+ }
1644
+
1645
+ // Parse the JSON-ish detail body the FastAPI handler emits on a 409 hard-constraint
1646
+ // rejection. The error message looks like:
1647
+ // `409 Conflict: {"detail":{"message":"…","violations":[{...}, ...]}}`
1648
+ function parseViolations(message: string): ConstraintViolation[] {
1649
+ if (!message) return [];
1650
+ const colon = message.indexOf(':');
1651
+ const tail = colon >= 0 ? message.slice(colon + 1).trim() : message;
1652
+ try {
1653
+ const parsed = JSON.parse(tail);
1654
+ const detail = parsed?.detail ?? parsed;
1655
+ const arr = detail?.violations;
1656
+ if (!Array.isArray(arr)) return [];
1657
+ return arr
1658
+ .filter((v: any) => v && typeof v === 'object')
1659
+ .map((v: any) => ({
1660
+ constraint_id: String(v.constraint_id ?? ''),
1661
+ constraint_description: String(v.constraint_description ?? ''),
1662
+ reason: String(v.reason ?? ''),
1663
+ }));
1664
+ } catch {
1665
+ return [];
1666
+ }
1667
+ }
frontend/src/screens/NewGame.tsx CHANGED
@@ -2,11 +2,27 @@ import { useEffect, useState } from 'react';
2
  import { api, Scenario, ModelInfo } from '../api';
3
  import { navigate } from '../App';
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  export function NewGame() {
6
  const [scenarios, setScenarios] = useState<Scenario[] | null>(null);
7
  const [picked, setPicked] = useState<string>('');
8
  const [models, setModels] = useState<ModelInfo[]>([]);
9
  const [model, setModel] = useState<string>('');
 
 
 
10
  const [busy, setBusy] = useState(false);
11
  const [err, setErr] = useState<string | null>(null);
12
 
@@ -18,13 +34,33 @@ export function NewGame() {
18
  api.listModels().then((r) => {
19
  setModels(r.models);
20
  setModel(r.default);
 
 
 
 
 
 
 
21
  }).catch(() => { /* non-fatal — backend will use its default */ });
22
  }, []);
23
 
 
 
 
 
 
 
 
 
 
24
  async function create() {
25
  setBusy(true); setErr(null);
26
  try {
27
- const r = await api.createGame(picked, model || undefined);
 
 
 
 
28
  navigate(`/games/${r.game_id}`);
29
  } catch (e) { setErr(String(e)); setBusy(false); }
30
  }
@@ -101,6 +137,100 @@ export function NewGame() {
101
  {busy ? 'Generating briefing…' : 'Open case →'}
102
  </button>
103
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  </>
105
  );
106
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import { api, Scenario, ModelInfo } from '../api';
3
  import { navigate } from '../App';
4
 
5
+ // Fallback role list when an older backend returns ModelsResponse without
6
+ // `roles[]`. Mirrors `wargame.agents.roles.CANONICAL_ROLES`.
7
+ const FALLBACK_ROLES = ['control', 'agent', 'rival', 'critic', 'premortem', 'evaluator'];
8
+
9
+ const ROLE_BLURBS: Record<string, string> = {
10
+ control: 'Briefings, adjudication, world updates',
11
+ agent: 'Advisory voices in the room',
12
+ rival: 'Rival CEO planner',
13
+ critic: 'Critic agent',
14
+ premortem: 'Pre-mortem agent',
15
+ evaluator: 'Independent rubric scorer',
16
+ };
17
+
18
  export function NewGame() {
19
  const [scenarios, setScenarios] = useState<Scenario[] | null>(null);
20
  const [picked, setPicked] = useState<string>('');
21
  const [models, setModels] = useState<ModelInfo[]>([]);
22
  const [model, setModel] = useState<string>('');
23
+ const [roles, setRoles] = useState<string[]>([]);
24
+ const [overrides, setOverrides] = useState<Record<string, string>>({});
25
+ const [overridesOpen, setOverridesOpen] = useState(false);
26
  const [busy, setBusy] = useState(false);
27
  const [err, setErr] = useState<string | null>(null);
28
 
 
34
  api.listModels().then((r) => {
35
  setModels(r.models);
36
  setModel(r.default);
37
+ if (r.roles && r.roles.length > 0) {
38
+ setRoles(r.roles);
39
+ } else {
40
+ // eslint-disable-next-line no-console
41
+ console.warn('GET /models did not return roles[]; using fallback list.');
42
+ setRoles(FALLBACK_ROLES);
43
+ }
44
  }).catch(() => { /* non-fatal — backend will use its default */ });
45
  }, []);
46
 
47
+ function setRoleOverride(role: string, modelId: string) {
48
+ setOverrides((prev) => {
49
+ const next = { ...prev };
50
+ if (!modelId) delete next[role];
51
+ else next[role] = modelId;
52
+ return next;
53
+ });
54
+ }
55
+
56
  async function create() {
57
  setBusy(true); setErr(null);
58
  try {
59
+ const r = await api.createGame(
60
+ picked,
61
+ model || undefined,
62
+ Object.keys(overrides).length > 0 ? overrides : undefined,
63
+ );
64
  navigate(`/games/${r.game_id}`);
65
  } catch (e) { setErr(String(e)); setBusy(false); }
66
  }
 
137
  {busy ? 'Generating briefing…' : 'Open case →'}
138
  </button>
139
  </div>
140
+
141
+ {models.length > 0 && roles.length > 0 && (
142
+ <RoleOverrides
143
+ roles={roles}
144
+ models={models}
145
+ overrides={overrides}
146
+ open={overridesOpen}
147
+ onToggle={() => setOverridesOpen((v) => !v)}
148
+ onSet={setRoleOverride}
149
+ onReset={() => setOverrides({})}
150
+ gameDefault={model}
151
+ />
152
+ )}
153
  </>
154
  );
155
  }
156
+
157
+ function RoleOverrides({
158
+ roles, models, overrides, open, onToggle, onSet, onReset, gameDefault,
159
+ }: {
160
+ roles: string[];
161
+ models: ModelInfo[];
162
+ overrides: Record<string, string>;
163
+ open: boolean;
164
+ onToggle: () => void;
165
+ onSet: (role: string, modelId: string) => void;
166
+ onReset: () => void;
167
+ gameDefault: string;
168
+ }) {
169
+ const overrideCount = Object.keys(overrides).length;
170
+ return (
171
+ <section className="nh-role-overrides">
172
+ <button
173
+ type="button"
174
+ className="nh-role-overrides__hd"
175
+ onClick={onToggle}
176
+ aria-expanded={open}
177
+ >
178
+ <div>
179
+ <span className="eyebrow">Per-role model routing</span>
180
+ <div className="hd">
181
+ {overrideCount === 0
182
+ ? 'All roles use the game default'
183
+ : `${overrideCount} role${overrideCount === 1 ? '' : 's'} routed to a different model`}
184
+ </div>
185
+ </div>
186
+ <span className="mono fg-3">{open ? 'Hide ↑' : 'Configure ↓'}</span>
187
+ </button>
188
+ {open && (
189
+ <div className="nh-role-overrides__body">
190
+ <p className="fg-3" style={{ margin: '0 0 12px' }}>
191
+ Each role can be pinned to a specific model — leave on the default to use{' '}
192
+ <span className="mono">{gameDefault || '(server default)'}</span>. Useful for routing
193
+ cheap planning to small models and reserving the flagship for adjudication.
194
+ </p>
195
+ <div className="nh-role-overrides__rows">
196
+ {roles.map((role) => {
197
+ const current = overrides[role] ?? '';
198
+ return (
199
+ <div className="nh-role-row" key={role}>
200
+ <div className="nh-role-row__name">
201
+ <span className="code mono">{role}</span>
202
+ {ROLE_BLURBS[role] && (
203
+ <span className="hint">{ROLE_BLURBS[role]}</span>
204
+ )}
205
+ </div>
206
+ <select
207
+ value={current}
208
+ onChange={(e) => onSet(role, e.target.value)}
209
+ style={{ font: 'inherit' }}
210
+ >
211
+ <option value="">(use game default)</option>
212
+ {models.map((m) => (
213
+ <option key={m.id} value={m.id}>
214
+ {m.display_name} · {m.provider}
215
+ </option>
216
+ ))}
217
+ </select>
218
+ </div>
219
+ );
220
+ })}
221
+ </div>
222
+ <div className="nh-role-overrides__ft">
223
+ <button
224
+ type="button"
225
+ className="btn ghost sm"
226
+ onClick={onReset}
227
+ disabled={overrideCount === 0}
228
+ >
229
+ Reset to defaults
230
+ </button>
231
+ </div>
232
+ </div>
233
+ )}
234
+ </section>
235
+ );
236
+ }
frontend/src/screens/SetupCase.tsx CHANGED
@@ -1,17 +1,32 @@
1
  import { useEffect, useRef, useState } from 'react';
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
- import { api, SetupSession, SetupMessage } from '../api';
5
  import { navigate } from '../App';
6
  import { Spinner } from '../components/Shell';
7
 
8
  type Phase = 'intro' | 'chat' | 'committing';
9
 
 
 
 
 
10
  function defaultScenarioId(): string {
11
  // e.g. case_lx4q3p9 — matches ^[a-z][a-z0-9_]{2,59}$
12
  return `case_${Date.now().toString(36)}`;
13
  }
14
 
 
 
 
 
 
 
 
 
 
 
 
15
  export function SetupCase() {
16
  const [phase, setPhase] = useState<Phase>('intro');
17
  const [scenarioId, setScenarioId] = useState(defaultScenarioId);
@@ -21,6 +36,9 @@ export function SetupCase() {
21
  const [busy, setBusy] = useState(false);
22
  const [err, setErr] = useState<string | null>(null);
23
  const threadRef = useRef<HTMLDivElement | null>(null);
 
 
 
24
 
25
  useEffect(() => {
26
  if (threadRef.current) {
@@ -57,6 +75,67 @@ export function SetupCase() {
57
  setBusy(false);
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  async function commitAndLaunch() {
61
  if (!session) return;
62
  setPhase('committing'); setErr(null);
@@ -160,6 +239,16 @@ export function SetupCase() {
160
 
161
  {err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
162
 
 
 
 
 
 
 
 
 
 
 
163
  <section className="nh-chat-app">
164
  <div className="nh-chat">
165
  <div className="nh-chat__thread" ref={threadRef}>
@@ -268,3 +357,106 @@ function fmtAt(at: string) {
268
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
269
  } catch { return ''; }
270
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useEffect, useRef, useState } from 'react';
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
+ import { api, SetupSession, SetupMessage, UploadedDocument } from '../api';
5
  import { navigate } from '../App';
6
  import { Spinner } from '../components/Shell';
7
 
8
  type Phase = 'intro' | 'chat' | 'committing';
9
 
10
+ const DOC_MAX_BYTES = 25 * 1024 * 1024; // 25 MiB — mirrors backend cap
11
+ const DOC_MAX_COUNT = 10;
12
+ const DOC_ALLOWED_EXT = ['.pdf', '.docx', '.md', '.txt'];
13
+
14
  function defaultScenarioId(): string {
15
  // e.g. case_lx4q3p9 — matches ^[a-z][a-z0-9_]{2,59}$
16
  return `case_${Date.now().toString(36)}`;
17
  }
18
 
19
+ function fmtBytes(n: number): string {
20
+ if (n < 1024) return `${n} B`;
21
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
22
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
23
+ }
24
+
25
+ function isAllowedExt(name: string): boolean {
26
+ const lower = name.toLowerCase();
27
+ return DOC_ALLOWED_EXT.some((ext) => lower.endsWith(ext));
28
+ }
29
+
30
  export function SetupCase() {
31
  const [phase, setPhase] = useState<Phase>('intro');
32
  const [scenarioId, setScenarioId] = useState(defaultScenarioId);
 
36
  const [busy, setBusy] = useState(false);
37
  const [err, setErr] = useState<string | null>(null);
38
  const threadRef = useRef<HTMLDivElement | null>(null);
39
+ const [docs, setDocs] = useState<UploadedDocument[]>([]);
40
+ const [docErr, setDocErr] = useState<string | null>(null);
41
+ const [docBusy, setDocBusy] = useState(false);
42
 
43
  useEffect(() => {
44
  if (threadRef.current) {
 
75
  setBusy(false);
76
  }
77
 
78
+ async function refreshDocs(sessionId: string) {
79
+ try {
80
+ const r = await api.listDocuments(sessionId);
81
+ setDocs(r.documents);
82
+ } catch (e) {
83
+ // Non-fatal — leave the prior list visible.
84
+ // eslint-disable-next-line no-console
85
+ console.warn('listDocuments failed', e);
86
+ }
87
+ }
88
+
89
+ async function uploadFiles(files: FileList | File[]) {
90
+ if (!session) return;
91
+ setDocErr(null);
92
+ const list = Array.from(files);
93
+ if (list.length === 0) return;
94
+ if (docs.length + list.length > DOC_MAX_COUNT) {
95
+ setDocErr(`Cap is ${DOC_MAX_COUNT} documents per session — currently ${docs.length} uploaded.`);
96
+ return;
97
+ }
98
+ setDocBusy(true);
99
+ try {
100
+ for (const file of list) {
101
+ if (!isAllowedExt(file.name)) {
102
+ setDocErr(`"${file.name}": only ${DOC_ALLOWED_EXT.join(', ')} are accepted.`);
103
+ continue;
104
+ }
105
+ if (file.size > DOC_MAX_BYTES) {
106
+ setDocErr(`"${file.name}": exceeds the 25 MB cap (${fmtBytes(file.size)}).`);
107
+ continue;
108
+ }
109
+ try {
110
+ await api.uploadDocument(session.session_id, file);
111
+ } catch (e: any) {
112
+ setDocErr(`"${file.name}": ${e?.message ?? String(e)}`);
113
+ }
114
+ }
115
+ await refreshDocs(session.session_id);
116
+ } finally {
117
+ setDocBusy(false);
118
+ }
119
+ }
120
+
121
+ async function removeDoc(filename: string) {
122
+ if (!session) return;
123
+ setDocErr(null);
124
+ try {
125
+ await api.deleteDocument(session.session_id, filename);
126
+ await refreshDocs(session.session_id);
127
+ } catch (e: any) {
128
+ setDocErr(`Could not remove "${filename}": ${e?.message ?? String(e)}`);
129
+ }
130
+ }
131
+
132
+ // Hydrate the doc list whenever a session arrives or its id changes.
133
+ useEffect(() => {
134
+ if (session?.session_id) {
135
+ refreshDocs(session.session_id);
136
+ }
137
+ }, [session?.session_id]);
138
+
139
  async function commitAndLaunch() {
140
  if (!session) return;
141
  setPhase('committing'); setErr(null);
 
239
 
240
  {err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
241
 
242
+ {session && phase !== 'committing' && (
243
+ <DocUploadPanel
244
+ docs={docs}
245
+ err={docErr}
246
+ busy={docBusy}
247
+ onUpload={uploadFiles}
248
+ onRemove={removeDoc}
249
+ />
250
+ )}
251
+
252
  <section className="nh-chat-app">
253
  <div className="nh-chat">
254
  <div className="nh-chat__thread" ref={threadRef}>
 
357
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
358
  } catch { return ''; }
359
  }
360
+
361
+ function DocUploadPanel({
362
+ docs, err, busy, onUpload, onRemove,
363
+ }: {
364
+ docs: UploadedDocument[];
365
+ err: string | null;
366
+ busy: boolean;
367
+ onUpload: (files: FileList | File[]) => void | Promise<void>;
368
+ onRemove: (filename: string) => void | Promise<void>;
369
+ }) {
370
+ const [drag, setDrag] = useState(false);
371
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
372
+ const remaining = DOC_MAX_COUNT - docs.length;
373
+
374
+ return (
375
+ <section className="nh-doc-upload">
376
+ <div className="nh-doc-upload__hd">
377
+ <div>
378
+ <span className="eyebrow">Grounding documents</span>
379
+ <div className="hd">
380
+ Drop in source materials — the setup agent extracts hard constraints from these at commit time.
381
+ </div>
382
+ </div>
383
+ <span className="mono fg-3">{docs.length} / {DOC_MAX_COUNT}</span>
384
+ </div>
385
+
386
+ <div
387
+ className={`nh-doc-drop ${drag ? 'over' : ''} ${remaining <= 0 || busy ? 'disabled' : ''}`}
388
+ onDragOver={(e) => {
389
+ e.preventDefault();
390
+ if (remaining > 0 && !busy) setDrag(true);
391
+ }}
392
+ onDragLeave={() => setDrag(false)}
393
+ onDrop={(e) => {
394
+ e.preventDefault();
395
+ setDrag(false);
396
+ if (remaining <= 0 || busy) return;
397
+ if (e.dataTransfer?.files?.length) {
398
+ onUpload(e.dataTransfer.files);
399
+ }
400
+ }}
401
+ onClick={() => {
402
+ if (remaining > 0 && !busy) fileInputRef.current?.click();
403
+ }}
404
+ role="button"
405
+ tabIndex={0}
406
+ onKeyDown={(e) => {
407
+ if ((e.key === 'Enter' || e.key === ' ') && remaining > 0 && !busy) {
408
+ e.preventDefault();
409
+ fileInputRef.current?.click();
410
+ }
411
+ }}
412
+ >
413
+ <input
414
+ ref={fileInputRef}
415
+ type="file"
416
+ multiple
417
+ accept={DOC_ALLOWED_EXT.join(',')}
418
+ style={{ display: 'none' }}
419
+ onChange={(e) => {
420
+ if (e.target.files?.length) {
421
+ onUpload(e.target.files);
422
+ e.target.value = '';
423
+ }
424
+ }}
425
+ />
426
+ <div className="nh-doc-drop__cta">
427
+ {busy ? (
428
+ <><Spinner /> Uploading…</>
429
+ ) : remaining <= 0 ? (
430
+ <span>Limit reached — remove a document to upload another.</span>
431
+ ) : (
432
+ <>
433
+ <strong>Drop documents here</strong> or click to browse —
434
+ <span className="mono"> {DOC_ALLOWED_EXT.join(' · ')}</span> · max 25 MB each.
435
+ </>
436
+ )}
437
+ </div>
438
+ </div>
439
+
440
+ {err && <p className="nh-doc-upload__err">{err}</p>}
441
+
442
+ {docs.length > 0 && (
443
+ <ul className="nh-doc-list">
444
+ {docs.map((d) => (
445
+ <li key={d.filename}>
446
+ <span className="nm mono">{d.filename}</span>
447
+ <span className="sz mono fg-3">{fmtBytes(d.bytes_stored)}</span>
448
+ <button
449
+ type="button"
450
+ className="rm"
451
+ onClick={() => onRemove(d.filename)}
452
+ disabled={busy}
453
+ >
454
+ remove
455
+ </button>
456
+ </li>
457
+ ))}
458
+ </ul>
459
+ )}
460
+ </section>
461
+ );
462
+ }
frontend/src/styles/app.css CHANGED
@@ -940,3 +940,231 @@ input, textarea, select { font: inherit; color: inherit; }
940
 
941
  .nh-orch__ft { margin-top: 16px; padding-top: 10px; border-top: 1px dashed var(--vellum);
942
  display: flex; justify-content: flex-end; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
940
 
941
  .nh-orch__ft { margin-top: 16px; padding-top: 10px; border-top: 1px dashed var(--vellum);
942
  display: flex; justify-content: flex-end; }
943
+
944
+ /* ============================================================
945
+ LiveProgress (5b) — SSE-driven in-turn UX
946
+ ============================================================ */
947
+ .nh-live { background: var(--paper); border: 1px solid var(--ink); border-radius: 2px;
948
+ padding: 24px; margin-bottom: 32px; position: relative;
949
+ box-shadow: 0 0 0 6px var(--dossier), var(--shadow-sheet); }
950
+ .nh-live::before { content: ""; position: absolute; top: -1px; left: -1px; right: -1px;
951
+ height: 2px; background: var(--oxblood); }
952
+ .nh-live__hd { display: flex; justify-content: space-between; align-items: center;
953
+ gap: 16px; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--vellum); }
954
+ .nh-live__hd .eyebrow { display: block; }
955
+ .nh-live__cost { font-size: 13px; color: var(--ink); white-space: nowrap;
956
+ font-variant-numeric: tabular-nums; }
957
+ .nh-live__cost .fg-3 { font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }
958
+
959
+ .nh-live__paused { display: flex; gap: 12px; align-items: baseline;
960
+ background: var(--dossier); border: 1px solid var(--amber); border-radius: 2px;
961
+ padding: 10px 14px; margin-bottom: 16px; }
962
+ .nh-live__paused .eyebrow { color: var(--amber); }
963
+
964
+ .nh-live__err { padding: 10px 14px; background: rgba(122,31,31,0.06);
965
+ border: 1px dashed var(--oxblood); border-radius: 2px; color: var(--oxblood);
966
+ font-size: 12px; margin-bottom: 14px; }
967
+
968
+ .nh-live__rail { list-style: none; padding: 0; margin: 0 0 20px;
969
+ display: flex; gap: 0; border: 1px solid var(--vellum); border-radius: 2px;
970
+ background: var(--dossier); overflow: hidden; flex-wrap: wrap; }
971
+ .nh-live__pill { flex: 1 1 140px; min-width: 120px;
972
+ display: flex; align-items: center; gap: 8px; padding: 12px 14px;
973
+ border-right: 1px solid var(--vellum); position: relative;
974
+ font-size: 12.5px; color: var(--slate); }
975
+ .nh-live__pill:last-child { border-right: 0; }
976
+ .nh-live__pill .dot { width: 7px; height: 7px; border-radius: 50%;
977
+ background: var(--vellum); flex: 0 0 auto; }
978
+ .nh-live__pill .t { font-weight: 500; }
979
+ .nh-live__pill .st { font-size: 11px; margin-left: auto; color: var(--slate); }
980
+ .nh-live__pill.running { background: var(--paper); color: var(--ink);
981
+ box-shadow: inset 0 -2px 0 var(--oxblood); }
982
+ .nh-live__pill.running .dot { background: var(--oxblood);
983
+ box-shadow: 0 0 0 3px rgba(122,31,31,0.18); animation: nh-live-pulse 1.6s ease-in-out infinite; }
984
+ .nh-live__pill.running .st { color: var(--oxblood); }
985
+ .nh-live__pill.done { color: var(--graphite); }
986
+ .nh-live__pill.done .dot { background: var(--moss); }
987
+ .nh-live__pill.done .st { color: var(--moss); }
988
+ .nh-live__pill.failed { color: var(--oxblood); }
989
+ .nh-live__pill.failed .dot { background: var(--oxblood); }
990
+ .nh-live__pill.failed .st { color: var(--oxblood); }
991
+ @keyframes nh-live-pulse {
992
+ 0%, 100% { box-shadow: 0 0 0 3px rgba(122,31,31,0.18); }
993
+ 50% { box-shadow: 0 0 0 6px rgba(122,31,31,0.05); }
994
+ }
995
+
996
+ .nh-live__grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 16px; }
997
+ @media (max-width: 900px) { .nh-live__grid { grid-template-columns: 1fr; } }
998
+ .nh-live__panel { background: var(--dossier); border: 1px solid var(--vellum);
999
+ border-radius: 2px; padding: 16px 18px; min-height: 120px; position: relative; }
1000
+ .nh-live__panel::before { content: ""; position: absolute; top: -1px; left: -1px; right: -1px;
1001
+ height: 2px; background: var(--ink); }
1002
+
1003
+ .nh-live__delibs { list-style: none; margin: 12px 0 0; padding: 0;
1004
+ display: flex; flex-direction: column; gap: 0; max-height: 360px; overflow-y: auto; }
1005
+ .nh-live__delibs li { padding: 10px 0; border-top: 1px dashed var(--vellum);
1006
+ animation: nh-fade 240ms cubic-bezier(.2,.6,.2,1); }
1007
+ .nh-live__delibs li:first-child { border-top: 1px solid var(--ink); }
1008
+ .nh-live__delibs .who { font-size: 11.5px; color: var(--ink); font-weight: 500;
1009
+ letter-spacing: 0; margin-bottom: 4px; }
1010
+ .nh-live__delibs .ex, .nh-live__delibs .fe { font-size: 13px; line-height: 1.5;
1011
+ color: var(--graphite); margin-top: 4px; }
1012
+ .nh-live__delibs .k { font-size: 10.5px; color: var(--slate); margin-right: 6px;
1013
+ letter-spacing: 0.06em; text-transform: uppercase; }
1014
+ .nh-live__delibs .fe .k { color: var(--oxblood); }
1015
+
1016
+ .nh-live__verdicts { list-style: none; margin: 12px 0 0; padding: 0;
1017
+ display: flex; flex-direction: column; gap: 8px; max-height: 360px; overflow-y: auto; }
1018
+ .nh-live__verdict { padding: 10px 12px; border: 1px solid var(--vellum);
1019
+ border-left: 3px solid var(--slate); border-radius: 2px; background: var(--paper);
1020
+ animation: nh-fade 240ms cubic-bezier(.2,.6,.2,1); }
1021
+ .nh-live__verdict .hd { display: flex; justify-content: space-between; align-items: baseline; }
1022
+ .nh-live__verdict .actor { font-size: 12.5px; color: var(--ink); font-weight: 500; }
1023
+ .nh-live__verdict .pl { font-size: 11px; color: var(--slate); letter-spacing: 0.04em; }
1024
+ .nh-live__verdict .oc { font-size: 11px; margin-top: 4px; letter-spacing: 0.04em;
1025
+ text-transform: uppercase; }
1026
+ .nh-live__verdict.outcome-major { border-left-color: var(--moss); }
1027
+ .nh-live__verdict.outcome-major .oc { color: var(--moss); }
1028
+ .nh-live__verdict.outcome-minor { border-left-color: var(--ice); }
1029
+ .nh-live__verdict.outcome-minor .oc { color: var(--ice); }
1030
+ .nh-live__verdict.outcome-noeffect { border-left-color: var(--amber); }
1031
+ .nh-live__verdict.outcome-noeffect .oc { color: var(--amber); }
1032
+ .nh-live__verdict.outcome-backfire { border-left-color: var(--oxblood); }
1033
+ .nh-live__verdict.outcome-backfire .oc { color: var(--oxblood); }
1034
+
1035
+ /* ============================================================
1036
+ Forms (5b) — role overrides, doc upload, constraint manager,
1037
+ violations banner. Paper/ink dossier, vellum dashed rules,
1038
+ oxblood for blocking states.
1039
+ ============================================================ */
1040
+
1041
+ /* Per-role overrides (NewGame) */
1042
+ .nh-role-overrides { background: var(--dossier); border: 1px solid var(--vellum);
1043
+ border-radius: 2px; margin-top: 14px; padding: 0; overflow: hidden; }
1044
+ .nh-role-overrides__hd { width: 100%; display: flex; align-items: center;
1045
+ justify-content: space-between; gap: 12px; padding: 14px 18px;
1046
+ background: transparent; border: 0; text-align: left; cursor: pointer;
1047
+ font: inherit; color: var(--ink); }
1048
+ .nh-role-overrides__hd:hover { background: var(--paper); }
1049
+ .nh-role-overrides__hd .eyebrow { display: block; margin-bottom: 4px; }
1050
+ .nh-role-overrides__hd .hd { font-size: 14px; font-weight: 500; }
1051
+ .nh-role-overrides__body { border-top: 1px solid var(--vellum);
1052
+ padding: 14px 18px 16px; }
1053
+ .nh-role-overrides__rows { display: grid; grid-template-columns: 1fr; gap: 0;
1054
+ border-top: 1px solid var(--ink); }
1055
+ .nh-role-row { display: grid; grid-template-columns: 1fr auto; gap: 12px;
1056
+ align-items: center; padding: 10px 0;
1057
+ border-bottom: 1px dashed var(--vellum); }
1058
+ .nh-role-row:last-child { border-bottom: 0; }
1059
+ .nh-role-row__name .code { font-size: 12.5px; font-weight: 600;
1060
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink); }
1061
+ .nh-role-row__name .hint { display: block; font-size: 11.5px;
1062
+ color: var(--slate); margin-top: 2px; }
1063
+ .nh-role-row select { font: inherit; padding: 6px 8px; min-width: 220px;
1064
+ border: 1px solid var(--vellum); background: var(--paper); }
1065
+ .nh-role-overrides__ft { margin-top: 10px; padding-top: 10px;
1066
+ border-top: 1px dashed var(--vellum);
1067
+ display: flex; justify-content: flex-end; }
1068
+
1069
+ /* Document upload (SetupCase) */
1070
+ .nh-doc-upload { background: var(--dossier); border: 1px solid var(--vellum);
1071
+ border-radius: 2px; padding: 16px 18px; margin-bottom: 16px; }
1072
+ .nh-doc-upload__hd { display: flex; align-items: flex-start;
1073
+ justify-content: space-between; gap: 16px; margin-bottom: 12px; }
1074
+ .nh-doc-upload__hd .eyebrow { display: block; margin-bottom: 4px; }
1075
+ .nh-doc-upload__hd .hd { font-size: 14px; color: var(--graphite); line-height: 1.5; }
1076
+ .nh-doc-drop { border: 2px dashed var(--vellum); border-radius: 2px;
1077
+ padding: 28px 16px; text-align: center; cursor: pointer;
1078
+ background: var(--paper); transition: all 120ms ease; }
1079
+ .nh-doc-drop:hover, .nh-doc-drop:focus { border-color: var(--ink);
1080
+ outline: none; }
1081
+ .nh-doc-drop.over { border-color: var(--ink); background: #FFFCF4; }
1082
+ .nh-doc-drop.disabled { cursor: not-allowed; opacity: 0.55; }
1083
+ .nh-doc-drop__cta { font-size: 13.5px; color: var(--graphite); }
1084
+ .nh-doc-drop__cta strong { color: var(--ink); font-weight: 600; }
1085
+ .nh-doc-upload__err { color: var(--oxblood); font-size: 12.5px;
1086
+ margin: 10px 0 0; padding: 8px 10px;
1087
+ background: rgba(122,31,31,0.04); border-left: 2px solid var(--oxblood); }
1088
+ .nh-doc-list { list-style: none; margin: 12px 0 0; padding: 0;
1089
+ border-top: 1px solid var(--ink); }
1090
+ .nh-doc-list li { display: grid; grid-template-columns: 1fr auto auto;
1091
+ gap: 12px; align-items: center; padding: 8px 0;
1092
+ border-bottom: 1px dashed var(--vellum); }
1093
+ .nh-doc-list li:last-child { border-bottom: 0; }
1094
+ .nh-doc-list .nm { font-size: 13px; color: var(--ink); word-break: break-all; }
1095
+ .nh-doc-list .sz { font-size: 11.5px; }
1096
+ .nh-doc-list .rm { background: none; border: 0; cursor: pointer;
1097
+ font-size: 11px; color: var(--slate); letter-spacing: 0.04em;
1098
+ text-transform: uppercase; padding: 2px 4px; }
1099
+ .nh-doc-list .rm:hover { color: var(--oxblood); }
1100
+ .nh-doc-list .rm:disabled { opacity: 0.4; cursor: not-allowed; }
1101
+
1102
+ /* Hard-constraint manager (GameView) */
1103
+ .nh-constraints { background: var(--dossier); border: 1px solid var(--vellum);
1104
+ border-radius: 2px; margin: 14px 0; overflow: hidden; }
1105
+ .nh-constraints__hd { width: 100%; display: flex; align-items: center;
1106
+ justify-content: space-between; gap: 12px; padding: 14px 18px;
1107
+ background: transparent; border: 0; text-align: left; cursor: pointer;
1108
+ font: inherit; color: var(--ink); }
1109
+ .nh-constraints__hd:hover { background: var(--paper); }
1110
+ .nh-constraints__hd .eyebrow { display: block; margin-bottom: 4px; }
1111
+ .nh-constraints__hd .hd { font-size: 14px; font-weight: 500; }
1112
+ .nh-constraints__body { border-top: 1px solid var(--vellum);
1113
+ padding: 14px 18px 16px; }
1114
+ .nh-constraints__err { color: var(--oxblood); font-size: 12.5px;
1115
+ margin: 0 0 10px; padding: 8px 10px;
1116
+ background: rgba(122,31,31,0.04); border-left: 2px solid var(--oxblood); }
1117
+ .nh-constraints__addbar { display: flex; justify-content: space-between;
1118
+ align-items: center; gap: 12px; margin-top: 14px; padding-top: 12px;
1119
+ border-top: 1px dashed var(--vellum); }
1120
+ .nh-constraint-list { list-style: none; margin: 0 0 12px; padding: 0;
1121
+ border-top: 1px solid var(--ink); }
1122
+ .nh-constraint { padding: 10px 0; border-bottom: 1px dashed var(--vellum); }
1123
+ .nh-constraint:last-child { border-bottom: 0; }
1124
+ .nh-constraint__row { display: flex; align-items: center; gap: 10px;
1125
+ flex-wrap: wrap; }
1126
+ .nh-constraint__desc { flex: 1; min-width: 220px; text-align: left;
1127
+ background: none; border: 0; cursor: text; font: inherit;
1128
+ font-size: 13.5px; color: var(--ink); padding: 0; line-height: 1.5; }
1129
+ .nh-constraint__desc:hover { color: var(--oxblood); }
1130
+ .nh-constraint__rm { background: none; border: 0; cursor: pointer;
1131
+ font-size: 11px; color: var(--slate); letter-spacing: 0.04em;
1132
+ text-transform: uppercase; padding: 2px 4px; }
1133
+ .nh-constraint__rm:hover { color: var(--oxblood); }
1134
+ .nh-constraint__detail { margin-top: 8px; padding: 8px 10px;
1135
+ background: var(--paper); border: 1px solid var(--vellum);
1136
+ font-size: 12.5px; color: var(--graphite); display: grid; gap: 6px; }
1137
+ .nh-constraint__detail .eyebrow { margin-right: 6px; }
1138
+ .nh-constraint-add { margin-top: 14px; padding-top: 12px;
1139
+ border-top: 1px dashed var(--vellum); }
1140
+ .nh-constraint-add__ft { display: flex; justify-content: flex-end;
1141
+ gap: 10px; margin-top: 6px; }
1142
+
1143
+ /* Severity / source badges */
1144
+ .nh-badge { display: inline-block; font-family: var(--font-mono);
1145
+ font-size: 10.5px; font-weight: 600; letter-spacing: 0.06em;
1146
+ text-transform: uppercase; padding: 2px 6px; border-radius: 2px;
1147
+ border: 1px solid currentColor; line-height: 1.4; white-space: nowrap; }
1148
+ .nh-badge.sev-hard { color: var(--oxblood); }
1149
+ .nh-badge.sev-soft { color: var(--amber); }
1150
+ .nh-badge.src-scenario { color: var(--ink); }
1151
+ .nh-badge.src-document { color: var(--graphite); }
1152
+ .nh-badge.src-manual { color: var(--slate); }
1153
+
1154
+ /* Violations banner */
1155
+ .nh-violations { margin: 12px 0; padding: 12px 14px;
1156
+ background: rgba(122,31,31,0.05); border: 1px solid var(--oxblood);
1157
+ border-radius: 2px; }
1158
+ .nh-violations__hd { display: flex; justify-content: space-between;
1159
+ align-items: center; margin-bottom: 8px; }
1160
+ .nh-violations__hd .eyebrow { color: var(--oxblood); }
1161
+ .nh-violations__rm { background: none; border: 0; cursor: pointer;
1162
+ font-size: 11px; color: var(--slate); letter-spacing: 0.04em;
1163
+ text-transform: uppercase; padding: 2px 4px; }
1164
+ .nh-violations__rm:hover { color: var(--oxblood); }
1165
+ .nh-violations ul { list-style: none; margin: 0; padding: 0; }
1166
+ .nh-violations li { padding: 6px 0; border-top: 1px dashed var(--oxblood-weak); }
1167
+ .nh-violations li:first-child { border-top: 1px solid var(--oxblood); }
1168
+ .nh-violations__cd { font-size: 13px; color: var(--ink); font-weight: 500; }
1169
+ .nh-violations__rs { font-size: 12.5px; color: var(--graphite);
1170
+ margin-top: 2px; line-height: 1.5; }