diff --git a/dashboard/src/app/recipes/[name]/layout.tsx b/dashboard/src/app/recipes/[name]/layout.tsx index 5bce4baa..86cb2bd0 100644 --- a/dashboard/src/app/recipes/[name]/layout.tsx +++ b/dashboard/src/app/recipes/[name]/layout.tsx @@ -198,7 +198,7 @@ export default function RecipeDetailLayout({ const { data: recipes } = useBridgeFetch( "/api/bridge/recipes", { - intervalMs: 30_000, + intervalMs: 10_000, transform: (raw) => { if (Array.isArray(raw)) return raw as RecipeSummary[]; const obj = raw as RecipesListResponse; diff --git a/dashboard/src/app/recipes/[name]/page.tsx b/dashboard/src/app/recipes/[name]/page.tsx index b2d5e7a9..96d46a3a 100644 --- a/dashboard/src/app/recipes/[name]/page.tsx +++ b/dashboard/src/app/recipes/[name]/page.tsx @@ -16,10 +16,10 @@ */ import Link from "next/link"; -import { use, useCallback, useEffect, useMemo, useState } from "react"; +import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { apiPath } from "@/lib/api"; -import { canonicalRecipeKey } from "@/lib/entityKey"; +import { canonicalRecipeKey, inboxItemKey } from "@/lib/entityKey"; import { useBridgeFetch } from "@/hooks/useBridgeFetch"; import { useToast } from "@/components/Toast"; import { Dialog } from "@/components/Dialog"; @@ -266,9 +266,11 @@ export default function RecipeHubOverviewPage({ ); // Runs filtered by recipe — bridge supports ?recipe= filter. - const { data: runsResp } = useBridgeFetch<{ runs: RunRecord[] }>( + // intervalMs is adaptive: poll at 3s when any run is in-flight, 10s otherwise. + const [runsIntervalMs, setRunsIntervalMs] = useState(10_000); + const { data: runsResp, refetch: refetchRuns } = useBridgeFetch<{ runs: RunRecord[] }>( `/api/bridge/runs?recipe=${encodeURIComponent(name)}&limit=50`, - { intervalMs: 10_000 }, + { intervalMs: runsIntervalMs }, ); const runs: RunRecord[] = useMemo(() => { const raw = runsResp?.runs ?? []; @@ -278,6 +280,49 @@ export default function RecipeHubOverviewPage({ .sort((a, b) => b.startedAt - a.startedAt); }, [runsResp, name]); + // Adaptive polling interval: 3s while any run is in-flight, 10s when idle. + // We update via setState only when the value needs to change to avoid loops. + const prevInFlightRef = useRef(null); + useEffect(() => { + const IN_FLIGHT_STATUSES = new Set(["running", "queued", "pending"]); + const hasInFlight = runs.some((r) => IN_FLIGHT_STATUSES.has(r.status)); + if (prevInFlightRef.current === hasInFlight) return; // stable, no-op + prevInFlightRef.current = hasInFlight; + setRunsIntervalMs(hasInFlight ? 3_000 : 10_000); + }, [runs]); + + // Toast once when a newly-completed run with inbox output is detected. + // -1 = "not yet initialised" (skip toasting until we've seen the initial data). + const lastSeenCompletedSeqRef = useRef(-1); + useEffect(() => { + if (runs.length === 0) return; + const highestSeq = runs.reduce((m, r) => (typeof r.seq === "number" && r.seq > m ? r.seq : m), -1); + // First render: mark existing runs as already-seen so we don't spam toasts on load. + if (lastSeenCompletedSeqRef.current === -1) { + lastSeenCompletedSeqRef.current = highestSeq; + return; + } + const DONE_STATUSES = new Set(["done", "success"]); + // Find completed runs newer than last-seen that produced inbox output. + for (const r of runs) { + if (typeof r.seq !== "number") continue; + if (r.seq <= lastSeenCompletedSeqRef.current) break; // already seen (runs sorted desc) + if (DONE_STATUSES.has(r.status) && Array.isArray(r.inboxOutputs) && r.inboxOutputs.length > 0) { + const output = [...r.inboxOutputs].sort((a, b) => b.deliveredAt - a.deliveredAt)[0]; + const key = inboxItemKey(output.filename); + toast.success("Output delivered to inbox", { + action: { + label: "View in inbox", + onClick: () => router.push(`/inbox?item=${encodeURIComponent(key)}`), + }, + }); + break; // toast at most once per poll cycle + } + } + lastSeenCompletedSeqRef.current = highestSeq; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runs]); + // Halt summary filtered by recipe. const { data: haltSummary } = useBridgeFetch( `/api/bridge/runs/halt-summary?recipe=${encodeURIComponent(name)}`, @@ -365,6 +410,8 @@ export default function RecipeHubOverviewPage({ action: { label: "View run", onClick: () => router.push(runHref) }, }); setRunModalOpen(false); + refetchRuns(); + refetchRecipes(); } else { toast.error(`Run failed: ${data.error ?? "unknown"}`); } @@ -374,7 +421,7 @@ export default function RecipeHubOverviewPage({ setRunStarting(false); } }, - [recipe, toast, router], + [recipe, toast, router, refetchRuns, refetchRecipes], ); const handleToggle = useCallback(async () => { @@ -612,6 +659,20 @@ export default function RecipeHubOverviewPage({ {r.status} )} {relTime(r.startedAt)} + {Array.isArray(r.inboxOutputs) && r.inboxOutputs.length > 0 && ( + + → inbox + + )} {formatDuration(r.durationMs)} @@ -694,12 +755,18 @@ export default function RecipeHubOverviewPage({ {/* LATEST INBOX OUTPUT */} {latestInboxOutput && ( - Latest inbox output + Latest output → Inbox
- {relTime(latestInboxOutput.deliveredAt)} + delivered {relTime(latestInboxOutput.deliveredAt)} + + View in inbox → +
)}