Skip to content

Commit 24d3853

Browse files
ralyodioclaude
andcommitted
fix(autoblog): server-owned in-flight state replaces flaky client poller
The Previews section gave four different inconsistent experiences after clicking Generate: the "~60–90s" promise was wrong, the 5-min client poller would give up and tell users to refresh manually, the page itself showed no in-flight indicator while the worker was running, and a silent generation failure left the section stuck on "No previews yet" with no signal anything had happened. Move ownership of the in-flight state from the client poller to the page itself: - page queries lx_keyword for status='generating' and renders a third state in the amber section: "⏳ N generating now" with the keyword name and a generating badge - new AutoblogAutoRefresh client component calls router.refresh() every 8s while inFlightCount > 0 and stops cleanly when it hits zero, so the preview link appears on its own - generateArticleNow drops the 5-min waitForNewArticle poller — POST, refresh once, let the page take over - empty-state copy now says "1–3 minutes" to match the worker's real ballpark (LLM + image + embeddings) - /api/lx/articles/latest deleted; no callers remain Silent generation failures still aren't surfaced (they need lx_keyword.updated_at + last_error to filter by recency and show the reason) — held that for a follow-up to keep this change focused. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c6ec186 commit 24d3853

4 files changed

Lines changed: 86 additions & 139 deletions

File tree

‎app/(app)/projects/[id]/autoblog/actions.tsx‎

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -14,40 +14,6 @@ async function call(path: string): Promise<{ ok: boolean; error?: string }> {
1414
}
1515
}
1616

