From c19db2c118cc582fc28909fdba1f53ef254dec9c Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 11 Oct 2026 18:32:09 +0000 Subject: [PATCH] advisor: show a "thinking" indicator during advisor fetches (v0.6.0) --- AGENTS.md | 4 ++++ CHANGELOG.md | 4 ++++ e2e/sim.spec.ts | 43 ++++++++++++++++++++++++++++++++++ package.json | 2 +- src/App.tsx | 30 +++++++++++++++++++++--- src/components/ActionPanel.tsx | 24 +++++++++++++++++-- 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8845c67..dd86e87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,10 @@ history/ 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 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 add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` / `removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite, diff --git a/CHANGELOG.md b/CHANGELOG.md index ff771c6..d9400bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ 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 - **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. diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index b02d746..5e4bdfc 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -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); }); }); + +/** + * 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/); + }); +}); diff --git a/package.json b/package.json index ad5f415..cc9d003 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/src/App.tsx b/src/App.tsx index 847efa5..306c1d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -173,6 +173,10 @@ export default function App() { const [regions, setRegions] = useState({}); const [advisors, setAdvisors] = useState([]); 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(() => 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} + {/* 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. */} - {busy ? : } - {busy ? 'Simulating…' : 'Idle'} + {advisorBusy || busy ? ( + + ) : ( + + )} + {advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'} + {/* 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 && ( +

+ + The advisor is thinking… this can take a few seconds. +

+ )} {advisors.length > 0 && (
    {advisors.map((a, i) => (