From c662fb11f7ccfbff2e524c8d8e16988dddcf7a82 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Sat, 10 Oct 2026 17:47:05 +0000 Subject: [PATCH] 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. --- README.md | 14 ++++++++--- components/UploadScreen.tsx | 12 +++++++++ components/WizardModeSwitch.tsx | 7 +++++- components/homework/HomeworkScreen.tsx | 12 +++++++++ lib/wizard-busy.ts | 34 ++++++++++++++++++++++++++ public/sw.js | 2 +- 6 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 lib/wizard-busy.ts diff --git a/README.md b/README.md index 094e912..c857ea8 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,10 @@ no database, no data leaving your machine except the requests you send to Ollama real summary — thumbnails/chips, files, difficulty, activities and folder, each with an *Edit* link back to its step. A flat Back/Cancel + Next/Freeze bar sits at the bottom above the safe area (the tab bar hides inside the wizard), and it is portalled to `` so the - screen entrance animation can never displace it. The desktop page is unchanged. + screen entrance animation can never displace it. While a run is generating, the wizard's + **mode switch hides** — a loading screen can't be swapped to the other wizard mid-flight — + and leaving the page anyway aborts the request instead of saving a deck in the background. + The desktop page is unchanged. - **Native-feel touches (phones)**: the Android Back gesture closes the topmost layer first (sheet, actions sheet, then one wizard step); deleting a deck shows an *Undo* toast instead of a confirm; the deck menu can share a deck as JSON through the system share @@ -1034,9 +1037,12 @@ Things I chose while building, with the reasoning: Drawer-only destinations were re-homed: Progress and Exam became tabs, the New deck screen got a **Study deck ⇄ Make my homework** segmented switch (also on `/homework`), and the library's phone row gained a **⋯** sheet with Import JSON + New folder (folder chips still appear only - once a folder exists). Verified by the mobile suite (134 checks: no hamburger/drawer, tab order, - the label matrix, per-tab routing, deep links, Back from every tab and the ⋯ sheet) plus the - other ten standing suites. + once a folder exists). The mode switch is hidden while either wizard is generating + (`lib/wizard-busy.ts`), and leaving a wizard mid-run aborts the request so a stray generation + can never save a deck or yank the user into the study feed. Verified by the mobile suite + (134 checks: no hamburger/drawer, tab order, the label matrix, per-tab routing, deep links, + Back from every tab and the ⋯ sheet) plus the wizard suite (92 checks, including the + loading-lock and abort-on-leave cases) and the other standing suites. 36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck flow and a 2-step homework flow that share the exact state and handlers with the desktop diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 12c1fb3..0efbd57 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -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); diff --git a/components/WizardModeSwitch.tsx b/components/WizardModeSwitch.tsx index 58094d3..b9362ce 100644 --- a/components/WizardModeSwitch.tsx +++ b/components/WizardModeSwitch.tsx @@ -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 (
diff --git a/components/homework/HomeworkScreen.tsx b/components/homework/HomeworkScreen.tsx index e0bdd8f..8c30725 100644 --- a/components/homework/HomeworkScreen.tsx +++ b/components/homework/HomeworkScreen.tsx @@ -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); diff --git a/lib/wizard-busy.ts b/lib/wizard-busy.ts new file mode 100644 index 0000000..f791721 --- /dev/null +++ b/lib/wizard-busy.ts @@ -0,0 +1,34 @@ +"use client"; + +/** + * True while either wizard is generating. The in-page mode switch reads this + * and hides itself, so a loading run can't be swapped out mid-flight — the + * loading screen owns the page until it finishes or is cancelled. + */ + +import { useSyncExternalStore } from "react"; + +let busy = false; +const listeners = new Set<() => void>(); + +export const wizardBusy = { + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getSnapshot(): boolean { + return busy; + }, + getServerSnapshot(): boolean { + return false; + }, + set(value: boolean): void { + if (busy === value) return; + busy = value; + listeners.forEach((listener) => listener()); + }, +}; + +export function useWizardBusy(): boolean { + return useSyncExternalStore(wizardBusy.subscribe, wizardBusy.getSnapshot, wizardBusy.getServerSnapshot); +} diff --git a/public/sw.js b/public/sw.js index c6869f5..1a04e84 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v28"; +const CACHE_VERSION = "freezmo-v29"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;