Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 136 additions & 10 deletions components/LandingToolPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import {
historyFieldsFromSuccess,
postGenerateWithRetry,
} from "@/lib/generateClient";
import { canRetryGenerateFailure } from "@/lib/generateRecoveryPolicy";
import {
canRetryGenerateFailure,
planGenerateWaitLeave,
shouldShowGenerateWaitDetach,
} from "@/lib/generateRecoveryPolicy";
import { downloadVideoFile, pushHistory } from "@/lib/history";
import {
canLiveGenerate,
Expand Down Expand Up @@ -117,8 +122,22 @@ export function LandingToolPanel({
const [privateResult, setPrivateResult] = useState(false);
/** Device-local bible SKU — carry into AfterPath Next SKU / Seller Pack hops. */
const [toySku, setToySku] = useState<string>("");
/**
* Durable recovery UI — checking/waiting unlocks non-destructive leave
* immediately (Create/Batch parity). Never invent a second generate.
*/
const [recoveringSavedResult, setRecoveringSavedResult] = useState(false);
const [awaitingPrimaryAfterRecovery, setAwaitingPrimaryAfterRecovery] =
useState(false);
const generateAbortRef = useRef<AbortController | null>(null);
/**
* When true, UI stopped waiting but the original /api/generate must keep
* running (no abort, no ledger cancel invent, no refund restore invent).
*/
const detachedWaitRef = useRef(false);
const landingMountedRef = useRef(true);
const resultVideoRef = useRef<HTMLVideoElement | null>(null);
const router = useRouter();
const toast = useToast();
const downloadAllowed = canDownloadResult({
demo,
Expand Down Expand Up @@ -210,24 +229,59 @@ export function LandingToolPanel({
}, []);

useEffect(() => {
landingMountedRef.current = true;
return () => {
generateAbortRef.current?.abort();
// Non-destructive leave: drop UI ownership only. Explicit Cancel is the
// sole path that aborts primary + best-effort cancels the ledger.
landingMountedRef.current = false;
generateAbortRef.current = null;
};
}, []);

function cancelInFlightGenerate() {
const plan = planGenerateWaitLeave("cancel");
if (!plan.abortPrimary) return;
const ctrl = generateAbortRef.current;
if (!ctrl) return;
// Explicit cancel only — abort signal triggers ledger cancel in generateClient.
// Detach (leaveWaitingKeepBackground) never calls abort().
ctrl.abort();
generateAbortRef.current = null;
detachedWaitRef.current = false;
setRecoveringSavedResult(false);
setAwaitingPrimaryAfterRecovery(false);
// Immediate settlement honesty until the aborted POST resolves.
setFailCreditState("refund unconfirmed");
toast(
"Canceled · ledger cancel best-effort · refund unconfirmed until balance confirms"
);
}

/**
* Stop waiting on Landing without aborting the original generate POST or
* inventing a refund restore. User can open Library while the same private
* task finishes.
*/
function leaveWaitingKeepBackground() {
const plan = planGenerateWaitLeave("detach");
if (plan.abortPrimary || plan.cancelLedger || plan.startNewGenerate) {
// Defensive: detach plan must never harm the in-flight job.
return;
}
// Drop the AbortController ref without abort() so cleanup cannot cancel.
generateAbortRef.current = null;
detachedWaitRef.current = true;
setRecoveringSavedResult(false);
setAwaitingPrimaryAfterRecovery(false);
setElapsed(0);
setStatus("idle");
toast(
"Still generating in the background · open Library when ready — no cancel sent"
);
// Soft client navigation keeps the original fetch alive in this document.
router.push("/library");
}

function replayResultVideo() {
document
.getElementById("landing-result")
Expand Down Expand Up @@ -425,14 +479,21 @@ export function LandingToolPanel({
setError(null);
setFailRetryAfterSec(null);
setFailCreditState(null);
setLastFailCode(null);
setLastFailFatal(false);
setLastFailPaywall(false);
setVideoUrl(null);
setRequestId(null);
setPrivateResult(false);
setCostCredits(null);
setResultSettlement(null);
setServerEcho(false);
setElapsed(0);
setRecoveringSavedResult(false);
setAwaitingPrimaryAfterRecovery(false);
detachedWaitRef.current = false;
setStatus("generating");
// Abort any prior in-flight POST before starting a new one (explicit replace).
generateAbortRef.current?.abort();
const abortCtrl = new AbortController();
generateAbortRef.current = abortCtrl;
Expand All @@ -455,11 +516,42 @@ export function LandingToolPanel({
maxRetries: 1,
fallbackImage: useAsset && image ? image : undefined,
signal: abortCtrl.signal,
onRecoveryState: (state) => {
if (detachedWaitRef.current || !landingMountedRef.current) return;
setRecoveringSavedResult(
state === "checking" || state === "waiting"
);
setAwaitingPrimaryAfterRecovery(state === "awaiting_primary");
},
}
);
if (generateAbortRef.current === abortCtrl) {
generateAbortRef.current = null;
}

// Detached wait / unmounted Landing: never abort, cancel, or setState for
// fail/refund invent. On success still persist to device Library.
if (detachedWaitRef.current || !landingMountedRef.current) {
if (result.ok) {
const data = result.data;
pushHistory(
historyFieldsFromSuccess(data, {
effect: effectSlug,
effectName,
fallbackDuration: duration,
fallbackAspect: aspectRatio,
fallbackResolution: resolution,
sku: toySku || undefined,
})
);
}
detachedWaitRef.current = false;
return;
}

setRecoveringSavedResult(false);
setAwaitingPrimaryAfterRecovery(false);

// Dead asset after TTL/process restart — clear and re-register for next try.
if (
(!result.ok && result.code === "ASSET_NOT_FOUND") ||
Expand Down Expand Up @@ -565,6 +657,16 @@ export function LandingToolPanel({
: status === "done"
? 100
: 0;
/**
* Live recovery / long-wait unlocks non-destructive leave (Create/Batch
* parity). Demo Lab never detaches to private Library.
*/
const showLandingDetach = shouldShowGenerateWaitDetach({
demoMode,
elapsedSec: elapsed,
recoveryChecking: recoveringSavedResult,
awaitingPrimary: awaitingPrimaryAfterRecovery,
});

// Prefer samples tagged for this effect, else all
const samples = [
Expand Down Expand Up @@ -790,14 +892,35 @@ export function LandingToolPanel({
</label>

{busy ? (
<button
type="button"
onClick={cancelInFlightGenerate}
title="Aborts this browser request. Soft-launch may still finish server-side; refund unconfirmed until balance confirms."
className="btn btn-ghost w-full border border-amber-400/40 text-amber-100"
>
Cancel request · {elapsed}s
</button>
<div className="space-y-2">
<button
type="button"
onClick={cancelInFlightGenerate}
title="Aborts this browser request. Soft-launch may still finish server-side; refund unconfirmed until balance confirms."
className="btn btn-ghost w-full border border-amber-400/40 text-amber-100"
data-generate-leave="cancel"
data-landing-leave="cancel"
>
Cancel request · {elapsed}s
</button>
{showLandingDetach ? (
<button
type="button"
onClick={leaveWaitingKeepBackground}
className="btn btn-ghost w-full border border-[var(--mint)]/35 text-[var(--mint)]"
title="Stop waiting here. Does not abort the original generate POST or claim a refund."
data-generate-leave="detach"
data-landing-leave="detach"
>
Open Library · keep generating
</button>
) : null}
<p className="text-center text-[10px] text-[var(--fg-dim)]">
{showLandingDetach
? "Leave without cancel — original job may finish server-side. No refund invent; check balance before a new attempt."
: "Generating… Cancel aborts this tab wait. Live debit may still settle — check balance before retry."}
</p>
</div>
) : trialDone && isFree && freeLiveOpen ? (
<div className="space-y-2">
<p className="rounded-lg border border-amber-300/25 bg-amber-300/[0.06] px-3 py-2 text-[11px] leading-snug text-amber-100">
Expand Down Expand Up @@ -907,6 +1030,9 @@ export function LandingToolPanel({
image={image}
effectLabel={effectName}
onCancel={cancelInFlightGenerate}
onLeaveToLibrary={leaveWaitingKeepBackground}
recoveryChecking={recoveringSavedResult}
awaitingPrimary={awaitingPrimaryAfterRecovery}
compact
className="min-h-[220px]"
/>
Expand Down
88 changes: 85 additions & 3 deletions scripts/generate-wait-honesty-regression.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* AIT-533 / AIT-545 / AIT-554 — Studio generate-wait recovery fail-closed
* residual (recovery exit + server-gated Retry + Batch/Image busy-leave).
* AIT-533 / AIT-545 / AIT-554 / AIT-563 — Studio generate-wait recovery
* fail-closed residual (recovery exit + server-gated Retry + Batch/Image/
* Create form/Landing busy-leave).
*
* Source contract (no network, no provider):
* 1. Recovery checking/waiting always unlocks a non-destructive detach exit
Expand All @@ -12,6 +13,7 @@
* 7. Image Studio Fail panel uses the same pure canRetryGenerateFailure gate
* 8. CreateStudio form-side busy-leave (AIT-571) next to Cancel
* 9. Image Studio mid-still busy-leave: pure detach (no abort / no refund invent)
* 10. LandingToolPanel mid-generate busy-leave: pure detach (no abort / no refund invent)
*
* Run: npm run generate-wait-honesty-regression
*/
Expand Down Expand Up @@ -590,6 +592,86 @@ const read = (rel) => readFileSync(join(root, rel), "utf8");
}


// ─── 11. AIT-563 LandingToolPanel busy-leave — non-destructive keep-generating ─

{
const landing = read("components/LandingToolPanel.tsx");
assert.match(landing, /leaveWaitingKeepBackground/);
assert.match(landing, /planGenerateWaitLeave\("detach"\)/);
assert.match(landing, /planGenerateWaitLeave\("cancel"\)/);
assert.match(landing, /shouldShowGenerateWaitDetach/);
assert.match(landing, /detachedWaitRef/);
assert.match(landing, /landingMountedRef/);
assert.match(landing, /data-generate-leave="detach"/);
assert.match(landing, /data-generate-leave="cancel"/);
assert.match(landing, /data-landing-leave="detach"/);
assert.match(landing, /Open Library · keep generating/);
assert.match(landing, /router\.push\(["']\/library["']\)/);
assert.match(landing, /recoveringSavedResult/);
assert.match(landing, /awaitingPrimaryAfterRecovery/);
assert.match(landing, /onRecoveryState/);
assert.match(
landing,
/GenerateWaitStage[\s\S]{0,600}onLeaveToLibrary=\{leaveWaitingKeepBackground\}/
);
assert.match(
landing,
/GenerateWaitStage[\s\S]{0,800}recoveryChecking=\{recoveringSavedResult\}/
);
// Detach function never aborts primary, cancels ledger, or invents restore.
{
const leaveFn = landing.match(
/function leaveWaitingKeepBackground\(\) \{[\s\S]*?\n \}/
)?.[0];
assert.ok(
leaveFn,
"leaveWaitingKeepBackground must exist on LandingToolPanel"
);
assert.match(leaveFn, /planGenerateWaitLeave\("detach"\)/);
assert.match(leaveFn, /generateAbortRef\.current = null/);
assert.match(leaveFn, /detachedWaitRef\.current = true/);
assert.match(leaveFn, /router\.push\(["']\/library["']\)/);
assert.doesNotMatch(leaveFn, /\.abort\s*\(/);
assert.doesNotMatch(leaveFn, /setFailCreditState/);
assert.doesNotMatch(leaveFn, /10 restored|credits restored/i);
}
// Explicit cancel remains abort + refund unconfirmed (never invent restore).
{
const cancelFn = landing.match(
/function cancelInFlightGenerate\(\) \{[\s\S]*?\n \}/
)?.[0];
assert.ok(cancelFn, "cancelInFlightGenerate must exist on LandingToolPanel");
assert.match(cancelFn, /planGenerateWaitLeave\("cancel"\)/);
assert.match(cancelFn, /ctrl\.abort\(\)/);
assert.match(cancelFn, /setFailCreditState\(["']refund unconfirmed["']\)/);
assert.doesNotMatch(cancelFn, /10 restored/);
}
// Detached settle path persists history without inventing fail/refund UI.
assert.match(
landing,
/detachedWaitRef\.current \|\| !landingMountedRef\.current/
);
assert.match(
landing,
/if \(detachedWaitRef\.current \|\| !landingMountedRef\.current\) \{[\s\S]{0,500}pushHistory/
);
// Unmount must not auto-abort (would kill detach leave).
assert.doesNotMatch(
landing,
/return \(\) => \{[\s\S]{0,220}generateAbortRef\.current\?\.abort\(\)/,
"Landing unmount must not abort in-flight generate POST (detach semantics)"
);
// AIT-545 Retry gate retained — AUTH / paywall / fatal / durable hold blocked.
assert.match(landing, /canRetryGenerateFailure/);
assert.match(landing, /lastFailCode/);
assert.match(landing, /lastFailFatal/);
assert.match(landing, /lastFailPaywall/);
assert.match(
landing,
/canRetryGenerateFailure\(\{[\s\S]{0,220}code: lastFailCode[\s\S]{0,120}fatal: lastFailFatal[\s\S]{0,120}paywall:[\s\S]{0,100}busy[\s\S]{0,80}hasInput:/
);
}

console.log(
"generate-wait-honesty-regression: PASS (detach on recovery · cancel vs detach · server-gated Retry · AIT-237 Create/Landing gate · fail-closed refund copy · Batch wiring · AIT-545 Image Studio gate · AIT-571 Create form busy-leave · AIT-554 Image busy-leave)"
"generate-wait-honesty-regression: PASS (detach on recovery · cancel vs detach · server-gated Retry · AIT-237 Create/Landing gate · fail-closed refund copy · Batch wiring · AIT-545 Image Studio gate · AIT-571 Create form busy-leave · AIT-554 Image busy-leave · AIT-563 Landing busy-leave)"
);
Loading