Lock the wizard mode switch while a run is generating

Reported: during a loading screen (freezing a deck) the Study deck /
Make my homework switch stayed live, so the run could be swapped out
mid-flight; a request that finished afterwards could even save a deck
the user never saw and navigate them into it.

- New lib/wizard-busy.ts: a tiny store both wizards publish to while
  phase === 'loading' (UploadScreen) or view.kind === 'loading'
  (HomeworkScreen); WizardModeSwitch hides itself while busy.
- Both wizards abort the in-flight request on unmount, so leaving via
  the header logo or the Back gesture stops the run instead of letting
  it finish invisibly and save/navigate later.
- Deck Cancel keeps its long-standing behaviour (wizard + cancelled
  notice + Retry), matching the freeze-loading suite.

Verified: wizard suite 92 checks (mode switch hidden during deck and
homework runs, it returns after Cancel, and a delayed reply after
leaving mid-run saves nothing and navigates nowhere) plus mobile
134/134 (also de-flaked the sheet's Escape close by waiting for
detach), regression 74/74, declutter 116/116, animation 8/8, native
35/35, i18n 25/25, phase1 38/38, phase2 43/43, phase3 20/20,
freeze-loading 6/6. Lint + build clean. SW freezmo-v29.
This commit is contained in:
Martin Dvořák 2026-10-10 17:47:05 +00:00
commit c662fb11f7
6 changed files with 75 additions and 6 deletions

View file

@ -42,6 +42,7 @@ import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone";
import { useOnline } from "@/lib/use-online";
import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft";
import { wizardBusy } from "@/lib/wizard-busy";
import { cn, uid } from "@/lib/utils";
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill";
@ -164,6 +165,17 @@ export function UploadScreen() {
documentsRef.current = documents;
}, [documents]);
/* A loading run locks the mode switch (see WizardModeSwitch). */
useEffect(() => {
wizardBusy.set(phase === "loading");
}, [phase]);
useEffect(() => () => wizardBusy.set(false), []);
/* Leaving the page mid-generation stops the request instead of letting it
finish invisibly and save a deck the user never sees. The loading
screen's Cancel button is the in-page way out. */
useEffect(() => () => abortRef.current?.abort(), []);
const addFiles = useCallback(async (files: File[]) => {
if (files.length === 0) return;
setFileError(null);

View file

@ -5,16 +5,21 @@ import Link from "next/link";
import { SegmentedPill } from "@/components/SegmentedPill";
import { useI18n } from "@/lib/i18n";
import { useIsPhone } from "@/lib/use-phone";
import { useWizardBusy } from "@/lib/wizard-busy";
import { cn } from "@/lib/utils";
/**
* In-page switch between the two wizards (New deck ⇄ Make my homework).
* Phones reach both through this control and the centre tab; desktop keeps
* the rail rows as well.
* the rail rows as well. Hidden while either wizard is generating: leaving a
* loading run through it would abandon the request mid-flight.
*/
export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" }) {
const { t } = useI18n();
const isPhone = useIsPhone();
const busy = useWizardBusy();
if (busy) return null;
return (
<div className="segmented mb-5" role="group" aria-label={t("wizard.mode.aria")}>

View file

@ -20,6 +20,7 @@ import { useI18n } from "@/lib/i18n";
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
import { useNotesIntake } from "@/lib/use-notes-intake";
import { clearWizardDraft } from "@/lib/wizard-draft";
import { wizardBusy } from "@/lib/wizard-busy";
import { HomeworkCheckResult } from "./HomeworkCheckResult";
import { HomeworkGuide } from "./HomeworkGuide";
@ -65,6 +66,17 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
if (record) setView({ kind: "record", record });
}, [initialRecordId]);
/* A loading run locks the mode switch (see WizardModeSwitch). */
const viewKind = view.kind;
useEffect(() => {
wizardBusy.set(viewKind === "loading");
}, [viewKind]);
useEffect(() => () => wizardBusy.set(false), []);
/* Leaving the page mid-run stops the request instead of letting it finish
invisibly and save a result the user never sees. */
useEffect(() => () => abortRef.current?.abort(), []);
/** Everything the API needs, built from the current input. */
const buildPayload = (modeValue: HomeworkMode) => {
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);