File size: 6,050 Bytes
88d2f2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
"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>
  );
}