Spaces:
Sleeping
Round 5b: frontend wiring — live progress, pause/resume, role overrides, doc upload, constraint manager
Browse filesTwo 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 +20 -1
- frontend/package-lock.json +0 -0
- frontend/scripts/sse-smoke.mjs +88 -0
- frontend/src/api.ts +204 -5
- frontend/src/components/LiveProgress.tsx +376 -0
- frontend/src/screens/GameView.tsx +505 -40
- frontend/src/screens/NewGame.tsx +131 -1
- frontend/src/screens/SetupCase.tsx +193 -1
- frontend/src/styles/app.css +228 -0
|
@@ -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;
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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 |
+
}
|
|
@@ -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: (
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
method: 'POST',
|
| 26 |
-
body: JSON.stringify(
|
| 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 |
+
});
|
|
@@ -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 |
+
}
|
|
@@ -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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
setActionSent(true);
|
| 207 |
setPhase('action');
|
| 208 |
-
} catch (e) {
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 524 |
-
{
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
</div>
|
| 527 |
</>
|
| 528 |
) : composerMode === 'coach' ? (
|
| 529 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
</span>
|
| 629 |
<button
|
| 630 |
className="btn primary"
|
| 631 |
-
disabled={!canSubmit || submitBusy}
|
| 632 |
onClick={submitAction}
|
| 633 |
-
>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
</div>
|
| 635 |
</>
|
| 636 |
)}
|
| 637 |
</section>
|
| 638 |
|
| 639 |
-
{/* ADVANCE LIVE
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 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 |
+
}
|
|
@@ -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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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; }
|