17-
// Poll lx_article for a new row in status='ready' created after `since`.
18-
// Returns the article id or null on timeout. Scoped to the current
19-
// project's lx_site via projectId so users with multiple projects don't
20-
// poll the wrong site when the picker cookie has drifted.
21-
//
22-
// Window is generous (5 min): the worker's "30-60s" ballpark is a best
23-
// case — LLM + sitemap + embeddings can push it past 2 min, and a short
24-
// window meant the dashboard would give up before the article landed.
25-
async function waitForNewArticle(
26-
projectId: string,
27-
since: Date,
28-
signal: AbortSignal,
29-
maxMs = 300_000,
30-
): Promise<string | null> {
31-
const start = Date.now();
32-
while (Date.now() - start < maxMs) {
33-
if (signal.aborted) return null;
34-
try {
35-
const res = await fetch(
36-
`/api/lx/articles/latest?projectId=${encodeURIComponent(projectId)}&since=${encodeURIComponent(since.toISOString())}`,
37-
{ cache: "no-store", signal },
38-
);
39-
if (res.status === 200) {
40-
const json = (await res.json()) as { ok?: boolean; article?: { id: string } };
41-
if (json.ok && json.article?.id) return json.article.id;
42-
}
43-
} catch {
44-
// transient — keep polling
45-
}
46-
await new Promise((r) => setTimeout(r, 2000));
47-
}
48-
return null;
49-
}
50-
5117
export function DashboardActions({
5218
paused,
5319
projectId,
@@ -79,39 +45,26 @@ export function DashboardActions({
7945

8046
async function generateArticleNow() {
8147
setBusy("article");
82-
setNotice("Generating… this can take 1–3 minutes.");
48+
setNotice(null);
8349
setError(null);
84-
const since = new Date();
8550
const r = await call(
8651
`/api/lx/articles/generate?projectId=${encodeURIComponent(projectId)}`,
8752
);
53+
setBusy(null);
8854
if (!r.ok) {
89-
setBusy(null);
90-
setNotice(null);
9155
setError(r.error ?? "Could not queue article.");
9256
return;
9357
}
94-
const controller = new AbortController();
95-
const articleId = await waitForNewArticle(
96-
projectId,
97-
since,
98-
controller.signal,
58+
// Don't poll from the client — the amber Previews section above
59+
// owns the in-flight state via the page's lx_keyword query, and
60+
// AutoblogAutoRefresh re-fetches every few seconds while anything
61+
// is generating. We just kick the first refresh so the worker's
62+
// 'generating' claim shows up immediately instead of after the
63+
// next interval tick.
64+
setNotice(
65+
"Queued — the preview will appear in the section above when it's ready (1–3 minutes).",
9966
);
100-
setBusy(null);
101-
if (articleId) {
102-
// Stay on the dashboard so the new preview link appears at the
103-
// top with the rest of the queue. Redirecting away was confusing
104-
// when users wanted to compare multiple previews; the link in the
105-
// amber "Previews waiting on Publish" section goes to the same
106-
// place when they're ready to review.
107-
setNotice("Preview ready — see the section at the top.");
108-
router.refresh();
109-
} else {
110-
setNotice(
111-
"Still generating. Refresh this page in a minute to see the preview at the top.",
112-
);
113-
router.refresh();
114-
}
67+
router.refresh();
11568
}
11669

11770
function togglePause() {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { useRouter } from "next/navigation";
5+
6+
// Periodically calls router.refresh() while `active` is true so the
7+
// autoblog dashboard can transition from "1 generating" → "preview ready"
8+
// without the user manually reloading. The parent decides when this is
9+
// active (passes `inFlightCount > 0`); the component stops cleanly when
10+
// that flips to false.
11+
//
12+
// 8s cadence balances responsiveness against server load — generation
13+
// itself takes 1–3 min, so polling much faster than this is wasted.
14+
const REFRESH_INTERVAL_MS = 8000;
15+
16+
export function AutoblogAutoRefresh({ active }: { active: boolean }) {
17+
const router = useRouter();
18+
useEffect(() => {
19+
if (!active) return;
20+
const id = setInterval(() => router.refresh(), REFRESH_INTERVAL_MS);
21+
return () => clearInterval(id);
22+
}, [active, router]);
23+
return null;
24+
}

‎app/(app)/projects/[id]/autoblog/page.tsx‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { DashboardActions } from "./actions";
66
import { Countdown } from "./countdown";
77
import { checkAutoblogReadiness, readinessLabel } from "@/lib/lx/readiness";
88
import { DeleteAutoblogButton } from "./delete-autoblog-button";
9+
import { AutoblogAutoRefresh } from "./auto-refresh";
910

1011
export const metadata = { title: "Autoblog" };
1112

@@ -89,6 +90,7 @@ export default async function AutoblogDashboardPage({
8990
{ data: upcoming },
9091
{ data: recent },
9192
{ data: previews },
93+
{ data: inFlight },
9294
] = await Promise.all([
9395
supabase
9496
.from("lx_keyword")
@@ -128,7 +130,20 @@ export default async function AutoblogDashboardPage({
128130
.eq("status", "ready")
129131
.order("created_at", { ascending: false })
130132
.limit(10),
133+
// Keywords actively being turned into articles. The autoblog worker
134+
// sets status='generating' the moment it claims a keyword and only
135+
// flips it off ('published' on success, 'failed' on error) once
136+
// generation resolves. Surfacing this count lets the page show a
137+
// live in-flight indicator instead of looking identical pre- and
138+
// post-click.
139+
supabase
140+
.from("lx_keyword")
141+
.select("id, keyword")
142+
.eq("site_id", site.id)
143+
.eq("status", "generating")
144+
.order("scheduled_for", { ascending: true }),
131145
]);
146+
const inFlightCount = (inFlight ?? []).length;
132147

133148
const readiness = await readinessPromise;
134149

@@ -193,14 +208,20 @@ export default async function AutoblogDashboardPage({
193208
)}
194209

195210
{/* Generated articles awaiting Publish — always visible so the
196-
user has a known destination after clicking Generate. Renders an
197-
empty-state when nothing's in 'ready' yet, keeping the section
198-
predictable on the page. */}
211+
user has a known destination after clicking Generate. Shows three
212+
distinct states so the page is the source of truth post-click
213+
instead of relying on a client-side poller: in-flight (keyword
214+
claimed by worker), previews ready, or empty. AutoblogAutoRefresh
215+
re-fetches on an interval while anything is in flight so the page
216+
transitions to "ready" without a manual reload. */}
217+
<AutoblogAutoRefresh active={inFlightCount > 0} />
199218
<section className="rounded border border-amber-500/40 bg-amber-500/5 p-4">
200219
<h2 className="text-sm font-bold text-amber-600 dark:text-amber-400">
201220
{(previews ?? []).length > 0
202221
? `⚠ ${previews!.length} preview${previews!.length === 1 ? "" : "s"} waiting on Publish`
203-
: "Previews waiting on Publish"}
222+
: inFlightCount > 0
223+
? `⏳ ${inFlightCount} generating now`
224+
: "Previews waiting on Publish"}
204225
</h2>
205226
{(previews ?? []).length > 0 ? (
206227
<>
@@ -223,12 +244,36 @@ export default async function AutoblogDashboardPage({
223244
</li>
224245
))}
225246
</ul>
247+
{inFlightCount > 0 && (
248+
<p className="mt-3 text-xs text-[var(--color-muted)]">
249+
Plus {inFlightCount} more generating — this page will refresh
250+
when {inFlightCount === 1 ? "it lands" : "they land"}.
251+
</p>
252+
)}
253+
</>
254+
) : inFlightCount > 0 ? (
255+
<>
256+
<p className="mt-1 text-xs text-[var(--color-muted)]">
257+
Generation takes 1–3 minutes (LLM + image + embeddings). This
258+
page is auto-refreshing — the preview link will appear here
259+
the moment it's ready.
260+
</p>
261+
<ul className="mt-3 space-y-1 text-sm">
262+
{(inFlight ?? []).map((k: any) => (
263+
<li key={k.id} className="text-[var(--color-muted)]">
264+
<span className="font-medium text-[var(--foreground)]">
265+
{k.keyword}
266+
</span>{" "}
267+
<span className="badge badge-warn ml-1">generating</span>
268+
</li>
269+
))}
270+
</ul>
226271
</>
227272
) : (
228273
<p className="mt-1 text-xs text-[var(--color-muted)]">
229274
No previews yet. Click <em>Generate article now</em> below to
230-
produce one — it'll appear here in ~60–90 seconds, then you can
231-
review and publish.
275+
produce one — it usually takes 1–3 minutes. The preview will
276+
appear here automatically when it's ready.
232277
</p>
233278
)}
234279
</section>

‎app/api/lx/articles/latest/route.ts‎

Lines changed: 0 additions & 75 deletions
This file was deleted.

0 commit comments

Comments
 (0)