360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { useState } from 'react';
|
||
import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
|
||
import { Badge } from './ui/badge';
|
||
import { Button } from './ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from './ui/dialog';
|
||
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 type { GameState, RoundItem } from '@shared/types';
|
||
|
||
interface Props {
|
||
game: GameState;
|
||
/** A jump (the only turn LLM call) is currently in flight — disable the commit. */
|
||
busy: boolean;
|
||
/**
|
||
* An advisor fetch (a separate LLM call) is in flight. Distinct from `busy`:
|
||
* it gates only the advisor UI (the "Ask advisor" button + its hint) and must
|
||
* never disable "Jump forward" or the rest of the turn flow.
|
||
*/
|
||
advisorBusy: boolean;
|
||
/** Advisor brainstorm suggestions from the last request. */
|
||
advisors: string[];
|
||
/** Queue a free-text action onto the pending round (no turn advance). */
|
||
onAddAction: (text: string) => void;
|
||
/** Queue a diplomatic message onto the pending round (no turn advance). */
|
||
onAddDiplomacy: (to: string, message: string) => void;
|
||
/** Remove one queued item by its index in `game.pending` (no turn advance). */
|
||
onRemoveQueueItem: (index: number) => void;
|
||
onAdvisor: (prompt?: string) => void;
|
||
/** Commit the whole pending round in a single LLM turn. */
|
||
onJump: () => void;
|
||
/**
|
||
* Render without the outer card chrome (the surrounding floating window
|
||
* provides the border and the "Your move" title). Same content, same testids.
|
||
*/
|
||
inWindow?: boolean;
|
||
}
|
||
|
||
/** One-line, display-safe rendering of a queued item (text truncated). */
|
||
function describeItem(item: RoundItem, nationName: (id?: string) => string): string {
|
||
const who = item.kind === 'diplomacy' ? ` to ${nationName(item.to)}` : '';
|
||
const text = item.text.length > 72 ? `${item.text.slice(0, 71)}…` : item.text;
|
||
return `${who ? `${who}:` : 'Action:'} “${text}”`;
|
||
}
|
||
|
||
/**
|
||
* The player's command deck ("Your move"). Three tabs — Action, Diplomacy,
|
||
* Advisor — each with a small "Add" button that queues an item onto the pending
|
||
* round (these never advance the turn). Below the tabs a visible pending-queue
|
||
* list — each row has a small trash button that removes that item (with a
|
||
* confirmation dialog) — and one prominent "Jump forward" button that commits
|
||
* the whole round in a single LLM turn. While a jump is in flight the commit
|
||
* is disabled (as is queue editing) and a hint is shown; an empty jump
|
||
* (nothing queued) is allowed and advances time.
|
||
*/
|
||
export default function ActionPanel({
|
||
game,
|
||
busy,
|
||
advisorBusy,
|
||
advisors,
|
||
onAddAction,
|
||
onAddDiplomacy,
|
||
onRemoveQueueItem,
|
||
onAdvisor,
|
||
onJump,
|
||
inWindow = false,
|
||
}: Props) {
|
||
const [tab, setTab] = useState('action');
|
||
const [action, setAction] = useState('');
|
||
const [target, setTarget] = useState('');
|
||
const [message, setMessage] = useState('');
|
||
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);
|
||
|
||
const others = Object.entries(game.nations)
|
||
.filter(([id]) => id !== game.playerNationId)
|
||
.sort(([a], [b]) => a.localeCompare(b));
|
||
|
||
const nationName = (id?: string) => (id ? (game.nations[id]?.name ?? id) : '');
|
||
|
||
const queueAction = () => {
|
||
const text = action.trim();
|
||
if (!text || busy) return;
|
||
onAddAction(text);
|
||
setAction('');
|
||
};
|
||
|
||
const queueDiplomacy = () => {
|
||
const msg = message.trim();
|
||
if (!msg || !target || busy) return;
|
||
onAddDiplomacy(target, msg);
|
||
setMessage('');
|
||
};
|
||
|
||
const applyIdea = (idea: string) => {
|
||
setAction(idea);
|
||
setTab('action');
|
||
};
|
||
|
||
const pending = game.pending;
|
||
// 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).
|
||
const removeTarget = confirmRemoveIndex !== null ? pending[confirmRemoveIndex] : undefined;
|
||
const queuedLabel = busy
|
||
? 'Simulating…'
|
||
: `Jump forward${pending.length > 0 ? ` · ${pending.length} queued` : ''}`;
|
||
|
||
const header = inWindow ? null : (
|
||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||
<div className="flex items-center gap-2">
|
||
<Radar className="size-4 text-primary" />
|
||
<CardTitle className="text-base">Your move</CardTitle>
|
||
</div>
|
||
</CardHeader>
|
||
);
|
||
|
||
const body = (
|
||
<>
|
||
<Tabs value={tab} onValueChange={setTab} className="gap-3">
|
||
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
|
||
<TabsTrigger value="action" data-testid="tab-action">
|
||
Action
|
||
</TabsTrigger>
|
||
<TabsTrigger value="diplo" data-testid="tab-diplo">
|
||
Diplomacy
|
||
</TabsTrigger>
|
||
<TabsTrigger value="advisor" data-testid="tab-advisor">
|
||
Advisor
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="action" className="space-y-2">
|
||
<Textarea
|
||
value={action}
|
||
onChange={(e) => setAction(e.target.value)}
|
||
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
|
||
rows={3}
|
||
data-testid="action-textarea"
|
||
/>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={queueAction}
|
||
disabled={busy || !action.trim()}
|
||
data-testid="add-action"
|
||
>
|
||
Add action
|
||
</Button>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="diplo" className="space-y-2">
|
||
<Select value={target} onValueChange={setTarget}>
|
||
<SelectTrigger className="w-full" data-testid="chat-target">
|
||
<SelectValue placeholder="Choose a nation to address…" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{others.map(([id, n]) => (
|
||
<SelectItem key={id} value={id}>
|
||
{n.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Textarea
|
||
value={message}
|
||
onChange={(e) => setMessage(e.target.value)}
|
||
placeholder="Your diplomatic message…"
|
||
rows={3}
|
||
data-testid="chat-message"
|
||
/>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={queueDiplomacy}
|
||
disabled={busy || !message.trim() || !target}
|
||
data-testid="add-diplomacy"
|
||
>
|
||
Add to round
|
||
</Button>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="advisor" className="space-y-2">
|
||
<Input
|
||
value={prompt}
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
||
data-testid="advisor-prompt"
|
||
/>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
||
disabled={advisorBusy}
|
||
data-testid="ask-advisor"
|
||
>
|
||
{advisorBusy && <Loader2 className="size-3.5 animate-spin" />}
|
||
{advisorBusy ? 'Thinking…' : 'Ask advisor'}
|
||
</Button>
|
||
{/* While the advisor (a separate LLM call) is thinking, mirror the
|
||
turn's "sim-hint" so the user knows the request is in flight. */}
|
||
{advisorBusy && (
|
||
<p
|
||
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||
data-testid="advisor-hint"
|
||
>
|
||
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||
The advisor is thinking… this can take a few seconds.
|
||
</p>
|
||
)}
|
||
{advisors.length > 0 && (
|
||
<ul className="space-y-1.5" data-testid="advisor-list">
|
||
{advisors.map((a, i) => (
|
||
<li
|
||
key={i}
|
||
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
|
||
data-testid="advisor-item"
|
||
>
|
||
<span className="flex-1 leading-snug">{a}</span>
|
||
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
|
||
Use
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* Pending queue + the prominent commit — always visible, in any mode. */}
|
||
<div className="mt-3 space-y-2">
|
||
<div className="rounded-lg border border-border/60 bg-card/40 p-2.5" data-testid="pending-list">
|
||
{pending.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground" data-testid="pending-empty">
|
||
Nothing queued — add an action or a message, then jump forward.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<div className="mb-1.5 flex items-center justify-between">
|
||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
Queued this round
|
||
</span>
|
||
<Badge variant="secondary" className="text-[11px] tabular-nums">
|
||
{pending.length}
|
||
</Badge>
|
||
</div>
|
||
<ul className="space-y-1">
|
||
{pending.map((item, i) => (
|
||
<li
|
||
key={`${i}-${item.text}`}
|
||
className="flex items-center gap-2 text-sm"
|
||
data-testid="pending-item"
|
||
data-kind={item.kind}
|
||
>
|
||
{item.kind === 'action' ? (
|
||
<Zap className="size-3.5 shrink-0 text-amber-400" aria-hidden />
|
||
) : (
|
||
<MessageSquare className="size-3.5 shrink-0 text-cyan-400" aria-hidden />
|
||
)}
|
||
{item.kind === 'diplomacy' && (
|
||
<span className="shrink-0 text-xs text-muted-foreground">
|
||
→ {nationName(item.to)}
|
||
</span>
|
||
)}
|
||
<span className="min-w-0 flex-1 truncate text-foreground/85">{item.text}</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||
onClick={() => setConfirmRemoveIndex(i)}
|
||
disabled={busy}
|
||
title="Remove from this round"
|
||
aria-label="Remove this queued item"
|
||
data-testid="remove-queue-item"
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Confirmation for removing a queued item (never removes without asking). */}
|
||
<Dialog open={removeTarget !== undefined} onOpenChange={(open) => !open && setConfirmRemoveIndex(null)}>
|
||
<DialogContent className="max-w-sm" data-testid="remove-confirm-dialog">
|
||
<DialogHeader>
|
||
<DialogTitle>Remove queued item?</DialogTitle>
|
||
<DialogDescription>
|
||
{removeTarget ? (
|
||
<>
|
||
{describeItem(removeTarget, nationName)} will be removed from this round and
|
||
won’t be simulated on the next jump.
|
||
</>
|
||
) : null}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setConfirmRemoveIndex(null)} data-testid="remove-cancel">
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={() => {
|
||
if (confirmRemoveIndex !== null) onRemoveQueueItem(confirmRemoveIndex);
|
||
setConfirmRemoveIndex(null);
|
||
}}
|
||
data-testid="remove-confirm"
|
||
>
|
||
<Trash2 className="size-4" />
|
||
Remove
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<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}
|
||
</Button>
|
||
{busy && (
|
||
<p
|
||
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"
|
||
data-testid="sim-hint"
|
||
>
|
||
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||
The world is thinking — turns can take a few seconds.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
if (inWindow) {
|
||
return (
|
||
<div data-testid="action-panel" className="p-4">
|
||
{body}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card className="gap-4" data-testid="action-panel">
|
||
{header}
|
||
<CardContent>{body}</CardContent>
|
||
</Card>
|
||
);
|
||
}
|