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:
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
|
||||
import { Loader2, MessageSquare, PenLine, Radar, Trash2, Zap } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
@@ -15,6 +15,7 @@ import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import NationOpDialog from './NationOpDialog';
|
||||
import type { GameState, RoundItem } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
@@ -27,6 +28,11 @@ interface Props {
|
||||
* never disable "Jump forward" or the rest of the turn flow.
|
||||
*/
|
||||
advisorBusy: boolean;
|
||||
/**
|
||||
* A pending nation-op confirm/reject request is in flight. Distinct from
|
||||
* `busy`: it gates only the nation-op dialog controls, never the turn flow.
|
||||
*/
|
||||
opBusy: boolean;
|
||||
/** Advisor brainstorm suggestions from the last request. */
|
||||
advisors: string[];
|
||||
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||
@@ -38,6 +44,10 @@ interface Props {
|
||||
onAdvisor: (prompt?: string) => void;
|
||||
/** Commit the whole pending round in a single LLM turn. */
|
||||
onJump: () => void;
|
||||
/** Apply a pending nation op; `edits.name` optionally overrides a rename. */
|
||||
onConfirmNationOp: (index: number, edits?: { name?: string }) => void;
|
||||
/** Drop (reject) a pending nation op by its index. */
|
||||
onRejectNationOp: (index: number) => void;
|
||||
/**
|
||||
* Render without the outer card chrome (the surrounding floating window
|
||||
* provides the border and the "Your move" title). Same content, same testids.
|
||||
@@ -66,12 +76,15 @@ export default function ActionPanel({
|
||||
game,
|
||||
busy,
|
||||
advisorBusy,
|
||||
opBusy,
|
||||
advisors,
|
||||
onAddAction,
|
||||
onAddDiplomacy,
|
||||
onRemoveQueueItem,
|
||||
onAdvisor,
|
||||
onJump,
|
||||
onConfirmNationOp,
|
||||
onRejectNationOp,
|
||||
inWindow = false,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState('action');
|
||||
@@ -81,6 +94,8 @@ export default function ActionPanel({
|
||||
const [prompt, setPrompt] = useState('');
|
||||
// Index of the queued item the user is about to remove, or null (dialog closed).
|
||||
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
||||
// Whether the nation-op review dialog is open.
|
||||
const [opDialogOpen, setOpDialogOpen] = useState(false);
|
||||
|
||||
const others = Object.entries(game.nations)
|
||||
.filter(([id]) => id !== game.playerNationId)
|
||||
@@ -108,6 +123,7 @@ export default function ActionPanel({
|
||||
};
|
||||
|
||||
const pending = game.pending;
|
||||
const ops = game.pendingNationOps;
|
||||
// The item the confirmation dialog is about (re-derived from the live list,
|
||||
// so if the queue changes while the dialog is open it tracks the current
|
||||
// item at that index — or the dialog closes if the index went out of range).
|
||||
@@ -325,6 +341,31 @@ export default function ActionPanel({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Pending nation ops proposed by the model — open to confirm or reject. */}
|
||||
{ops.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpDialogOpen(true)}
|
||||
disabled={busy}
|
||||
data-testid="nation-op-trigger"
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5 text-sm hover:bg-amber-500/15"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-amber-300">
|
||||
<PenLine className="size-4" />
|
||||
{ops.length} nation {ops.length === 1 ? 'proposal' : 'proposals'} — review
|
||||
</span>
|
||||
<span className="text-xs text-amber-300/70">confirm or reject</span>
|
||||
</button>
|
||||
)}
|
||||
<NationOpDialog
|
||||
game={game}
|
||||
open={opDialogOpen}
|
||||
onOpenChange={setOpDialogOpen}
|
||||
opBusy={opBusy}
|
||||
onConfirm={onConfirmNationOp}
|
||||
onReject={onRejectNationOp}
|
||||
/>
|
||||
|
||||
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
||||
{queuedLabel}
|
||||
|
||||
@@ -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