polyglot-alpha / ui /components /TriggerButton.tsx
licaomeng
deploy: main@8970ffb → HF Spaces (2026-05-27T05:19Z)
88d2f2a
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Play, Check, Loader2 } from "lucide-react";
import { triggerEvent } from "@/lib/api";
import { useDemoMode } from "@/contexts/ModeContext";
// W16-A: button no longer waits for lifecycle terminal status before
// navigating. The detail page renders the phases in "pending" state
// immediately and animates them live via its own SSE subscription. This
// flips the perceived latency from 40+s (in dev mode) down to whatever the
// POST round-trip takes (~50-300ms). The progressive labels that used to
// drive the busy state are gone because they're invisible after we push.
const TRIGGER_LABEL_LIVE = "Trigger live demo";
const TRIGGER_LABEL_MOCK = "Trigger mock demo";
const TRIGGER_LABEL_BUSY = "Triggering…";
const TRIGGER_LABEL_SLOW = "Backend slow — still trying…";
// Soft hint timer: if the POST hasn't returned within 5s we swap the busy
// label to a "Backend slow…" reassurance. The user can still navigate away
// manually; we don't actually unblock or auto-redirect anywhere because
// without an event_id there's nothing to navigate to.
const SLOW_HINT_MS = 5_000;
// Hard fallback: if POST hangs for this long, give up and reset the button
// so the user can retry without reloading the page.
const POST_HARD_TIMEOUT_MS = 30_000;
export function TriggerButton() {
const router = useRouter();
const { mode, isHydrated } = useDemoMode();
// W7-E: keep the label SSR-safe ("Trigger live demo") until the mode
// context has hydrated so the server-rendered button text matches the
// first client paint. After hydration the label switches to reflect the
// resolved mode without producing a React hydration mismatch warning.
const effectiveMode: typeof mode = isHydrated ? mode : "live";
const [busy, setBusy] = useState(false);
const [triggered, setTriggered] = useState(false);
const [slow, setSlow] = useState(false);
const [errorHint, setErrorHint] = useState<string | null>(null);
const slowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clean up any leftover timers on unmount so they can't fire after
// navigation.
useEffect(() => {
return () => {
if (slowTimerRef.current) clearTimeout(slowTimerRef.current);
if (hardTimerRef.current) clearTimeout(hardTimerRef.current);
};
}, []);
const label = useMemo(() => {
if (!busy) {
if (triggered) return "Triggered";
return effectiveMode === "mock" ? TRIGGER_LABEL_MOCK : TRIGGER_LABEL_LIVE;
}
return slow ? TRIGGER_LABEL_SLOW : TRIGGER_LABEL_BUSY;
}, [busy, triggered, slow, effectiveMode]);
const clearTimers = () => {
if (slowTimerRef.current) {
clearTimeout(slowTimerRef.current);
slowTimerRef.current = null;
}
if (hardTimerRef.current) {
clearTimeout(hardTimerRef.current);
hardTimerRef.current = null;
}
};
const onClick = async () => {
setBusy(true);
setTriggered(false);
setSlow(false);
setErrorHint(null);
// W7-A fallback safety net — these timers only fire if the POST never
// returns within their window. In the happy path (POST in 50-300ms) the
// success branch clears them before they get a chance to run.
slowTimerRef.current = setTimeout(() => setSlow(true), SLOW_HINT_MS);
hardTimerRef.current = setTimeout(() => {
// POST is wedged. Don't auto-navigate (no event id). Reset so the
// user can retry. The slow label stays visible briefly as feedback.
setBusy(false);
setErrorHint(
"Backend didn't respond in 30s — try again or see /events for cached runs.",
);
}, POST_HARD_TIMEOUT_MS);
try {
// Forward the user-selected demo mode (W5-B) so the backend can pick
// the synthetic mock lifecycle vs. the real RSS pipeline. Pass the
// resolved `mode` (hydrated context) — `effectiveMode` exists only
// so the SSR/CSR labels match before hydration.
const result = await triggerEvent(undefined, mode);
clearTimers();
if (result?.event_id) {
// Push immediately. The detail page has its own SSE subscription
// and renders phases in "pending" state until the first event
// lands, so the user sees the DAG animate live instead of staring
// at the trigger button.
setTriggered(true);
router.push(`/events/${String(result.event_id)}`);
// Leave `busy=true` for a tick so the spinner stays visible until
// Next.js commits the route transition. The component unmounts on
// navigation anyway; the cleanup effect handles any stragglers.
setBusy(false);
} else {
// Unexpected — POST succeeded but no id. Fall back to the events
// list so the user can still find their run.
setBusy(false);
setTriggered(true);
router.push(`/events`);
}
} catch {
clearTimers();
setBusy(false);
setSlow(false);
setErrorHint("Backend unreachable — see /events for cached runs.");
}
};
return (
<div className="flex flex-col gap-2">
<Button
variant="outline"
disabled={busy}
onClick={onClick}
aria-label="Trigger a live demo event"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" aria-hidden />
) : triggered ? (
<Check className="h-4 w-4 text-emerald-400" aria-hidden />
) : (
<Play className="h-4 w-4" aria-hidden />
)}
{label}
</Button>
{(busy || errorHint) && (
<p
className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground"
aria-live="polite"
>
{errorHint ?? (slow ? TRIGGER_LABEL_SLOW : TRIGGER_LABEL_BUSY)}
</p>
)}
</div>
);
}