advisor: show a "thinking" indicator during advisor fetches (v0.6.0)
CI / build + unit + e2e (pull_request) Successful in 1m33s

This commit is contained in:
2026-10-11 18:32:09 +00:00
parent 977bf584ce
commit c19db2c118
6 changed files with 101 additions and 6 deletions
+27 -3
View File
@@ -173,6 +173,10 @@ export default function App() {
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());
@@ -406,14 +410,21 @@ export default function App() {
}
}, [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],
@@ -466,6 +477,7 @@ export default function App() {
game={game}
regions={regions}
busy={busy}
advisorBusy={advisorBusy}
advisors={advisors}
onAddAction={runAddAction}
onAddDiplomacy={runAddDiplomacy}
@@ -505,7 +517,10 @@ export default function App() {
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;
@@ -533,6 +548,7 @@ function GameScreen({
game,
regions,
busy,
advisorBusy,
advisors,
onAddAction,
onAddDiplomacy,
@@ -650,17 +666,24 @@ function GameScreen({
{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',
busy
advisorBusy || busy
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border bg-muted/40 text-muted-foreground',
)}
>
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />}
{busy ? 'Simulating…' : 'Idle'}
{advisorBusy || busy ? (
<Loader2 className="size-3 animate-spin" />
) : (
<CircleDot className="size-3" />
)}
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
</span>
<Button
variant="secondary"
@@ -756,6 +779,7 @@ function GameScreen({
<ActionPanel
game={game}
busy={busy}
advisorBusy={advisorBusy}
advisors={advisors}
onAddAction={onAddAction}
onAddDiplomacy={onAddDiplomacy}