Skip to content

Commit 306d233

Browse files
committed
feat: add job polling for generation flow
1 parent 1d5a4cb commit 306d233

20 files changed

Lines changed: 1838 additions & 97 deletions

File tree

docs/job-system.md

Lines changed: 453 additions & 0 deletions
Large diffs are not rendered by default.

frontend/app/generate/random/page.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import GenerateLoading from "@/components/generate/GenerateLoading";
77
import GenerateError from "@/components/generate/GenerateError";
88
import GenerateGrid from "@/components/generate/GenerateGrid";
99
import GenerateActions from "@/components/generate/GenerateActions";
10-
import GenerateEmpty from "@/components/generate/GenerateEmpty";
1110
import GenerateOverlay from "@/components/generate/GenerateOverlay";
1211
import StageProgress from "@/components/generate/StageProgress";
1312
import StageTransition from "@/components/generate/StageTransition";
@@ -30,6 +29,7 @@ export default function RandomGeneratePage() {
3029
handleRegenerate,
3130
handleNext,
3231
refetch,
32+
loreJob,
3333
} = useRandomGeneration();
3434

3535
return (
@@ -42,27 +42,31 @@ export default function RandomGeneratePage() {
4242
<GenerateLoading
4343
isLoading={isLoading}
4444
category={stageConfig.category}
45+
job={loreJob}
4546
/>
46-
<GenerateError error={error} onRefetch={refetch} />
47-
<StageTransition stageKey={stageConfig.category}>
48-
<GenerateGrid
49-
generatedOptions={generatedOptions}
50-
selectedIndex={selectedIndex}
51-
stage={stageConfig.category}
52-
onSelectCard={handleSelectCard}
53-
/>
54-
</StageTransition>
47+
<GenerateError error={error} isLoading={isLoading} onRefetch={refetch} />
48+
{!isLoading && (
49+
<StageTransition stageKey={stageConfig.category}>
50+
<GenerateGrid
51+
generatedOptions={generatedOptions}
52+
selectedIndex={selectedIndex}
53+
stage={stageConfig.category}
54+
onSelectCard={handleSelectCard}
55+
/>
56+
</StageTransition>
57+
)}
5558
<GenerateActions
5659
hasSelection={selectedIndex !== null}
5760
isLoading={isLoading || generateDraftMutation.isPending}
5861
isLastStage={stageConfig.category === "relics"}
62+
hasError={!!error}
5963
onRegenerate={handleRegenerate}
6064
onNext={handleNext}
6165
/>
62-
<GenerateEmpty show={!isLoading && generatedOptions.length === 0} />
6366
<GenerateOverlay
6467
isPending={generateDraftMutation.isPending}
6568
currentMessage={currentLoadingMessage}
69+
job={generateDraftMutation.job}
6670
/>
6771
</main>
6872
);

frontend/components/generate/GenerateActions.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface GenerateActionsProps {
55
hasSelection: boolean;
66
isLoading: boolean;
77
isLastStage: boolean;
8+
hasError: boolean;
89
onRegenerate: () => void;
910
onNext: () => void;
1011
}
@@ -13,9 +14,13 @@ export default function GenerateActions({
1314
hasSelection,
1415
isLoading,
1516
isLastStage,
17+
hasError,
1618
onRegenerate,
1719
onNext,
1820
}: GenerateActionsProps) {
21+
// Don't show actions if there's an error (retry button is in error component)
22+
if (hasError) return null;
23+
1924
return (
2025
<section className="mt-8 flex items-center justify-between gap-4">
2126
<ActionButton

frontend/components/generate/GenerateEmpty.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.
Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
11
import SharedActionButton from "@/components/shared/buttons/ActionButton";
2+
import { AlertCircle, RotateCw } from "lucide-react";
23

34
interface GenerateErrorProps {
45
error: string | null;
6+
isLoading: boolean;
57
onRefetch: () => void;
68
}
79

810
export default function GenerateError({
911
error,
12+
isLoading,
1013
onRefetch,
1114
}: GenerateErrorProps) {
12-
if (!error) return null;
15+
// Don't show error while loading (new job is running)
16+
if (!error || isLoading) return null;
1317

1418
return (
15-
<section className="bg-destructive/10 border-destructive text-destructive mb-8 rounded-lg border p-4">
16-
<p className="font-semibold">Error:</p>
17-
<p>{error}</p>
18-
<SharedActionButton onClick={onRefetch}>Try again</SharedActionButton>
19+
<section className="bg-destructive/10 border-destructive mb-8 rounded-lg border p-6">
20+
<div className="flex items-start gap-3">
21+
<AlertCircle className="text-destructive mt-0.5 h-5 w-5 flex-shrink-0" />
22+
<div className="flex-1">
23+
<p className="text-destructive mb-1 font-semibold">Generation Failed</p>
24+
<p className="text-destructive/90 mb-4 text-sm">{error}</p>
25+
<SharedActionButton
26+
onClick={onRefetch}
27+
variant="destructive"
28+
size="default"
29+
icon={<RotateCw className="h-4 w-4" />}
30+
>
31+
Retry Generation
32+
</SharedActionButton>
33+
</div>
34+
</div>
1935
</section>
2036
);
2137
}
Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,44 @@
1-
import GenerateCardSkeleton from "@/components/generate/GenerateCardSkeleton";
1+
import { Job } from "@/lib/api/jobs";
2+
import { Loader2 } from "lucide-react";
23

34
interface GenerateLoadingProps {
45
isLoading: boolean;
56
category: string;
7+
job?: Job;
68
}
79

810
export default function GenerateLoading({
911
isLoading,
1012
category,
13+
job,
1114
}: GenerateLoadingProps) {
12-
if (!isLoading) return null;
15+
// Don't show loading if job failed (error will be shown via GenerateError)
16+
if (!isLoading || job?.status === "failed") return null;
17+
18+
const progress = job?.progress || 0;
1319

1420
return (
15-
<div>
16-
<p className="text-muted-foreground mb-6 text-center">
17-
Generating {category}...
18-
</p>
19-
<section className="mb-12 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
20-
<GenerateCardSkeleton />
21-
<GenerateCardSkeleton />
22-
<GenerateCardSkeleton />
23-
</section>
21+
<div className="mb-12 flex flex-col items-center gap-4">
22+
{/* Simple spinner */}
23+
<Loader2 className="text-primary h-8 w-8 animate-spin" />
24+
25+
{/* Progress Bar */}
26+
<div className="w-full max-w-md">
27+
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
28+
<div
29+
className="h-full bg-primary transition-all duration-300"
30+
style={{ width: `${progress}%` }}
31+
/>
32+
</div>
33+
<p className="mt-2 text-center text-sm font-medium text-muted-foreground">
34+
{progress}%
35+
</p>
36+
</div>
37+
38+
{/* Status Message */}
39+
{job?.message && (
40+
<p className="text-muted-foreground">{job.message}</p>
41+
)}
2442
</div>
2543
);
2644
}

frontend/components/generate/GenerateOverlay.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,54 @@
11
import { Sparkles } from "lucide-react";
22
import { LOADING_MESSAGES } from "@/constants/loading-messages";
3+
import JobProgressSpinner from "@/components/jobs/JobProgressSpinner";
4+
import { Job } from "@/lib/api/jobs";
35

46
interface GenerateOverlayProps {
57
isPending: boolean;
68
currentMessage: number;
9+
job?: Job;
710
}
811

912
export default function GenerateOverlay({
1013
isPending,
1114
currentMessage,
15+
job,
1216
}: GenerateOverlayProps) {
1317
if (!isPending) return null;
1418

1519
return (
1620
<div className="bg-background/80 fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
17-
<div className="text-center">
18-
<div className="mb-8 flex justify-center">
19-
<div className="relative">
20-
<div className="border-primary/20 border-t-primary h-24 w-24 animate-spin rounded-full border-4"></div>
21-
<div className="absolute inset-0 flex items-center justify-center">
22-
<Sparkles className="text-primary h-8 w-8" />
23-
</div>
21+
<div className="w-full max-w-2xl px-4 text-center">
22+
{/* Show job progress if available */}
23+
{job ? (
24+
<div>
25+
<h2 className="text-foreground mb-8 text-2xl font-bold">
26+
{LOADING_MESSAGES[currentMessage]}
27+
</h2>
28+
<JobProgressSpinner
29+
job={job}
30+
showProgress={true}
31+
showMessage={true}
32+
/>
2433
</div>
25-
</div>
26-
<h2 className="text-foreground mb-4 text-2xl font-bold">
27-
{LOADING_MESSAGES[currentMessage]}
28-
</h2>
29-
<p className="text-muted-foreground">
30-
Weaving your chosen elements into an epic adventure...
31-
</p>
34+
) : (
35+
<>
36+
<div className="mb-8 flex justify-center">
37+
<div className="relative">
38+
<div className="border-primary/20 border-t-primary h-24 w-24 animate-spin rounded-full border-4"></div>
39+
<div className="absolute inset-0 flex items-center justify-center">
40+
<Sparkles className="text-primary h-8 w-8" />
41+
</div>
42+
</div>
43+
</div>
44+
<h2 className="text-foreground mb-4 text-2xl font-bold">
45+
{LOADING_MESSAGES[currentMessage]}
46+
</h2>
47+
<p className="text-muted-foreground">
48+
Weaving your chosen elements into an epic adventure...
49+
</p>
50+
</>
51+
)}
3252
</div>
3353
</div>
3454
);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import { Job } from "@/lib/api/jobs";
4+
import { Sparkles } from "lucide-react";
5+
6+
interface JobProgressSpinnerProps {
7+
job: Job | undefined;
8+
className?: string;
9+
showProgress?: boolean;
10+
showMessage?: boolean;
11+
}
12+
13+
export default function JobProgressSpinner({
14+
job,
15+
className = "",
16+
showProgress = true,
17+
showMessage = true,
18+
}: JobProgressSpinnerProps) {
19+
const progress = job?.progress || 0;
20+
21+
return (
22+
<div className={`flex flex-col items-center gap-6 ${className}`}>
23+
{/* Fancy Animated Spinner */}
24+
<div className="relative">
25+
<div className="border-primary/20 border-t-primary h-24 w-24 animate-spin rounded-full border-4"></div>
26+
<div className="absolute inset-0 flex items-center justify-center">
27+
<Sparkles className="text-primary h-8 w-8" />
28+
</div>
29+
</div>
30+
31+
{/* Progress Bar */}
32+
{showProgress && (
33+
<div className="w-full max-w-md">
34+
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
35+
<div
36+
className="h-full bg-primary transition-all duration-300"
37+
style={{ width: `${progress}%` }}
38+
/>
39+
</div>
40+
<p className="mt-2 text-center text-sm font-medium text-muted-foreground">
41+
{progress}%
42+
</p>
43+
</div>
44+
)}
45+
46+
{/* Status Message */}
47+
{showMessage && job?.message && (
48+
<p className="text-muted-foreground">{job.message}</p>
49+
)}
50+
51+
{/* Error Display */}
52+
{job?.status === "failed" && job.error && (
53+
<div className="max-w-md rounded-lg border border-destructive bg-destructive/10 p-4">
54+
<p className="text-sm font-medium text-destructive">Error:</p>
55+
<p className="mt-1 text-sm text-muted-foreground">{job.error}</p>
56+
</div>
57+
)}
58+
</div>
59+
);
60+
}

frontend/hooks/useRandomGeneration.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function useRandomGeneration() {
3434
isLoading,
3535
refetch,
3636
error: queryError,
37+
job: loreJob,
3738
} = useGenerateLore(
3839
stageConfig.category as
3940
| "characters"
@@ -50,6 +51,20 @@ export function useRandomGeneration() {
5051

5152
const [currentLoadingMessage, setCurrentLoadingMessage] = useState(0);
5253

54+
//* Update error state from query errors
55+
useEffect(() => {
56+
if (queryError) {
57+
setError(queryError.message);
58+
}
59+
}, [queryError]);
60+
61+
//* Clear error when new job starts loading
62+
useEffect(() => {
63+
if (isLoading) {
64+
setError(null);
65+
}
66+
}, [isLoading]);
67+
5368
//* Cycle through loading messages during full story generation
5469
useEffect(() => {
5570
if (generateDraftMutation.isPending) {
@@ -82,13 +97,18 @@ export function useRandomGeneration() {
8297

8398
//* Update generated options when data changes
8499
useEffect(() => {
85-
if (loreData) {
86-
setGeneratedOptions(loreData);
87-
setSelectedIndex(null);
100+
if (loreData && !isLoading) {
101+
// Only reset selection if we have NEW options (not just an update to existing data)
102+
setGeneratedOptions((prev) => {
103+
const isNewData = prev.length === 0 || prev.length !== loreData.length || prev[0]?.name !== loreData[0]?.name;
104+
if (isNewData) {
105+
setSelectedIndex(null);
106+
}
107+
return loreData;
108+
});
88109
setError(null);
89-
} else {
90110
}
91-
}, [loreData]);
111+
}, [loreData, isLoading]);
92112

93113
const handleSelectCard = (index: number) => {
94114
setSelectedIndex(selectedIndex === index ? null : index);
@@ -97,6 +117,7 @@ export function useRandomGeneration() {
97117
const handleRegenerate = () => {
98118
setGeneratedOptions([]); //* Clear cards immediately
99119
setSelectedIndex(null); //* Clear selection
120+
setError(null); //* Clear error
100121
refetch(); //* Trigger new generation
101122
};
102123

@@ -157,5 +178,6 @@ export function useRandomGeneration() {
157178
handleRegenerate,
158179
handleNext,
159180
refetch,
181+
loreJob,
160182
};
161183
}

0 commit comments

Comments
 (0)