diff --git a/messages/de.json b/messages/de.json
index 2d115aed0..779d21e65 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4172,6 +4172,8 @@
"regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.",
"regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.",
"regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen.",
+ "regenerateSettling": "Die Einschätzung wird noch erstellt",
+ "regenerateSettleFailed": "Die Aktualisierung konnte nicht abgeschlossen werden. Bitte später erneut versuchen.",
"deviceScore": {
"learning": "Deine Basislinie wird noch gelernt – ein paar weitere Nächte vervollständigen den Verlauf.",
"average": "30-Tage-Durchschnitt {value}",
diff --git a/messages/en.json b/messages/en.json
index 15f1df75d..f417c2673 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4172,6 +4172,8 @@
"regenerateRateLimited": "Refresh limit reached — please try again later.",
"regenerateUnavailable": "No fresh analysis arrived — please try again later.",
"regenerateError": "Still refreshing — please try again in a moment.",
+ "regenerateSettling": "Your assessment is still being prepared",
+ "regenerateSettleFailed": "The refresh could not be completed. Please try again later.",
"deviceScore": {
"learning": "Still learning your baseline — a few more nights will fill in the trend.",
"average": "30-day average {value}",
diff --git a/messages/es.json b/messages/es.json
index 13c66febd..09bea375a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4172,6 +4172,8 @@
"regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.",
"regenerateUnavailable": "No llegó un análisis nuevo: inténtalo de nuevo más tarde.",
"regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento.",
+ "regenerateSettling": "La valoración todavía se está preparando",
+ "regenerateSettleFailed": "No se pudo completar la actualización. Inténtalo de nuevo más tarde.",
"deviceScore": {
"learning": "Aún estamos aprendiendo tu línea base: unas noches más completarán la tendencia.",
"average": "Media de 30 días {value}",
diff --git a/messages/fr.json b/messages/fr.json
index dd414bccc..f55859fba 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4172,6 +4172,8 @@
"regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.",
"regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.",
"regenerateError": "Actualisation en cours — réessayez dans un instant.",
+ "regenerateSettling": "L’évaluation est encore en cours de préparation",
+ "regenerateSettleFailed": "L’actualisation n’a pas pu aboutir. Réessayez plus tard.",
"deviceScore": {
"learning": "Nous apprenons encore votre référence : quelques nuits de plus compléteront la tendance.",
"average": "Moyenne sur 30 jours {value}",
diff --git a/messages/it.json b/messages/it.json
index 7ee83cfc5..844cdb904 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4172,6 +4172,8 @@
"regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.",
"regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.",
"regenerateError": "Aggiornamento ancora in corso — riprova tra un momento.",
+ "regenerateSettling": "La valutazione è ancora in preparazione",
+ "regenerateSettleFailed": "Non è stato possibile completare l’aggiornamento. Riprova più tardi.",
"deviceScore": {
"learning": "Stiamo ancora imparando la tua linea di base: qualche notte in più completerà l'andamento.",
"average": "Media a 30 giorni {value}",
diff --git a/messages/pl.json b/messages/pl.json
index 91caae79a..082ee3bac 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4172,6 +4172,8 @@
"regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.",
"regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.",
"regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę.",
+ "regenerateSettling": "Ocena jest wciąż przygotowywana",
+ "regenerateSettleFailed": "Nie udało się ukończyć odświeżania. Spróbuj ponownie później.",
"deviceScore": {
"learning": "Wciąż poznajemy Twoją linię bazową – kilka kolejnych nocy uzupełni trend.",
"average": "Średnia z 30 dni {value}",
diff --git a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts
index d26443e4b..ddcf1900b 100644
--- a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts
+++ b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts
@@ -87,7 +87,7 @@ beforeEach(() => {
describe("POST /api/documents/inbound/reindex", () => {
it("returns the remaining un-indexed count as a NUMBER, never a boolean", async () => {
- const res = await POST(req() as never, {} as never);
+ const res = await POST(req() as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(typeof body.data.enqueued).toBe("number");
@@ -98,14 +98,14 @@ describe("POST /api/documents/inbound/reindex", () => {
vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({
enqueued: false,
});
- const res = await POST(req() as never, {} as never);
+ const res = await POST(req() as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.enqueued).toBe(0);
});
it("scopes both counts to the caller's LIVE documents (the gauge's own figures)", async () => {
- await POST(req() as never, {} as never);
+ await POST(req() as never);
expect(prisma.inboundDocument.count).toHaveBeenCalledWith({
where: { userId: "user-1", deletedAt: null },
});
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 1cd21297c..8c13b2c62 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -1,3 +1,16 @@
+/**
+ * Refs #786 — async-truth contract, server end (the client end lives in
+ * `src/components/insights/use-insights-advisor.ts`, `AdvisorSettleState`):
+ * the force POST generates INLINE and, on success, writes the fresh briefing
+ * to the user cache (`insightsCachedAt` advances) — even when the client has
+ * long since aborted its 45 s request. A failed inline generation writes a
+ * failure marker the GET reports as `generationFailed`. The client relies on
+ * exactly those two signals to settle a timed-out regenerate: it polls the
+ * GET until `cachedAt` advances past its baseline (success) or a NEWER
+ * failure marker appears (honest error). Renaming or short-circuiting either
+ * write breaks that convergence — the settling seam tests pin the client
+ * half, this comment is the server half of the contract.
+ */
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import {
diff --git a/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx b/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx
new file mode 100644
index 000000000..c296cab43
--- /dev/null
+++ b/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx
@@ -0,0 +1,89 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+/**
+ * Refs #786 — the regenerate button's settling state.
+ *
+ * While a timed-out force regenerate is settling (the server is still
+ * generating; the hook polls for the outcome) the button may NOT offer a
+ * retry or read as failed: it stays disabled, keeps the spinner, and its
+ * label says the assessment is still being prepared. Static renders pin the
+ * three visible facts; the toast edges live in the hook/strip effect and are
+ * pinned by the outcome mapping (`regenerateToastKind`).
+ */
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/insights",
+}));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+import { InsightsTabStrip, regenerateToastKind } from "../insights-tab-strip";
+import { I18nProvider } from "@/lib/i18n/context";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe(" regenerate settling state (#786)", () => {
+ it("settling: button disabled, spinner on, label says still being prepared", () => {
+ const html = render(
+ {}}
+ regenerating={false}
+ regenerateSettling
+ />,
+ );
+ expect(html).toContain('data-slot="insights-tab-strip-regenerate"');
+ expect(html).toContain("Your assessment is still being prepared");
+ expect(html).toContain("disabled");
+ expect(html).toContain("animate-spin");
+ });
+
+ it("idle: button enabled with the regenerate label", () => {
+ const html = render(
+ {}} regenerating={false} />,
+ );
+ expect(html).toContain("Re-run assessment");
+ expect(html).not.toContain("Your assessment is still being prepared");
+ });
+});
+
+describe("regenerateToastKind — falling-edge outcome mapping (#786)", () => {
+ it("maps settle-failed to the honest settle error, never success", () => {
+ expect(regenerateToastKind("settle-failed")).toEqual({
+ kind: "error",
+ messageKey: "insights.regenerateSettleFailed",
+ });
+ });
+
+ it("keeps the existing mappings", () => {
+ expect(regenerateToastKind("fresh")).toEqual({
+ kind: "success",
+ messageKey: "insights.regenerateSuccess",
+ });
+ expect(regenerateToastKind("timeout")).toEqual({
+ kind: "error",
+ messageKey: "insights.regenerateError",
+ });
+ expect(regenerateToastKind("rate-limited")).toEqual({
+ kind: "error",
+ messageKey: "insights.regenerateRateLimited",
+ });
+ expect(regenerateToastKind("empty")).toEqual({
+ kind: "error",
+ messageKey: "insights.regenerateUnavailable",
+ });
+ expect(regenerateToastKind("no-provider")).toBe(null);
+ // Legacy mounts without an outcome keep the unconditional success.
+ expect(regenerateToastKind(null)).toEqual({
+ kind: "success",
+ messageKey: "insights.regenerateSuccess",
+ });
+ expect(regenerateToastKind(undefined)).toEqual({
+ kind: "success",
+ messageKey: "insights.regenerateSuccess",
+ });
+ });
+});
diff --git a/src/components/insights/__tests__/use-insights-advisor-settling.test.ts b/src/components/insights/__tests__/use-insights-advisor-settling.test.ts
new file mode 100644
index 000000000..855c1c6cf
--- /dev/null
+++ b/src/components/insights/__tests__/use-insights-advisor-settling.test.ts
@@ -0,0 +1,143 @@
+import { describe, it, expect } from "vitest";
+
+/**
+ * Refs #786 — the async-truth contract for the force regenerate.
+ *
+ * A regenerate the client gives up on at 45 s is NOT a failure: the server's
+ * inline generation keeps running and usually writes the cache moments later.
+ * The old behaviour fired an error toast and then never converged (staleTime
+ * 1 h + focus refetch off), so the page kept showing the old assessment while
+ * claiming the refresh failed. The fix enters a "settling" state: remember
+ * the baseline `cachedAt`, keep polling on the existing bounded cadence, and
+ * resolve to a REAL outcome — fresh (cache advanced), settle-failed (a newer
+ * failure marker, or the attempt cap).
+ *
+ * Two pure seams pin the state machine:
+ * - `nextAdvisorPollInterval` gains settling as the second poll reason,
+ * with attempts counted from the settle start.
+ * - `resolveAdvisorSettle` decides fresh / settle-failed / keep-waiting.
+ */
+
+import {
+ ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
+ ADVISOR_REVALIDATE_POLL_MS,
+ nextAdvisorPollInterval,
+ resolveAdvisorSettle,
+ type AdvisorSettleState,
+} from "../use-insights-advisor";
+
+function settle(
+ overrides: Partial = {},
+): AdvisorSettleState {
+ return {
+ baselineCachedAt: "2026-08-13T06:00:00.000Z",
+ baselineFailed: false,
+ startedAtUpdateCount: 4,
+ ...overrides,
+ };
+}
+
+describe("nextAdvisorPollInterval — settling is the second poll reason (#786)", () => {
+ it("polls while settling even when nothing reports revalidating", () => {
+ expect(nextAdvisorPollInterval(false, 5, settle())).toBe(
+ ADVISOR_REVALIDATE_POLL_MS,
+ );
+ expect(nextAdvisorPollInterval(undefined, 5, settle())).toBe(
+ ADVISOR_REVALIDATE_POLL_MS,
+ );
+ });
+
+ it("counts settling attempts from the settle start, not from mount", () => {
+ // 12 data updates total but only 8 since settling began → keep polling.
+ expect(
+ nextAdvisorPollInterval(false, 12, settle({ startedAtUpdateCount: 4 })),
+ ).toBe(ADVISOR_REVALIDATE_POLL_MS);
+ });
+
+ it("stops at the settling attempt ceiling", () => {
+ expect(
+ nextAdvisorPollInterval(
+ false,
+ 4 + ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
+ settle({ startedAtUpdateCount: 4 }),
+ ),
+ ).toBe(false);
+ });
+
+ it("keeps the pre-existing revalidating behaviour when not settling", () => {
+ expect(nextAdvisorPollInterval(true, 1, null)).toBe(
+ ADVISOR_REVALIDATE_POLL_MS,
+ );
+ expect(nextAdvisorPollInterval(false, 1, null)).toBe(false);
+ expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS);
+ });
+});
+
+describe("resolveAdvisorSettle — the settle state machine (#786)", () => {
+ it("resolves fresh once cachedAt advances past the baseline", () => {
+ expect(
+ resolveAdvisorSettle(
+ settle(),
+ { cachedAt: "2026-08-13T06:02:31.000Z", generationFailed: false },
+ 6,
+ ),
+ ).toBe("fresh");
+ });
+
+ it("resolves fresh on ANY cachedAt when there was no baseline (first generation)", () => {
+ expect(
+ resolveAdvisorSettle(
+ settle({ baselineCachedAt: null }),
+ { cachedAt: "2026-08-13T06:02:31.000Z", generationFailed: false },
+ 6,
+ ),
+ ).toBe("fresh");
+ });
+
+ it("keeps waiting while the cache is unchanged and attempts remain", () => {
+ expect(
+ resolveAdvisorSettle(
+ settle(),
+ { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: false },
+ 6,
+ ),
+ ).toBe(null);
+ });
+
+ it("resolves settle-failed on a NEWER failure marker (failed flipped during settling)", () => {
+ expect(
+ resolveAdvisorSettle(
+ settle({ baselineFailed: false }),
+ { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: true },
+ 6,
+ ),
+ ).toBe("settle-failed");
+ });
+
+ it("does NOT treat a failure marker that predates the settle as fresh evidence", () => {
+ // The baseline already carried the failed flag — an unchanged flag says
+ // nothing about THIS generation; keep waiting for cachedAt or the cap.
+ expect(
+ resolveAdvisorSettle(
+ settle({ baselineFailed: true }),
+ { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: true },
+ 6,
+ ),
+ ).toBe(null);
+ });
+
+ it("resolves settle-failed at the attempt cap — never waits forever", () => {
+ expect(
+ resolveAdvisorSettle(
+ settle({ startedAtUpdateCount: 4 }),
+ { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: false },
+ 4 + ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
+ ),
+ ).toBe("settle-failed");
+ });
+
+ it("a missing payload keeps waiting (no evidence either way)", () => {
+ expect(resolveAdvisorSettle(settle(), null, 6)).toBe(null);
+ expect(resolveAdvisorSettle(settle(), undefined, 6)).toBe(null);
+ });
+});
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
index bc861d344..dbf4d0ea6 100644
--- a/src/components/insights/insights-layout-shell.tsx
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -200,6 +200,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
onRegenerate={advisorEnabled ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
regenerateOutcome={advisor.regenerateOutcome}
+ regenerateSettling={advisor.regenerateSettling}
availability={availability}
visibleTileIds={visibleTileIds}
tileOrder={tileOrder}
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx
index 33e41e837..60f6613d0 100644
--- a/src/components/insights/insights-tab-strip.tsx
+++ b/src/components/insights/insights-tab-strip.tsx
@@ -88,7 +88,20 @@ export interface InsightsTabStripProps {
* mounts / no regenerate) falls back to the prior unconditional success.
*/
regenerateOutcome?:
- "fresh" | "empty" | "rate-limited" | "timeout" | "no-provider" | null;
+ | "fresh"
+ | "empty"
+ | "rate-limited"
+ | "timeout"
+ | "no-provider"
+ | "settle-failed"
+ | null;
+ /**
+ * Refs #786 — true while a timed-out regenerate is settling (the server is
+ * still generating; the hook polls for the proven outcome). The button
+ * stays busy with a "still being prepared" label, and the falling-edge
+ * toast waits for the COMBINED busy state so no error fires mid-settle.
+ */
+ regenerateSettling?: boolean;
/**
* v1.4.27 F19 — analytics + event-driven availability inputs the
* gating helper reads. When omitted the strip falls back to its
@@ -637,10 +650,38 @@ function buildTabs(
return entries;
}
+/**
+ * Refs #786 — the falling-edge toast decision as a pure seam. Maps the last
+ * settled outcome to the toast that fires when the busy state falls:
+ * `settle-failed` (a settling poll proved the generation failed, or the
+ * attempt cap ran out) gets its own honest error copy; `no-provider` stays
+ * silent (the surface already shows the connect-AI empty state); a missing
+ * outcome (legacy mounts) keeps the historical unconditional success.
+ */
+export function regenerateToastKind(
+ outcome: InsightsTabStripProps["regenerateOutcome"] | undefined,
+): { kind: "success" | "error"; messageKey: string } | null {
+ switch (outcome) {
+ case "timeout":
+ return { kind: "error", messageKey: "insights.regenerateError" };
+ case "rate-limited":
+ return { kind: "error", messageKey: "insights.regenerateRateLimited" };
+ case "empty":
+ return { kind: "error", messageKey: "insights.regenerateUnavailable" };
+ case "settle-failed":
+ return { kind: "error", messageKey: "insights.regenerateSettleFailed" };
+ case "no-provider":
+ return null;
+ default:
+ return { kind: "success", messageKey: "insights.regenerateSuccess" };
+ }
+}
+
function InsightsTabStripImpl({
onRegenerate,
regenerating = false,
regenerateOutcome,
+ regenerateSettling = false,
availability,
visibleTileIds,
tileOrder,
@@ -686,24 +727,33 @@ function InsightsTabStripImpl({
// regenerate claimed "refreshed" while serving the old text. The latest
// outcome is read through a ref-free dep so the effect fires only on the
// spinner edge.
- const lastRegeneratingRef = useRef(regenerating);
+ //
+ // Refs #786 — the edge watches the COMBINED busy state (`regenerating ||
+ // regenerateSettling`): a timed-out regenerate flows straight into
+ // settling, so the spinner never falls at 45 s and NO error toast fires
+ // while the server may still deliver. The edge fires once settling
+ // resolves, with the proven outcome — `"fresh"` (success) or
+ // `"settle-failed"` (its own honest error copy). The mapping is the pure
+ // `regenerateToastKind` seam above.
+ const busy = regenerating || regenerateSettling;
+ const lastBusyRef = useRef(busy);
useEffect(() => {
- if (lastRegeneratingRef.current && !regenerating) {
- if (regenerateOutcome === "timeout") {
- toast.error(t("insights.regenerateError"));
- } else if (regenerateOutcome === "rate-limited") {
- toast.error(t("insights.regenerateRateLimited"));
- } else if (regenerateOutcome === "empty") {
- toast.error(t("insights.regenerateUnavailable"));
- } else if (regenerateOutcome !== "no-provider") {
- // `"fresh"` or legacy `undefined`/null → success.
- toast.success(t("insights.regenerateSuccess"));
+ if (lastBusyRef.current && !busy) {
+ const decision = regenerateToastKind(regenerateOutcome);
+ if (decision) {
+ if (decision.kind === "error") {
+ toast.error(t(decision.messageKey));
+ } else {
+ toast.success(t(decision.messageKey));
+ }
}
}
- lastRegeneratingRef.current = regenerating;
- }, [regenerating, regenerateOutcome, t]);
+ lastBusyRef.current = busy;
+ }, [busy, regenerateOutcome, t]);
- const regenerateLabel = t("insights.regenerateAnalysis");
+ const regenerateLabel = regenerateSettling
+ ? t("insights.regenerateSettling")
+ : t("insights.regenerateAnalysis");
const customizeLabel = t("insights.customize");
// Overflow affordance for the pill row. The row hides its scrollbar
@@ -1000,7 +1050,9 @@ function InsightsTabStripImpl({