v0.6.0 — advisor "thinking" indicator #18
@@ -120,6 +120,10 @@ history/
|
|||||||
own. This design reduces LLM call frequency and makes each call a coherent batch of work.
|
own. This design reduces LLM call frequency and makes each call a coherent batch of work.
|
||||||
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
|
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
|
||||||
trash button per row with a confirm dialog); like the adds it never advances the turn.
|
trash button per row with a confirm dialog); like the adds it never advances the turn.
|
||||||
|
- **Advisor "thinking" state (separate from the turn `busy`).** The Advisor tab shows a thinking hint
|
||||||
|
(mirroring the turn's `sim-hint`) and disables its "Ask advisor" button while the advisor LLM call is
|
||||||
|
in flight, and the top-bar `status-indicator` reads "Advisor thinking…". It is a distinct `advisorBusy`
|
||||||
|
flag in `App.tsx` and must never gate "Jump forward" or the turn flow.
|
||||||
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
|
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
|
||||||
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
|
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
|
||||||
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
|
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.6.0 — 2026-10-11 18:27
|
||||||
|
|
||||||
|
- Advisor fetches now show a "thinking" indicator (mirroring the turn's "Simulating…" hint) while the model responds, and the Ask-advisor button disables during the fetch; the top-bar status shows "Advisor thinking…" in the meantime. Fixes #15.
|
||||||
|
|
||||||
## v0.5.0 — 2026-10-11 17:36
|
## v0.5.0 — 2026-10-11 17:36
|
||||||
|
|
||||||
- **HUD revamp** — removed the "Map" window (the full-screen map is directly interactive via scroll/trackpad; no floating zoom control, and the now-dead `MapApi`/`onMapReady` map plumbing is removed from `WorldMap`); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a new `pinned` mode on `FloatingWindow` — viewport-centered, no drag, no persisted position, always above other windows); and the dossier / event-feed / action panels can each be docked into a left or right full-height pane (a new `dock`/`onDockChange` mode on `FloatingWindow`, with small left/right/float controls in each title bar, choice persisted in `localStorage` and reproduced on reload; the default "float" reproduces the current first-run layout exactly). The legend stays a small floating widget. Fixes #4, Fixes #5.
|
- **HUD revamp** — removed the "Map" window (the full-screen map is directly interactive via scroll/trackpad; no floating zoom control, and the now-dead `MapApi`/`onMapReady` map plumbing is removed from `WorldMap`); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a new `pinned` mode on `FloatingWindow` — viewport-centered, no drag, no persisted position, always above other windows); and the dossier / event-feed / action panels can each be docked into a left or right full-height pane (a new `dock`/`onDockChange` mode on `FloatingWindow`, with small left/right/float controls in each title bar, choice persisted in `localStorage` and reproduced on reload; the default "float" reproduces the current first-run layout exactly). The legend stays a small floating widget. Fixes #4, Fixes #5.
|
||||||
|
|||||||
@@ -517,3 +517,46 @@ test.describe('History — HUD revamp (pinned top bar, dockable panels, no Map w
|
|||||||
expect(Math.abs(after.width - 300)).toBeLessThanOrEqual(3);
|
expect(Math.abs(after.width - 300)).toBeLessThanOrEqual(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advisor "thinking" indicator (issue #15). Asking the advisor is a *separate*
|
||||||
|
* LLM call with its own `advisorBusy` flag — it must never block the turn flow.
|
||||||
|
* Under the deterministic stub the fetch resolves in milliseconds, so the
|
||||||
|
* transient thinking state is too short to assert mid-flight reliably. Instead
|
||||||
|
* we assert the *terminal* states, which are deterministic: before the fetch the
|
||||||
|
* button is enabled and the hint is absent; after it, the advisor list is
|
||||||
|
* populated (5 canned ideas), the hint is gone, the button is re-enabled, and
|
||||||
|
* the top bar is back to "Idle".
|
||||||
|
*/
|
||||||
|
test.describe('History — Advisor thinking indicator (#15)', () => {
|
||||||
|
test('asking the advisor populates the list and clears the busy state', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||||
|
|
||||||
|
// Open the Advisor tab.
|
||||||
|
await page.getByTestId('tab-advisor').click();
|
||||||
|
|
||||||
|
// Before any fetch: the button is enabled, no thinking hint, no results yet.
|
||||||
|
const ask = page.getByTestId('ask-advisor');
|
||||||
|
await expect(ask).toBeEnabled();
|
||||||
|
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('advisor-list')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Ask the advisor (with an optional focus). The stub returns 5 canned ideas.
|
||||||
|
await page.getByTestId('advisor-prompt').fill('the nuclear standoff');
|
||||||
|
await ask.click();
|
||||||
|
|
||||||
|
// Terminal state: the result populates the advisor list.
|
||||||
|
await expect(page.getByTestId('advisor-list')).toBeVisible({ timeout: 20000 });
|
||||||
|
await expect(page.getByTestId('advisor-item')).toHaveCount(5, { timeout: 20000 });
|
||||||
|
|
||||||
|
// The fetch finished: the thinking hint is gone and the button is re-enabled.
|
||||||
|
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
|
||||||
|
await expect(ask).toBeEnabled();
|
||||||
|
|
||||||
|
// Nothing is in flight now, so the top bar reports idle (not the turn flow).
|
||||||
|
await expect(page.getByTestId('status-indicator')).toHaveText(/Idle/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
|
|||||||
+27
-3
@@ -173,6 +173,10 @@ export default function App() {
|
|||||||
const [regions, setRegions] = useState<RegionMap>({});
|
const [regions, setRegions] = useState<RegionMap>({});
|
||||||
const [advisors, setAdvisors] = useState<string[]>([]);
|
const [advisors, setAdvisors] = useState<string[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
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
|
// 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.
|
// list of saved games (newest first) shown on the home screen.
|
||||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||||
@@ -406,14 +410,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [game, applyState]);
|
}, [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(
|
const askAdvisor = useCallback(
|
||||||
async (prompt?: string) => {
|
async (prompt?: string) => {
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
|
setAdvisorBusy(true);
|
||||||
try {
|
try {
|
||||||
const { suggestions } = await api.advisor(game.id, prompt);
|
const { suggestions } = await api.advisor(game.id, prompt);
|
||||||
setAdvisors(suggestions);
|
setAdvisors(suggestions);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setAdvisorBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[game],
|
[game],
|
||||||
@@ -466,6 +477,7 @@ export default function App() {
|
|||||||
game={game}
|
game={game}
|
||||||
regions={regions}
|
regions={regions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
advisorBusy={advisorBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={runAddAction}
|
onAddAction={runAddAction}
|
||||||
onAddDiplomacy={runAddDiplomacy}
|
onAddDiplomacy={runAddDiplomacy}
|
||||||
@@ -505,7 +517,10 @@ export default function App() {
|
|||||||
interface GameScreenProps {
|
interface GameScreenProps {
|
||||||
game: GameState;
|
game: GameState;
|
||||||
regions: RegionMap;
|
regions: RegionMap;
|
||||||
|
/** A turn ("Jump forward") is in flight — drives the turn flow + commit button. */
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
||||||
|
advisorBusy: boolean;
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
onAddAction: (a: string) => void;
|
onAddAction: (a: string) => void;
|
||||||
onAddDiplomacy: (to: string, m: string) => void;
|
onAddDiplomacy: (to: string, m: string) => void;
|
||||||
@@ -533,6 +548,7 @@ function GameScreen({
|
|||||||
game,
|
game,
|
||||||
regions,
|
regions,
|
||||||
busy,
|
busy,
|
||||||
|
advisorBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
@@ -650,17 +666,24 @@ function GameScreen({
|
|||||||
{player?.name ?? game.playerNationId}
|
{player?.name ?? game.playerNationId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<span
|
||||||
data-testid="status-indicator"
|
data-testid="status-indicator"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
'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-primary/40 bg-primary/10 text-primary'
|
||||||
: 'border-border bg-muted/40 text-muted-foreground',
|
: 'border-border bg-muted/40 text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />}
|
{advisorBusy || busy ? (
|
||||||
{busy ? 'Simulating…' : 'Idle'}
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CircleDot className="size-3" />
|
||||||
|
)}
|
||||||
|
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -756,6 +779,7 @@ function GameScreen({
|
|||||||
<ActionPanel
|
<ActionPanel
|
||||||
game={game}
|
game={game}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
advisorBusy={advisorBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={onAddAction}
|
onAddAction={onAddAction}
|
||||||
onAddDiplomacy={onAddDiplomacy}
|
onAddDiplomacy={onAddDiplomacy}
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ import type { GameState, RoundItem } from '@shared/types';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: GameState;
|
game: GameState;
|
||||||
/** A jump (the only LLM call) is currently in flight — disable the commit. */
|
/** A jump (the only turn LLM call) is currently in flight — disable the commit. */
|
||||||
busy: boolean;
|
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. */
|
/** Advisor brainstorm suggestions from the last request. */
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
/** Queue a free-text action onto the pending round (no turn advance). */
|
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||||
@@ -59,6 +65,7 @@ function describeItem(item: RoundItem, nationName: (id?: string) => string): str
|
|||||||
export default function ActionPanel({
|
export default function ActionPanel({
|
||||||
game,
|
game,
|
||||||
busy,
|
busy,
|
||||||
|
advisorBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
@@ -194,10 +201,23 @@ export default function ActionPanel({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
||||||
|
disabled={advisorBusy}
|
||||||
data-testid="ask-advisor"
|
data-testid="ask-advisor"
|
||||||
>
|
>
|
||||||
Ask advisor
|
{advisorBusy && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{advisorBusy ? 'Thinking…' : 'Ask advisor'}
|
||||||
</Button>
|
</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 && (
|
{advisors.length > 0 && (
|
||||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||||
{advisors.map((a, i) => (
|
{advisors.map((a, i) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user