feat: nation-op rename review — confirm/reject routes, client dialog, AGENTS.md (P4a, #16)
Wire the 'LLM proposes, player confirms' nation-rename flow end to end: - server: POST /api/games/:id/nation-op/confirm|reject (404/400, version-bumped SSE state) - client: confirmNationOp/rejectNationOp api helpers + NationOpDialog (editable rename) wired into the Action panel via a 'Nation proposals' trigger - AGENTS.md: document the pendingNationOps mechanism, routes, nationRef resolution, stub-never-proposes
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Loader2, PenLine, X } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import type { GameState } from '@shared/types';
|
||||
import type { NationOp, RenameOp } from '@shared/protocol';
|
||||
|
||||
interface Props {
|
||||
game: GameState;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** A confirm/reject request is in flight — disable the per-op controls. */
|
||||
opBusy: boolean;
|
||||
/** Apply a pending op; `edits.name` optionally overrides a rename's target name. */
|
||||
onConfirm: (index: number, edits?: { name?: string }) => void;
|
||||
/** Drop a pending op (reject / dismiss). */
|
||||
onReject: (index: number) => void;
|
||||
}
|
||||
|
||||
/** A short, display-safe description of a pending nation op. */
|
||||
function describeOp(op: NationOp, name: (ref: string) => string): string {
|
||||
if (op.kind === 'rename') return `Rename ${name(op.nationRef)} to “${op.to}”`;
|
||||
if (op.kind === 'split')
|
||||
return `Split ${name(op.nationRef)} into ${op.parts.length} nations (${op.parts
|
||||
.map((p) => p.name)
|
||||
.join(', ')})`;
|
||||
return `Merge ${op.nationRefs.map(name).join(' + ')} into “${op.into}”`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`)
|
||||
* and lets the player apply ("LLM proposes, player confirms") or drop each one.
|
||||
* A rename shows the model's proposed name in an editable field (so the player
|
||||
* can tweak it before confirming); split and merge are declared but not yet
|
||||
* applied (P4b) — they can be dismissed but not confirmed.
|
||||
*/
|
||||
export default function NationOpDialog({
|
||||
game,
|
||||
open,
|
||||
onOpenChange,
|
||||
opBusy,
|
||||
onConfirm,
|
||||
onReject,
|
||||
}: Props) {
|
||||
const ops = game.pendingNationOps;
|
||||
const name = (ref: string) => game.nations[ref]?.name ?? ref;
|
||||
// The player's edit to a rename's target name, keyed by pending-op index.
|
||||
// An unset entry means "use the model's proposed name" (`op.to`).
|
||||
const [edits, setEdits] = useState<Record<number, string>>({});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg" data-testid="nation-op-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<span className="flex items-center gap-2">
|
||||
<PenLine className="size-4 text-primary" />
|
||||
Nation proposals
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
The model proposed changes to nations. Confirm to apply them (you can edit a
|
||||
rename) or reject to leave the world as it is.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{ops.length === 0 ? (
|
||||
<p className="py-2 text-sm text-muted-foreground" data-testid="nation-op-empty">
|
||||
Nothing pending — the model hasn’t proposed any nation changes.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list">
|
||||
{ops.map((op, i) => {
|
||||
const isRename = op.kind === 'rename';
|
||||
const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : '';
|
||||
return (
|
||||
<li
|
||||
key={`${i}-${op.kind}`}
|
||||
className="rounded-lg border border-border/70 bg-card/50 p-3"
|
||||
data-testid="nation-op-item"
|
||||
data-kind={op.kind}
|
||||
>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<Badge variant="secondary" className="shrink-0 text-[11px] capitalize">
|
||||
{op.kind}
|
||||
</Badge>
|
||||
<span className="min-w-0 flex-1 text-sm leading-snug">
|
||||
{describeOp(op, name)}
|
||||
</span>
|
||||
</div>
|
||||
{isRename ? (
|
||||
<div className="space-y-2.5">
|
||||
<Input
|
||||
value={edits[i] ?? (op as RenameOp).to}
|
||||
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||
placeholder="New name…"
|
||||
data-testid="nation-op-name-input"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onReject(i)}
|
||||
disabled={opBusy}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
data-testid="nation-op-reject"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onConfirm(i, finalName ? { name: finalName } : undefined)}
|
||||
disabled={opBusy}
|
||||
data-testid="nation-op-confirm"
|
||||
>
|
||||
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it.
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onReject(i)}
|
||||
disabled={opBusy}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
data-testid="nation-op-reject"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user