File size: 3,948 Bytes
80bfc09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { useCallback, useEffect, useRef, useState } from "react";
import { useApi } from "@/contexts/ApiContext";
import { useHfAuth } from "@/contexts/HfAuthContext";
import { listLocalDatasets } from "@/lib/replayApi";
import { listJobs } from "@/lib/jobsApi";
import type { RobotRecord } from "@/hooks/useRobots";
import type { ProgressSnapshot } from "@/lib/onboardingSteps";

// Matches the key useRobots persists the selected robot under.
const SELECTED_KEY = "lelab.selectedRobot";
// The tour only needs to notice progress within a few seconds; a gentle cadence
// keeps load off the backend, and polling pauses entirely while the tab is
// hidden.
const POLL_MS = 3000;

const EMPTY: ProgressSnapshot = {
  hasSelectedRobot: false,
  robotIsClean: false,
  hasLocalDataset: false,
  hasTrainedModel: false,
  isAuthenticated: false,
};

/**
 * A read-only snapshot of how far the user has actually gotten, derived from
 * existing endpoints. It only fetches while the tour is active, so it adds
 * zero cost when the tour is closed. Deliberately does NOT reuse useRobots
 * (which refetches per route and owns selection) — a second instance would
 * double traffic and race the selection state.
 *
 * Only cheap local endpoints are polled — /robots, /datasets?scope=local (a
 * filesystem scan, no Hub call), and /jobs — so the tour never adds Hugging
 * Face Hub API load. Tracked cloud jobs already surface in /jobs, and auth
 * comes from the existing HfAuth context.
 */
export function useOnboardingProgress(active: boolean): ProgressSnapshot {
  const { baseUrl, fetchWithHeaders } = useApi();
  const { auth } = useHfAuth();
  const [snapshot, setSnapshot] = useState<ProgressSnapshot>(EMPTY);
  const isAuthenticated = auth.status === "authenticated";

  const refresh = useCallback(async () => {
    let selectedName: string | null = null;
    try {
      selectedName = localStorage.getItem(SELECTED_KEY);
    } catch {
      // Storage unavailable — treat as no selection.
    }

    const [robotsBody, datasets, jobs] = await Promise.all([
      fetchWithHeaders(`${baseUrl}/robots`)
        .then((r) => (r.ok ? r.json() : null))
        .catch(() => null),
      listLocalDatasets(baseUrl, fetchWithHeaders).catch(() => []),
      listJobs(baseUrl, fetchWithHeaders, 20).catch(() => []),
    ]);

    const records: RobotRecord[] = robotsBody?.robots ?? [];
    const selected = selectedName
      ? records.find((r) => r.name === selectedName) ?? null
      : null;

    setSnapshot({
      hasSelectedRobot: !!selected,
      robotIsClean: !!selected?.is_clean,
      hasLocalDataset: datasets.some(
        (d) => d.source === "local" || d.source === "both"
      ),
      hasTrainedModel: jobs.some(
        (j) => j.checkpoint_count > 0 || j.state === "done"
      ),
      isAuthenticated,
    });
  }, [baseUrl, fetchWithHeaders, isAuthenticated]);

  // Keep the latest refresh in a ref so the polling interval never tears down.
  const refreshRef = useRef(refresh);
  refreshRef.current = refresh;

  useEffect(() => {
    if (!active) {
      setSnapshot(EMPTY);
      return;
    }
    let cancelled = false;
    let inFlight = false;
    const tick = async () => {
      // Skip while backgrounded, and never let ticks pile up if one is slow.
      if (cancelled || inFlight || document.visibilityState === "hidden") return;
      inFlight = true;
      try {
        await refreshRef.current();
      } finally {
        inFlight = false;
      }
    };
    tick();
    const id = setInterval(tick, POLL_MS);
    // Refresh promptly when the user returns to the tab.
    const onVisible = () => {
      if (document.visibilityState === "visible") tick();
    };
    document.addEventListener("visibilitychange", onVisible);
    return () => {
      cancelled = true;
      clearInterval(id);
      document.removeEventListener("visibilitychange", onVisible);
    };
  }, [active]);

  return snapshot;
}