diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md
index 3bcb4d850..5532421af 100644
--- a/.claude/skills/verify-debatelab/features/README.md
+++ b/.claude/skills/verify-debatelab/features/README.md
@@ -51,6 +51,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it.
- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete all my data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`).
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
+- [AI failures](./ai-failures.md): the reason and next step for a failed AI request: Sign in again after a refused session in Generate, Ask, Sparring and Crossfire, Retry after a network failure in Ask, and the plan dialog for a limit (`recipes/ai-failures.spec.ts`).
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
- Billing: expired account blocked, promo code restores access, and a discount code claimed once (a second Apply does not count again) stays in the pricing dialog and is sent with the upgrade (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` pages (`recipes/localization.spec.ts`).
diff --git a/.claude/skills/verify-debatelab/features/ai-failures.md b/.claude/skills/verify-debatelab/features/ai-failures.md
new file mode 100644
index 000000000..c0f1f8a7d
--- /dev/null
+++ b/.claude/skills/verify-debatelab/features/ai-failures.md
@@ -0,0 +1,27 @@
+# AI failures: the reason and the next step
+
+When an AI request fails, the tool says why in one plain sentence and offers the step that can work. A daily, weekly or plan limit opens the same plan dialog every tool uses, and a Pro-only model opens the upgrade dialog. When the server refuses the session (401, or a 403 that is not about the model), the error offers `Sign in again` instead of Retry, because sending again would be refused the same way. It signs the tab out and opens `/login` with `Your session expired. Sign in again.`, and signing in returns to the page. Timeouts, network failures, provider errors, malformed answers and server errors keep Retry. Crossfire and Sparring retries reuse the round's quota session, so a retry is not charged again. A request that is too large has no Retry. The mapping is `aiFailureRecovery` in `src/lib/ai/failure-reason.ts`, and the same file gives `ai_request_failed` its `error_code` and `stage`.
+
+## Sub-features
+
+- `fail-signin-tool`: Generate answered 401 shows `Sign-in required` with `Sign in again` and no Retry; the button lands on `/login` with the expired-session notice.
+- `fail-network-ask`: an Ask question whose request never reached the server shows `Check your connection and try again.` with Retry; once the network is back, Retry answers it and the question appears once.
+- `fail-signin-ask`: Ask answered 401 shows `Sign in again` in the chat, with no Retry.
+- `fail-limit-tool`: Brainstorm with `daily_limit_override: 0` opens `Daily AI uses used up`.
+- `fail-signin-sparring`: the opponent's turn answered 401 shows `The opponent could not answer` with `Sign in again` instead of `Retry opponent turn`.
+- `fail-signin-crossfire`: the AI's first question in an AI-led round answered 401 shows `Sign in again` instead of `retry-ai`.
+
+## How to get to it (user POV)
+
+- Any AI tool when its request fails: `/app/generate`, `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask`, `/app/sparring`, `/app/crossfire`.
+
+## Driving it with verify.sh + Playwright
+
+- `bin/verify.sh drive recipes/ai-failures` runs each sub-feature in its own test, on both viewports.
+- The recipe forces each failure in the browser with `page.route`: a 401 JSON answer for `functions/v1/ai` (letting a round's `quotaCheck` through) or `functions/v1/ai-stream`, or `route.abort("internetdisconnected")` for a network failure. The limit step sets `daily_limit_override: 0` through `admin`.
+- Provider failures with Retry are `gen-error` in `recipes/case-generation.spec.ts`; the Ask limit with Retry is `usage-ask-not-sent` in `recipes/usage-limits.spec.ts`.
+
+## Gotchas
+
+- A real 401 from a function makes the client refresh the session once (`verifySessionAfterRejection`). On this stack the refresh succeeds, so the tab stays signed in until `Sign in again` is clicked.
+- A timeout needs the 200-second client budget to pass, so no recipe forces one. `src/lib/ai/failure-reason.test.ts` covers its mapping.
diff --git a/.claude/skills/verify-debatelab/recipes/ai-failures.spec.ts b/.claude/skills/verify-debatelab/recipes/ai-failures.spec.ts
new file mode 100644
index 000000000..fd36ef690
--- /dev/null
+++ b/.claude/skills/verify-debatelab/recipes/ai-failures.spec.ts
@@ -0,0 +1,117 @@
+import type { Page, Route } from "@playwright/test";
+import { admin, AI_TIMEOUT, chooseForMotion, expect, startDebate, test } from "./fixtures";
+
+// Feature map: features/ai-failures.md. Each failure is forced in the browser,
+// so the provider is never called and the recipe runs the same in both modes.
+const motion = "This House would make public transport free at the point of use";
+const resolution = "Resolved: cities should ban private cars downtown.";
+
+/** Answers every `ai` request except a round's quota check as the server does for a refused session. */
+async function refuseSession(page: Page): Promise {
+ await page.route("**/functions/v1/ai", async (route: Route) => {
+ if (route.request().method() === "POST" && (route.request().postDataJSON() as { quotaCheck?: boolean } | null)?.quotaCheck) {
+ await route.fallback();
+ return;
+ }
+ await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "Invalid JWT" }) });
+ });
+}
+
+async function expectBackOnLogin(page: Page): Promise {
+ await expect(page).toHaveURL(/\/login$/, { timeout: AI_TIMEOUT });
+ await expect(page.getByRole("status").filter({ hasText: "Your session expired. Sign in again." })).toBeVisible();
+}
+
+test("fail-signin-tool: a refused session on Generate offers Sign in again, not Retry, and it opens the login page", async ({ signedIn: page, evidence }) => {
+ await startDebate(page, motion);
+ await page.goto("/app/generate");
+ await refuseSession(page);
+ await page.getByRole("button", { name: "Generate debate case" }).click();
+ const failure = page.getByRole("alert").filter({ hasText: "Sign-in required" });
+ await expect(failure).toBeVisible({ timeout: AI_TIMEOUT });
+ await expect(failure.getByRole("button", { name: "Retry" })).toHaveCount(0);
+ await evidence.shot("01-generate-sign-in-again");
+ await evidence.aria("01-generate-sign-in-again", failure);
+
+ await failure.getByRole("button", { name: "Sign in again" }).click();
+ await expectBackOnLogin(page);
+ await evidence.shot("02-login-after-sign-in-again");
+});
+
+test("fail-network-ask: a question that never reached the server stays with Retry, and Retry answers it once", async ({ signedIn: page, evidence }) => {
+ const question = "What is the strongest objection to free public transport?";
+ await page.goto("/app/ask");
+ await page.route("**/functions/v1/ai-stream", (route) => route.abort("internetdisconnected"));
+ await page.route("**/functions/v1/ai", (route) => route.abort("internetdisconnected"));
+ await page.getByRole("textbox", { name: "Message to your debate coach" }).fill(question);
+ await page.keyboard.press("Enter");
+ const failure = page.getByRole("alert").filter({ hasText: "Check your connection and try again." });
+ await expect(failure).toBeVisible({ timeout: AI_TIMEOUT });
+ await evidence.shot("01-ask-network-retry");
+ await evidence.aria("01-ask-network-retry", failure);
+
+ await page.unroute("**/functions/v1/ai-stream");
+ await page.unroute("**/functions/v1/ai");
+ const answer = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT });
+ await failure.getByRole("button", { name: "Retry" }).click();
+ await answer;
+ await expect(failure).toHaveCount(0);
+ await expect(page.getByText(question)).toHaveCount(1);
+ await evidence.shot("02-ask-answered-after-retry");
+});
+
+test("fail-signin-ask: a refused session in Ask offers Sign in again", async ({ signedIn: page, evidence }) => {
+ await page.goto("/app/ask");
+ await page.route("**/functions/v1/ai-stream", (route) => route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "Invalid JWT" }) }));
+ await refuseSession(page);
+ await page.getByRole("textbox", { name: "Message to your debate coach" }).fill("How do I weigh two impacts?");
+ await page.keyboard.press("Enter");
+ const failure = page.getByRole("alert").filter({ has: page.getByRole("button", { name: "Sign in again" }) });
+ await expect(failure).toBeVisible({ timeout: AI_TIMEOUT });
+ await expect(failure.getByRole("button", { name: "Retry" })).toHaveCount(0);
+ await evidence.shot("01-ask-sign-in-again");
+ await evidence.aria("01-ask-sign-in-again", failure);
+});
+
+test("fail-limit-tool: a tool refused by the daily limit opens the plan dialog", async ({ signedIn: page, user, evidence }) => {
+ await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId());
+ await page.goto("/app/brainstorm");
+ await page.getByLabel("Motion / topic *").fill(motion);
+ await page.getByRole("button", { name: "Start brainstorming" }).click();
+ await chooseForMotion(page, "once");
+ const dialog = page.getByRole("dialog").filter({ hasText: "Daily AI uses used up" });
+ await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT });
+ await evidence.shot("01-brainstorm-limit-dialog");
+ await evidence.aria("01-brainstorm-limit-dialog", dialog);
+});
+
+test("fail-signin-sparring: a refused session for the opponent's turn offers Sign in again instead of Retry", async ({ signedIn: page, evidence }) => {
+ await page.goto("/app/sparring");
+ await page.getByLabel("Motion").fill(motion);
+ await page.getByRole("combobox", { name: "Round format" }).click();
+ await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click();
+ await page.getByRole("button", { name: "Start round" }).click();
+ await chooseForMotion(page, "create");
+ const draft = page.getByRole("textbox", { name: "Draft for Affirmative constructive" });
+ await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
+ await refuseSession(page);
+ await draft.fill("Universal access removes the direct price barrier to participation in work, school, and civic life.");
+ await page.getByRole("button", { name: "Submit speech" }).click();
+ await expect(page.getByRole("heading", { name: "The opponent could not answer" })).toBeVisible({ timeout: AI_TIMEOUT });
+ await expect(page.getByRole("button", { name: "Retry opponent turn" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Sign in again" })).toBeVisible();
+ await evidence.shot("01-sparring-sign-in-again");
+});
+
+test("fail-signin-crossfire: a refused session for the AI's question offers Sign in again instead of Retry", async ({ signedIn: page, evidence }) => {
+ await page.goto("/app/crossfire");
+ await page.getByTestId("crossfire-resolution").fill(resolution);
+ await page.getByTestId("crossfire-argument").fill("Private cars create congestion and unsafe streets.");
+ await page.getByTestId("mode-ai-questions-user").click();
+ await refuseSession(page);
+ await page.getByTestId("start-crossfire").click();
+ await chooseForMotion(page, "create");
+ await expect(page.getByRole("button", { name: "Sign in again" })).toBeVisible({ timeout: AI_TIMEOUT });
+ await expect(page.getByTestId("retry-ai")).toHaveCount(0);
+ await evidence.shot("01-crossfire-sign-in-again");
+});
diff --git a/docs/architecture.md b/docs/architecture.md
index 6f8025ee7..ac1691b8f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -170,6 +170,10 @@ A run that produces nothing costs nothing. When an `ai` request fails on the ser
Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary.
+The facade accepts only the event names and property values listed in it. `ai_request_failed` says why a request failed: `tool`, `error_code` (one of `limit_reached`, `model_restricted`, `auth_expired`, `timeout`, `network`, `aborted`, `provider_error`, `invalid_response`, `server_error`, `unknown`, mapped from the client's AI error classes in [`src/lib/ai/failure-reason.ts`](../src/lib/ai/failure-reason.ts)), `http_status`, `stage` (`request`, `stream`, `parse`), `streaming` and `duration_ms`. The same file decides each failure's next step: the plan dialog, Sign in again, or Retry. The activation events in [`src/lib/activation-events.ts`](../src/lib/activation-events.ts) are `signup_completed` (`method`), `trial_started`, `paywall_shown` (`reason`, `tool`), `first_ai_success` (`tool`) and `welcome_completed`. The once-per-account ones remember what they sent in this browser's local storage. `first_ai_success` is sent only for an account made in the last seven days, so an older account's next answer is not counted as its first. PostHog keeps its own state in memory and is never told who the user is, so it cannot join one student's events across page loads.
+
+Every `React.lazy` component and route loader goes through `loadChunk` in [`src/lib/lazy-chunk.ts`](../src/lib/lazy-chunk.ts). After a deploy removes an old file, the page reloads once per path in ten minutes (the time is kept in `sessionStorage` and shared with the `vite:preloadError` listener, so a file still missing after the reload stays an error while a later deploy can reload again) instead of crashing on a module that loaded as nothing.
+
CI and staging must not upload authenticated browser captures. Logging must not expose tokens, provider keys, user content, or protected staging values.
## PWA and offline behavior
diff --git a/src/App.tsx b/src/App.tsx
index 3303cd74a..32d489de6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,7 @@
// src/App.tsx
import { Owl } from "@/components/brand/Owl";
import { buttonVariants } from "@/components/ui/button";
-import { Component, Suspense, lazy, useEffect, useRef, useState, type ComponentType, type ErrorInfo, type ReactNode } from "react";
+import { Component, Suspense, useEffect, useRef, useState, type ComponentType, type ErrorInfo, type ReactNode } from "react";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -19,6 +19,9 @@ import { focusCurrentDebate, noteTourChunkFailure, tourChunkGaveUp } from "@/lib
import { OfflineIndicator } from "@/components/OfflineIndicator";
import { StagingBanner } from "@/components/StagingBanner";
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
+import { captureSignupCompleted, captureTrialStarted, captureWelcomeCompleted, rememberActivationAccount } from "@/lib/activation-events";
+import { useSubscription } from "@/hooks/use-subscription";
+import { lazyChunk, loadChunk, namedChunk } from "@/lib/lazy-chunk";
import { appLayoutLoader, localizedRouteLoaders, preloadStartupRoute, routeLoaders, type RouteLoader } from "@/lib/route-modules";
import site from "@/config/site.json";
import type { PrerenderedRoute } from "@/lib/seo/pages";
@@ -41,7 +44,7 @@ function prerenderedPage(route: PrerenderedRoute) {
}
function prerenderedModule(loader: RouteLoader<{ default: ComponentType }>) {
- const LazyPage = lazy(loader);
+ const LazyPage = lazyChunk(loader);
return function PrerenderedPage() {
const [Page] = useState(() => loader.loaded()?.default ?? LazyPage);
return ;
@@ -56,28 +59,28 @@ const AboutPage = prerenderedPage("/about");
const ContactPage = prerenderedPage("/contact");
// The Hungarian and Spanish addresses in src/config/localized-routes.json.
const LOCALIZED_PAGES = LOCALIZED_PATHS.map((entry) => ({ ...entry, Page: prerenderedModule(localizedRouteLoaders[entry.path]) }));
-const HomePage = lazy(routeLoaders["/app"]);
-const CurrentDebatePage = lazy(routeLoaders["/app/debate"]);
-const WorkspacesPage = lazy(routeLoaders["/app/workspaces"]);
-const WorkspaceInvitePage = lazy(routeLoaders["/app/workspaces/invite"]);
-const WorkspacePage = lazy(routeLoaders["/app/workspaces/:workspaceId"]);
-const GeneratePage = lazy(routeLoaders["/app/generate"]);
-const AttackPage = lazy(routeLoaders["/app/attack"]);
-const BrainstormPage = lazy(routeLoaders["/app/brainstorm"]);
-const FactoidsPage = lazy(routeLoaders["/app/factoids"]);
-const AskPage = lazy(routeLoaders["/app/ask"]);
-const CrossfirePage = lazy(routeLoaders["/app/crossfire"]);
-const SettingsPage = lazy(routeLoaders["/app/settings"]);
-const HistoryPage = lazy(routeLoaders["/app/history"]);
-const LibraryPage = lazy(routeLoaders["/app/library"]);
-const TimerPage = lazy(routeLoaders["/app/timer"]);
-const SparringPage = lazy(routeLoaders["/app/sparring"]);
-const AdminPage = lazy(routeLoaders["/app/admin"]);
-const SharedPage = lazy(routeLoaders["/share/:code"]);
-const FlowchartPage = lazy(routeLoaders["/app/flowchart"]);
-const LoginPage = lazy(routeLoaders["/login"]);
-const AuthCallbackPage = lazy(routeLoaders["/auth/callback"]);
-const SourcesPage = lazy(routeLoaders["/expocit/sources"]);
+const HomePage = lazyChunk(routeLoaders["/app"]);
+const CurrentDebatePage = lazyChunk(routeLoaders["/app/debate"]);
+const WorkspacesPage = lazyChunk(routeLoaders["/app/workspaces"]);
+const WorkspaceInvitePage = lazyChunk(routeLoaders["/app/workspaces/invite"]);
+const WorkspacePage = lazyChunk(routeLoaders["/app/workspaces/:workspaceId"]);
+const GeneratePage = lazyChunk(routeLoaders["/app/generate"]);
+const AttackPage = lazyChunk(routeLoaders["/app/attack"]);
+const BrainstormPage = lazyChunk(routeLoaders["/app/brainstorm"]);
+const FactoidsPage = lazyChunk(routeLoaders["/app/factoids"]);
+const AskPage = lazyChunk(routeLoaders["/app/ask"]);
+const CrossfirePage = lazyChunk(routeLoaders["/app/crossfire"]);
+const SettingsPage = lazyChunk(routeLoaders["/app/settings"]);
+const HistoryPage = lazyChunk(routeLoaders["/app/history"]);
+const LibraryPage = lazyChunk(routeLoaders["/app/library"]);
+const TimerPage = lazyChunk(routeLoaders["/app/timer"]);
+const SparringPage = lazyChunk(routeLoaders["/app/sparring"]);
+const AdminPage = lazyChunk(routeLoaders["/app/admin"]);
+const SharedPage = lazyChunk(routeLoaders["/share/:code"]);
+const FlowchartPage = lazyChunk(routeLoaders["/app/flowchart"]);
+const LoginPage = lazyChunk(routeLoaders["/login"]);
+const AuthCallbackPage = lazyChunk(routeLoaders["/auth/callback"]);
+const SourcesPage = lazyChunk(routeLoaders["/expocit/sources"]);
const NotFound = prerenderedPage("*");
const ToolsHubPage = prerenderedPage("/tools");
const DebateTimerToolPage = prerenderedPage("/tools/debate-timer");
@@ -87,7 +90,7 @@ const SpeechTimeCalculatorToolPage = prerenderedPage("/tools/speech-time-calcula
// Public pages never load the signed-in header, navigation and debate dialogs.
// Arriving from sign-in, the page being opened loads alongside the layout
// instead of after it.
-const AppLayout = lazy(() => {
+const AppLayout = lazyChunk(() => {
void preloadStartupRoute(window.location.pathname);
return appLayoutLoader();
});
@@ -104,7 +107,7 @@ function loadOnboardingTour(): Promise {
// leave the Start debate step closed for the rest of the page. The second
// failure is the last: `noteTourChunkFailure` logs once and the tour owner
// continues without it.
- onboardingTourPromise ??= import("@/components/features/onboarding/OnboardingTour").then((mod) => {
+ onboardingTourPromise ??= loadChunk(() => import("@/components/features/onboarding/OnboardingTour")).then((mod) => {
onboardingTourComponent = mod.OnboardingTour;
return mod.OnboardingTour;
}).catch((error: unknown) => {
@@ -115,8 +118,7 @@ function loadOnboardingTour(): Promise {
return onboardingTourPromise;
}
-const FirstRun = lazy(() => import("@/components/features/onboarding/FirstRun")
- .then(({ FirstRun: Run }) => ({ default: Run })));
+const FirstRun = lazyChunk(() => namedChunk(() => import("@/components/features/onboarding/FirstRun"), "FirstRun"));
// TanStack Query refetches after an `offline` then `online` pair, but a page
// opened offline never sees `offline`. Retry whatever failed once the
@@ -271,6 +273,23 @@ export function ToolRouteAnalytics(): null {
return null;
}
+/** signup_completed and trial_started, once per new account, and the account first_ai_success checks. AppLayout's trial banner shares this subscription read. */
+export function ActivationAnalytics(): null {
+ const { user } = useAuth();
+ const { tier, loading } = useSubscription();
+
+ useEffect(() => {
+ rememberActivationAccount(user ?? null);
+ if (user) captureSignupCompleted(user);
+ }, [user]);
+
+ useEffect(() => {
+ if (user && !loading) captureTrialStarted(user, tier);
+ }, [user, tier, loading]);
+
+ return null;
+}
+
function useRouteLabel(pathname: string): { fallback: RouteFallback; label: string } {
const { t } = useTranslation();
const fallback = getRouteFallback(pathname);
@@ -344,7 +363,10 @@ export function AppWelcome() {
markIntroSeen(browserStorage())}
+ onIntroSeen={() => {
+ markIntroSeen(browserStorage());
+ captureWelcomeCompleted(userId);
+ }}
tour={(continueFromIntro) => }
/>
@@ -470,6 +492,7 @@ function AppRoutes() {
element={
}>
+ }>
diff --git a/src/components/WebTelemetry.tsx b/src/components/WebTelemetry.tsx
index a2475da7e..60d0fd264 100644
--- a/src/components/WebTelemetry.tsx
+++ b/src/components/WebTelemetry.tsx
@@ -1,8 +1,9 @@
-import { lazy, Suspense, useEffect, useState } from "react";
+import { Suspense, useEffect, useState } from "react";
+import { lazyChunk } from "@/lib/lazy-chunk";
import { loadOptionalComponent } from "@/components/optional-telemetry";
-const Analytics = lazy(() => loadOptionalComponent(() => import("@vercel/analytics/react"), "Analytics"));
-const SpeedInsights = lazy(() => loadOptionalComponent(() => import("@vercel/speed-insights/react"), "SpeedInsights"));
+const Analytics = lazyChunk(() => loadOptionalComponent(() => import("@vercel/analytics/react"), "Analytics"));
+const SpeedInsights = lazyChunk(() => loadOptionalComponent(() => import("@vercel/speed-insights/react"), "SpeedInsights"));
export function WebTelemetry() {
const [ready, setReady] = useState(false);
diff --git a/src/components/auth/SignInAgainButton.tsx b/src/components/auth/SignInAgainButton.tsx
new file mode 100644
index 000000000..994f228dc
--- /dev/null
+++ b/src/components/auth/SignInAgainButton.tsx
@@ -0,0 +1,28 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { LogIn } from 'lucide-react';
+import { Button, type ButtonProps } from '@/components/ui/button';
+import { supabase } from '@/lib/supabase/client';
+import { endSessionToSignInAgain } from '@/lib/supabase/session-expiry';
+
+/** The next step after the server refused this session: sign out and open the login page. */
+export function SignInAgainButton({ size = 'sm', variant = 'outline', className }: Pick) {
+ const { t } = useTranslation();
+ const [pending, setPending] = useState(false);
+ return (
+
+ );
+}
diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx
index cbb8db5b7..481a4cb61 100644
--- a/src/components/features/ask/AskChat.tsx
+++ b/src/components/features/ask/AskChat.tsx
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, MessageCircle, Send, RotateCcw, Quote, Map, List, HelpCircle, Paperclip, X, Bookmark, Mic, MicOff, Search, Save, PanelLeftOpen, PlusCircle, Square, SlidersHorizontal, ArrowDown, AlertCircle } from 'lucide-react';
import { Button, ThinkingDots } from '@/components/ui/button';
+import { SignInAgainButton } from '@/components/auth/SignInAgainButton';
import { Owl, OwlAvatar } from '@/components/brand/Owl';
import { Input } from '@/components/ui/input';
import { Markdown } from '@/components/ui/markdown';
@@ -35,6 +36,8 @@ export interface AskChatError {
title: string;
description?: string;
onRetry?: () => void;
+ /** The server refused the session: offer Sign in again instead of Retry. */
+ signIn?: boolean;
}
interface AskChatProps {
@@ -742,7 +745,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
{error.description &&
{error.description}
}
- {error.onRetry && (
+ {error.signIn ? : error.onRetry && (
)}
{onDismissError && (
diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx
index 089477ae1..2856a8ef9 100644
--- a/src/components/features/sparring/SparringRound.tsx
+++ b/src/components/features/sparring/SparringRound.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useOnlineStatus } from '@/hooks/use-online-status';
import { AlertCircle, Check, ChevronRight, CirclePause, CirclePlay, Clock3, CloudOff, RotateCcw, Send, UserRound, Wifi } from 'lucide-react';
import { Button } from '@/components/ui/button';
+import { SignInAgainButton } from '@/components/auth/SignInAgainButton';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
@@ -39,6 +40,8 @@ interface SparringRoundProps {
/** The judge's scorecard as the model writes it. */
liveJudging?: LiveRunState;
judgingError?: string | null;
+ /** The opponent or judge request was refused for auth: offer Sign in again instead of retrying. */
+ signInRequired?: boolean;
submissionError?: string | null;
onSubmit: (content: string, delivery?: SpeechDelivery | null) => void;
onRetryAi?: () => void;
@@ -66,6 +69,7 @@ export function SparringRound({
judging = false,
liveJudging,
judgingError,
+ signInRequired = false,
submissionError,
onSubmit,
onRetryAi,
@@ -238,7 +242,9 @@ export function SparringRound({
{t('sparring.round.scoreFailed', 'Could not score this round')}