0.4.0 — fix queue-add race + per-item remove button (#10, #11)

This commit is contained in:
2026-10-11 16:09:29 +00:00
parent 3287bcaca4
commit ef27b59762
14 changed files with 448 additions and 29 deletions
+76 -5
View File
@@ -1,13 +1,21 @@
import { useState } from 'react';
import { Loader2, MessageSquare, Radar, Zap } from 'lucide-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 } from '@shared/types';
import type { GameState, RoundItem } from '@shared/types';
interface Props {
game: GameState;
@@ -19,6 +27,8 @@ interface Props {
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;
@@ -29,13 +39,22 @@ interface Props {
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 and one prominent "Jump forward" button commit the whole round in a
* single LLM turn. While a jump is in flight the commit is disabled and a hint
* is shown; an empty jump (nothing queued) is allowed and advances time.
* 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,
@@ -43,6 +62,7 @@ export default function ActionPanel({
advisors,
onAddAction,
onAddDiplomacy,
onRemoveQueueItem,
onAdvisor,
onJump,
inWindow = false,
@@ -52,6 +72,8 @@ export default function ActionPanel({
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)
@@ -79,6 +101,10 @@ export default function ActionPanel({
};
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` : ''}`;
@@ -227,6 +253,18 @@ export default function ActionPanel({
</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>
@@ -234,6 +272,39 @@ export default function ActionPanel({
)}
</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}