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:
parent
8557b04621
commit
c662fb11f7
6 changed files with 75 additions and 6 deletions
14
README.md
14
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
|
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
|
*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 `<body>` so the
|
the safe area (the tab bar hides inside the wizard), and it is portalled to `<body>` 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
|
- **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*
|
(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
|
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
|
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
|
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
|
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,
|
once a folder exists). The mode switch is hidden while either wizard is generating
|
||||||
the label matrix, per-tab routing, deep links, Back from every tab and the ⋯ sheet) plus the
|
(`lib/wizard-busy.ts`), and leaving a wizard mid-run aborts the request so a stray generation
|
||||||
other ten standing suites.
|
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
|
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
|
flow and a 2-step homework flow that share the exact state and handlers with the desktop
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { useBackClose } from "@/lib/use-back-close";
|
||||||
import { useIsPhone } from "@/lib/use-phone";
|
import { useIsPhone } from "@/lib/use-phone";
|
||||||
import { useOnline } from "@/lib/use-online";
|
import { useOnline } from "@/lib/use-online";
|
||||||
import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft";
|
import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft";
|
||||||
|
import { wizardBusy } from "@/lib/wizard-busy";
|
||||||
import { cn, uid } from "@/lib/utils";
|
import { cn, uid } from "@/lib/utils";
|
||||||
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
||||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||||
|
|
@ -164,6 +165,17 @@ export function UploadScreen() {
|
||||||
documentsRef.current = documents;
|
documentsRef.current = documents;
|
||||||
}, [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[]) => {
|
const addFiles = useCallback(async (files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,21 @@ import Link from "next/link";
|
||||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { useIsPhone } from "@/lib/use-phone";
|
import { useIsPhone } from "@/lib/use-phone";
|
||||||
|
import { useWizardBusy } from "@/lib/wizard-busy";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-page switch between the two wizards (New deck ⇄ Make my homework).
|
* In-page switch between the two wizards (New deck ⇄ Make my homework).
|
||||||
* Phones reach both through this control and the centre tab; desktop keeps
|
* 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" }) {
|
export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isPhone = useIsPhone();
|
const isPhone = useIsPhone();
|
||||||
|
const busy = useWizardBusy();
|
||||||
|
|
||||||
|
if (busy) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="segmented mb-5" role="group" aria-label={t("wizard.mode.aria")}>
|
<div className="segmented mb-5" role="group" aria-label={t("wizard.mode.aria")}>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { useI18n } from "@/lib/i18n";
|
||||||
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
||||||
import { useNotesIntake } from "@/lib/use-notes-intake";
|
import { useNotesIntake } from "@/lib/use-notes-intake";
|
||||||
import { clearWizardDraft } from "@/lib/wizard-draft";
|
import { clearWizardDraft } from "@/lib/wizard-draft";
|
||||||
|
import { wizardBusy } from "@/lib/wizard-busy";
|
||||||
|
|
||||||
import { HomeworkCheckResult } from "./HomeworkCheckResult";
|
import { HomeworkCheckResult } from "./HomeworkCheckResult";
|
||||||
import { HomeworkGuide } from "./HomeworkGuide";
|
import { HomeworkGuide } from "./HomeworkGuide";
|
||||||
|
|
@ -65,6 +66,17 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
||||||
if (record) setView({ kind: "record", record });
|
if (record) setView({ kind: "record", record });
|
||||||
}, [initialRecordId]);
|
}, [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. */
|
/** Everything the API needs, built from the current input. */
|
||||||
const buildPayload = (modeValue: HomeworkMode) => {
|
const buildPayload = (modeValue: HomeworkMode) => {
|
||||||
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||||
|
|
|
||||||
34
lib/wizard-busy.ts
Normal file
34
lib/wizard-busy.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue