Files
History/src/App.tsx
T
2026-10-11 18:32:09 +00:00

808 lines
29 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
Activity,
BookOpen,
CircleDot,
ListTree,
Loader2,
Map as MapIcon,
Radar,
} from 'lucide-react';
import { Toaster } from './components/ui/sonner';
import { Button } from './components/ui/button';
import { Badge } from './components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
import { cn } from './lib/utils';
import PresetPicker from './components/PresetPicker';
import NationSelect from './components/NationSelect';
import WorldMap from './components/WorldMap';
import FloatingWindow from './components/FloatingWindow';
import MapLegend from './components/MapLegend';
import NationPanel from './components/NationPanel';
import EventFeed from './components/EventFeed';
import ActionPanel from './components/ActionPanel';
import { api } from './lib/api';
import { useSse } from './lib/sse';
import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol';
import type { GameState, Preset, PresetMeta } from '@shared/types';
/** Which top-level screen the app is showing. */
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' };
/**
* Surface a turn failure. A 502 (live model down / timeout) gets a friendly,
* actionable message; anything else passes the underlying error through.
*/
function toastError(e: unknown): void {
const msg = e instanceof Error ? e.message : String(e ?? 'Something went wrong');
if (/502|temporarily|timeout|fetch failed|network/i.test(msg)) {
toast.error('AI temporarily unavailable — try again');
} else {
toast.error(msg);
}
}
// ---------------------------------------------------------------------------
// Floating-window collapse + dock persistence.
// (Free-floating *positions* live in FloatingWindow itself, per id.)
// ---------------------------------------------------------------------------
const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed';
const FLOAT_DOCK_KEY = 'history.floatwin.v1.dock';
/**
* Every collapsible HUD panel (the slim, pinned header is never collapsed).
* The three substantive panels (dossier / feed / action) are also dockable;
* the legend stays a small floating widget, and there is no longer a "Map"
* window.
*/
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend'] as const;
function loadCollapsedMap(): Record<string, boolean> {
try {
const raw = localStorage.getItem(FLOAT_COLLAPSED_KEY);
if (raw == null) return {};
const v: unknown = JSON.parse(raw);
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
const out: Record<string, boolean> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (typeof val === 'boolean') out[k] = val;
}
return out;
} catch {
return {};
}
}
function persistCollapsedMap(m: Record<string, boolean>): void {
try {
localStorage.setItem(FLOAT_COLLAPSED_KEY, JSON.stringify(m));
} catch {
/* storage unavailable — collapse state just won't persist */
}
}
/** A dock mode: a docked edge, or 'float' (free-floating, the default). */
type DockMode = 'left' | 'right' | 'float';
function loadDockMap(): Record<string, DockMode> {
try {
const raw = localStorage.getItem(FLOAT_DOCK_KEY);
if (raw == null) return {};
const v: unknown = JSON.parse(raw);
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
const out: Record<string, DockMode> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (val === 'left' || val === 'right' || val === 'float') out[k] = val;
}
return out;
} catch {
return {};
}
}
function persistDockMap(m: Record<string, DockMode>): void {
try {
localStorage.setItem(FLOAT_DOCK_KEY, JSON.stringify(m));
} catch {
/* storage unavailable — dock choices just won't persist */
}
}
// ---------------------------------------------------------------------------
// Active-game persistence. The id of the game the user is (or was last) playing
// is remembered in localStorage, so a page refresh drops them straight back
// into their game, and the home screen can offer to resume it explicitly.
// ---------------------------------------------------------------------------
const ACTIVE_GAME_KEY = 'history.activeGameId';
function readActiveGameId(): string | null {
try {
const v = localStorage.getItem(ACTIVE_GAME_KEY);
return v && v.length > 0 ? v : null;
} catch {
return null;
}
}
function persistActiveGameId(id: string): void {
try {
localStorage.setItem(ACTIVE_GAME_KEY, id);
} catch {
/* storage unavailable — the game just won't auto-resume */
}
}
function clearActiveGameId(): void {
try {
localStorage.removeItem(ACTIVE_GAME_KEY);
} catch {
/* storage unavailable */
}
}
// ---------------------------------------------------------------------------
// Hash routing. The active game's id lives in the URL fragment: home is `#/`
// (a bare page with no hash is also home) and an active game is `#/game/<id>`.
// This manual hash sync — no react-router, no server-side route — fits the
// existing state-based router and is static/Traefik safe, because the fragment
// never reaches the server (every non-API GET just serves `index.html`). The
// localStorage active-game id above stays as the fallback for a bare `/` load;
// the URL hash takes precedence when present.
// ---------------------------------------------------------------------------
/** The game id encoded in `#/game/<id>`, or `null` when the hash is home/bare. */
function hashGameId(hash: string): string | null {
const m = hash.match(/^#\/game\/(.+)$/);
return m && m[1].length > 0 ? m[1] : null;
}
/** Write the home route (`#/`) into the URL hash unless it is already home/bare. */
function setHomeHash(): void {
const h = window.location.hash;
if (h !== '' && h !== '#/') window.location.hash = '#/';
}
export default function App() {
const [screen, setScreen] = useState<Screen>({ name: 'home' });
const [presets, setPresets] = useState<PresetMeta[]>([]);
const [loading, setLoading] = useState(false);
const [game, setGame] = useState<GameState | null>(null);
const [regions, setRegions] = useState<RegionMap>({});
const [advisors, setAdvisors] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
// Separate from `busy`: an advisor fetch is its own LLM call. It must NOT
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
// it only gates the advisor UI (the "Ask advisor" button + its hint).
const [advisorBusy, setAdvisorBusy] = useState(false);
// The id of the game the user is/was last in (mirrors localStorage) and the
// list of saved games (newest first) shown on the home screen.
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
const [savedGames, setSavedGames] = useState<GameSummary[]>([]);
// Once the boot fetch resolves (success or fallback), allow the URL-sync effect
// to start writing the hash. Until then, a deep link in the URL must not be
// clobbered by an app-driven "home" write during the initial render.
const bootedRef = useRef(false);
// Latest game + handlers, read by the (mount-once) hashchange listener so it
// never acts on a stale closure. Written each render (same pattern as sse.ts).
const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null);
// The single entry point for adopting a full game state — SSE `state`/`turn`
// events AND the REST responses (add/remove/jump) AND game loads all flow
// through here. Two rules:
// • a *different* game id (new game, deep link, resume) is always adopted;
// • the *same* game is adopted only when its `version` is not older than
// the current state's ("never go backwards").
// The comparison happens inside the functional updater, so it always reads
// the latest committed state — never a stale closure. This is what makes a
// late-arriving stale SSE snapshot (e.g. the on-connect resync taken just
// before an add was saved, or an EventSource auto-reconnect) harmless: it
// carries a lower version than the fresher state the REST response already
// applied, so it is dropped instead of clobbering the queued item (#10).
// Legacy games saved before `version` existed are treated as version 0.
const applyState = useCallback((s: GameState) => {
setGame((cur) => {
if (!cur || cur.id !== s.id) return s;
return (s.version ?? 0) >= (cur.version ?? 0) ? s : cur;
});
}, []);
// On the home screen: load the scenario manifest and the list of saved games
// (newest-updated first) so the user can resume a prior game.
useEffect(() => {
if (screen.name !== 'home') return;
let alive = true;
api
.listPresets()
.then((r) => alive && setPresets(r.presets))
.catch(() => {});
api
.listGames()
.then((r) => alive && setSavedGames(r.games))
.catch(() => {});
return () => {
alive = false;
};
}, [screen.name]);
// On first mount, decide which game to show and load it. A deep link in the
// URL (`#/game/<id>`) takes precedence over the localStorage auto-resume; a
// bare page (no hash) falls back to the stored active game. The map is
// refetched by the effect below (keyed on `game`) and `useSse(game?.id)`
// re-attaches the stream, resyncing from the server on connect. A bad or
// missing id (404) must never crash: we clear the hash to `#/`, drop any
// stale stored id, and stay on the home screen. Once boot resolves (success
// or fallback) `bootedRef` flips so the URL-sync effect may start writing.
useEffect(() => {
const id = hashGameId(window.location.hash) ?? readActiveGameId();
if (!id) {
bootedRef.current = true; // nothing to load — activate URL syncing
return;
}
let alive = true;
(async () => {
try {
const { game } = await api.getGame(id);
if (!alive) return;
persistActiveGameId(id); // remember it so a future bare `/` load resumes it
setActiveGameId(id);
applyState(game);
setScreen({ name: 'game' });
} catch {
if (!alive) return;
clearActiveGameId();
setActiveGameId(null);
setHomeHash(); // bad/missing id → fall back to the home screen
} finally {
bootedRef.current = true; // boot resolved — activate URL syncing
}
})();
return () => {
alive = false;
};
}, []);
// Keep the URL in sync with the current screen: a loaded game is
// `#/game/<id>`; everything else (home and the pick screens) is `#/`. This is
// the single writer of the hash for app-driven transitions (game created,
// resumed, or leaving for home). It only writes when the desired hash differs
// from the current one, so it never triggers a redundant `hashchange`.
useEffect(() => {
if (!bootedRef.current) return;
const desired = screen.name === 'game' && game ? `#/game/${game.id}` : '#/';
if (window.location.hash !== desired) window.location.hash = desired;
}, [screen.name, game?.id]);
// React to user-driven hash changes: manual edits and browser back/forward.
// Only re-act when the parsed id actually differs from the loaded game (or the
// hash went home while we're in a game), so the writes above never loop. Uses
// `latestRef` so the mount-once effect always sees the current game + handlers.
useEffect(() => {
const onHashChange = () => {
const cur = latestRef.current;
if (!cur) return;
const id = hashGameId(window.location.hash);
if (id) {
if (id === cur.game?.id) return; // already showing this game — no-op
void cur.loadGame(id);
} else if (cur.game) {
cur.goHome(); // hash is home but we're in a game — return home
}
// else: hash is home and we're already on home/pick — nothing to do
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
// Keep the choropleth in sync with whatever state is shown (territory changes).
useEffect(() => {
if (!game) return;
api
.map(game.id)
.then((r) => setRegions(r.regions))
.catch(() => {});
}, [game]);
// Resync from the persisted server state on connect and after every turn.
useSse(game?.id ?? null, {
onState: applyState,
onTurn: applyState,
});
const play = useCallback(async (presetId: string) => {
setLoading(true);
// Explicitly starting a fresh game: drop the remembered active game so a
// refresh won't resurrect the previous one (`pickNation` sets the new id).
clearActiveGameId();
setActiveGameId(null);
try {
const { preset } = await api.getPreset(presetId);
setScreen({ name: 'pick', preset });
} catch (e) {
toastError(e);
setScreen({ name: 'home' });
} finally {
setLoading(false);
}
}, []);
const pickNation = useCallback(
async (nationId: string) => {
if (screen.name !== 'pick') return;
setLoading(true);
try {
const { game } = await api.createGame(screen.preset.id, nationId);
// Remember this as the active game so a refresh resumes it.
persistActiveGameId(game.id);
setActiveGameId(game.id);
applyState(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
toastError(e);
} finally {
setLoading(false);
}
},
[screen],
);
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
// list updates from the response and the SSE `state` event. Both are
// version-gated via applyState, so the item can never be clobbered by a
// stale snapshot (#10).
const runAddAction = useCallback(
async (text: string) => {
if (!game) return;
try {
const { game: g } = await api.addAction(game.id, text);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game, applyState],
);
// Add a diplomatic message to the pending round. Fast, non-LLM — no `busy`.
const runAddDiplomacy = useCallback(
async (to: string, message: string) => {
if (!game) return;
try {
const { game: g } = await api.addDiplomacy(game.id, to, message);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game, applyState],
);
// Remove a queued item by index. Fast, non-LLM — no `busy`; the pending list
// updates from the response and the SSE `state` event (version-gated).
const runRemoveQueueItem = useCallback(
async (index: number) => {
if (!game) return;
try {
const { game: g } = await api.removeQueueItem(game.id, index);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game, applyState],
);
// Jump forward: the single LLM call that commits the pending round. Only this
// sets `busy` (the adds above are fast and non-LLM).
const runJump = useCallback(async () => {
if (!game) return;
setBusy(true);
try {
const { state } = await api.jump(game.id);
applyState(state);
} catch (e) {
toastError(e);
} finally {
setBusy(false);
}
}, [game, applyState]);
// Ask the advisor: a separate (slow) LLM call. It sets its own `advisorBusy`
// flag (distinct from the turn `busy`) and clears it in `finally`, so the flag
// is released on both success and error. Asking never advances a turn and
// never touches the turn `busy` state — "Jump forward" stays usable.
const askAdvisor = useCallback(
async (prompt?: string) => {
if (!game) return;
setAdvisorBusy(true);
try {
const { suggestions } = await api.advisor(game.id, prompt);
setAdvisors(suggestions);
} catch (e) {
toastError(e);
} finally {
setAdvisorBusy(false);
}
},
[game],
);
// Load (fetch + show) a saved game by id. Shared by the home screen's resume
// affordances and by the hash router when the URL points at a different game.
// On failure (deleted / 404) it never throws: it clears the stale reference,
// clears the hash to `#/`, and stays on the home screen.
const loadGame = useCallback(async (id: string) => {
setLoading(true);
try {
const { game } = await api.getGame(id);
// Make this the active game so a bare `/` reload keeps resuming it.
persistActiveGameId(id);
setActiveGameId(id);
applyState(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
clearActiveGameId();
setActiveGameId(null);
setHomeHash();
setScreen({ name: 'home' });
toastError(e);
} finally {
setLoading(false);
}
}, []);
// The home screen's "resume" affordances just load a saved game by id.
const resumeGame = useCallback((id: string) => void loadGame(id), [loadGame]);
const goHome = useCallback(() => {
setScreen({ name: 'home' });
setGame(null);
setRegions({});
setAdvisors([]);
}, []);
// Expose the latest game + handlers to the mount-once hashchange listener.
latestRef.current = { game, goHome, loadGame };
if (screen.name === 'game' && game) {
return (
<>
<Toaster richColors position="top-right" />
<GameScreen
key={game.id}
game={game}
regions={regions}
busy={busy}
advisorBusy={advisorBusy}
advisors={advisors}
onAddAction={runAddAction}
onAddDiplomacy={runAddDiplomacy}
onRemoveQueueItem={runRemoveQueueItem}
onAdvisor={askAdvisor}
onJump={runJump}
onHome={goHome}
/>
</>
);
}
if (screen.name === 'pick') {
return (
<>
<Toaster richColors position="top-right" />
<NationSelect preset={screen.preset} loading={loading} onPick={pickNation} onBack={goHome} />
</>
);
}
return (
<>
<Toaster richColors position="top-right" />
<PresetPicker
presets={presets}
loading={loading}
onPlay={play}
savedGames={savedGames}
activeGameId={activeGameId}
onResume={resumeGame}
/>
</>
);
}
interface GameScreenProps {
game: GameState;
regions: RegionMap;
/** A turn ("Jump forward") is in flight — drives the turn flow + commit button. */
busy: boolean;
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
advisorBusy: boolean;
advisors: string[];
onAddAction: (a: string) => void;
onAddDiplomacy: (to: string, m: string) => void;
onRemoveQueueItem: (index: number) => void;
onAdvisor: (p?: string) => void;
onJump: () => void;
onHome: () => void;
}
/**
* The main play view — a full-bleed world map with a floating HUD. The MapLibre
* map is a fixed, full-viewport background (z-0) that the user drives directly
* (wheel/trackpad zoom + drag pan — there is no floating zoom control). Over it:
* a slim **pinned** header bar (fixed, non-movable, centered at the top a few
* px down; scenario, turn + date, player nation, live status, "Focus map"
* collapse-all); the nation dossier, the event feed, and the "Your move"
* command deck — each free-floating by default (at the same first-run positions
* as before) but **dockable** into a full-height left or right pane, the choice
* being remembered; and the nation-color legend, which stays a small floating
* widget (not dockable). Each floating window remembers its dragged position,
* and clicking one raises it above its siblings. Pan/zoom/click on the map
* stays fully available in the gaps between windows.
*/
function GameScreen({
game,
regions,
busy,
advisorBusy,
advisors,
onAddAction,
onAddDiplomacy,
onRemoveQueueItem,
onAdvisor,
onJump,
onHome,
}: GameScreenProps) {
// Which nation's dossier to show. Defaults to the player; remounts per game.
const [viewingId, setViewingId] = useState(game.playerNationId);
const player = game.nations[game.playerNationId];
const viewed = game.nations[viewingId] ?? player;
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
// Collapse state for the HUD panels, persisted per id (survives reloads).
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsedMap);
const setCollapsedFor = useCallback((id: string, c: boolean) => {
setCollapsed((prev) => {
const next = { ...prev, [id]: c };
persistCollapsedMap(next);
return next;
});
}, []);
const allCollapsed = PANEL_WINDOW_IDS.every((id) => collapsed[id]);
// "Focus map" collapses every panel at once to reveal the bare map; the same
// button (now "Show panels") restores them.
const toggleAllPanels = useCallback(() => {
setCollapsed((prev) => {
const target = !PANEL_WINDOW_IDS.every((id) => prev[id]);
const next = { ...prev };
for (const id of PANEL_WINDOW_IDS) next[id] = target;
persistCollapsedMap(next);
return next;
});
}, []);
// Dock state for the three dockable panels (dossier / feed / action),
// persisted per id (survives reloads). The default is 'float' — the current
// first-run positions — so nothing visually changes until the user docks
// something. The legend is not dockable; the header is pinned.
const [dock, setDock] = useState<Record<string, DockMode>>(loadDockMap);
const setDockFor = useCallback((id: string, mode: DockMode) => {
setDock((prev) => {
const next = { ...prev, [id]: mode };
persistDockMap(next);
return next;
});
}, []);
// The `dock` prop expects a concrete edge, or `undefined` to free-float.
const dockOf = (id: string) => {
const m = dock[id];
return m === 'left' || m === 'right' ? m : undefined;
};
return (
<div className="min-h-screen">
{/* Full-viewport background map, behind every floating window. */}
<WorldMap
regions={regions}
playerNationId={game.playerNationId}
nations={game.nations}
presetId={game.presetId}
onFocusNation={setViewingId}
/>
{/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
Fixed and non-movable, centered at the top a few px from the top edge. */}
<FloatingWindow
id="header"
dataTestId="top-bar"
pinned
collapsible={false}
title={
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="sm" onClick={onHome} className="shrink-0" data-testid="back-to-scenarios">
← Scenarios
</Button>
<div className="flex min-w-0 items-center gap-2.5">
<Radar className="size-5 shrink-0 text-primary" />
<div className="min-w-0 leading-tight">
<div className="truncate text-base font-bold tracking-tight">History</div>
<div className="truncate text-[11px] font-normal text-muted-foreground">
{game.presetTitle}
</div>
</div>
</div>
</div>
}
titleExtra={
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1">
<div className="text-right leading-none">
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
Turn
</div>
<div className="mt-0.5 text-xl font-bold tabular-nums" data-testid="turn-count">
{game.turn}
</div>
</div>
<div className="hidden h-8 w-px bg-border sm:block" />
<div className="text-right leading-none">
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
Date
</div>
<div className="mt-1 text-sm font-semibold tabular-nums" data-testid="game-date">
{game.date}
</div>
</div>
<div className="hidden h-8 w-px bg-border sm:block" />
<div className="flex items-center gap-2">
<span
className="inline-block size-3.5 rounded-full ring-2 ring-white/10"
style={{ background: nationColor(game.playerNationId) }}
/>
<span className="text-sm font-medium" data-testid="player-nation">
{player?.name ?? game.playerNationId}
</span>
</div>
{/* Three-state live status: advisor fetch in flight → "Advisor
thinking…"; else a turn in flight → "Simulating…"; else "Idle".
Both LLM-in-flight states share the amber spinner; idle is muted. */}
<span
data-testid="status-indicator"
className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
advisorBusy || busy
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border bg-muted/40 text-muted-foreground',
)}
>
{advisorBusy || busy ? (
<Loader2 className="size-3 animate-spin" />
) : (
<CircleDot className="size-3" />
)}
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
</span>
<Button
variant="secondary"
size="sm"
onClick={toggleAllPanels}
data-testid="focus-map"
title={allCollapsed ? 'Show all panels' : 'Collapse all panels to reveal the map'}
>
<MapIcon className="size-3.5" />
{allCollapsed ? 'Show panels' : 'Focus map'}
</Button>
</div>
}
/>
{/* Nation dossier, top-left by default; dockable into a left/right pane. */}
<FloatingWindow
id="dossier"
dataTestId="panel-dossier"
anchor="top-left"
width={300}
title="Dossier"
titleIcon={<BookOpen className="size-4" />}
dock={dockOf('dossier')}
onDockChange={(m) => setDockFor('dossier', m)}
collapsed={!!collapsed.dossier}
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
bodyClassName="max-h-[56vh] overflow-y-auto"
>
<div className="border-b border-border/60 p-3">
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Inspect
</div>
<Select value={viewingId} onValueChange={setViewingId}>
<SelectTrigger className="w-full" data-testid="inspect-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{nations.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
</FloatingWindow>
{/* Event feed, right side by default; dockable into a left/right pane. */}
<FloatingWindow
id="feed"
dataTestId="panel-feed"
anchor="right"
width={330}
title="Event feed"
titleIcon={<Activity className="size-4" />}
titleExtra={
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums">
{game.events.length}
</Badge>
}
dock={dockOf('feed')}
onDockChange={(m) => setDockFor('feed', m)}
collapsed={!!collapsed.feed}
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
bodyClassName="h-[52vh]"
>
<EventFeed events={game.events} nations={game.nations} inWindow />
</FloatingWindow>
{/* Command deck ("Your move"), bottom-center by default; dockable. */}
<FloatingWindow
id="action"
dataTestId="panel-action"
anchor="bottom-center"
width={370}
title="Your move"
titleIcon={<Radar className="size-4 text-primary" />}
titleExtra={
game.pending.length > 0 ? (
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums" data-testid="title-queued">
{game.pending.length} queued
</Badge>
) : undefined
}
dock={dockOf('action')}
onDockChange={(m) => setDockFor('action', m)}
collapsed={!!collapsed.action}
onCollapsedChange={(c) => setCollapsedFor('action', c)}
bodyClassName="max-h-[72vh] overflow-y-auto"
>
<ActionPanel
game={game}
busy={busy}
advisorBusy={advisorBusy}
advisors={advisors}
onAddAction={onAddAction}
onAddDiplomacy={onAddDiplomacy}
onRemoveQueueItem={onRemoveQueueItem}
onAdvisor={onAdvisor}
onJump={onJump}
inWindow
/>
</FloatingWindow>
{/* Nation-color legend, bottom-left. */}
<FloatingWindow
id="legend"
anchor="bottom-left"
width={170}
title="Legend"
titleIcon={<ListTree className="size-4" />}
collapsed={!!collapsed.legend}
onCollapsedChange={(c) => setCollapsedFor('legend', c)}
>
<MapLegend regions={regions} />
</FloatingWindow>
</div>
);
}