From 18aa0eea1ba06cafe59f8e977282bd6e86f9768a Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 02:35:29 +0200 Subject: [PATCH 01/10] feat(onboarding): add interactive spotlight tour engine New users land in front of the full LeRobot pipeline at once, with no guide through calibrate, record, train, and run. There was no onboarding surface beyond the install modal. Add a self-contained guided tour built on the Radix/shadcn primitives already in the tree, so it pulls in no new dependencies. A step model describes each stage in plain language and names the real element to highlight via a data-tour attribute; a spotlight overlay dims the page, cuts a hole over the target, tracks it across scroll and resize, and positions a tour card beside it. An OnboardingProvider drives the linear step machine and persists a seen flag, and a reusable launcher button starts the tour from the landing top bar or a floating corner control on any page. This is the first, hardware-independent layer: the walkthrough runs and is drivable end to end. Cross-page navigation, state awareness, the first-visit welcome, and motion polish build on top in later commits. --- frontend/src/App.tsx | 37 +++-- frontend/src/components/jobs/JobsSection.tsx | 2 +- .../src/components/landing/LandingTopBar.tsx | 6 +- frontend/src/components/landing/RobotTile.tsx | 5 +- .../onboarding/SpotlightOverlay.tsx | 113 ++++++++++++++ .../src/components/onboarding/TourCard.tsx | 145 ++++++++++++++++++ .../components/onboarding/TourLauncher.tsx | 58 +++++++ frontend/src/contexts/OnboardingContext.tsx | 134 ++++++++++++++++ frontend/src/lib/onboardingSteps.ts | 113 ++++++++++++++ frontend/src/pages/Calibration.tsx | 6 +- frontend/src/pages/Landing.tsx | 10 +- 11 files changed, 605 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/onboarding/SpotlightOverlay.tsx create mode 100644 frontend/src/components/onboarding/TourCard.tsx create mode 100644 frontend/src/components/onboarding/TourLauncher.tsx create mode 100644 frontend/src/contexts/OnboardingContext.tsx create mode 100644 frontend/src/lib/onboardingSteps.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc3cf52c..429724d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import UpdateNotice from "@/components/UpdateNotice"; import { TooltipProvider } from "@radix-ui/react-tooltip"; import { ApiProvider } from "./contexts/ApiContext"; import { HfAuthProvider } from "./contexts/HfAuthContext"; +import { OnboardingProvider } from "./contexts/OnboardingContext"; const queryClient = new QueryClient(); @@ -33,24 +34,26 @@ function App() { - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } /> - - - + } /> + + + + diff --git a/frontend/src/components/jobs/JobsSection.tsx b/frontend/src/components/jobs/JobsSection.tsx index 1ae635b9..04244647 100644 --- a/frontend/src/components/jobs/JobsSection.tsx +++ b/frontend/src/components/jobs/JobsSection.tsx @@ -245,7 +245,7 @@ const JobsSection: React.FC = () => { untrackedHubInactive.length; return ( -
+

Jobs

diff --git a/frontend/src/components/landing/LandingTopBar.tsx b/frontend/src/components/landing/LandingTopBar.tsx index c8646927..a36321f6 100644 --- a/frontend/src/components/landing/LandingTopBar.tsx +++ b/frontend/src/components/landing/LandingTopBar.tsx @@ -1,5 +1,6 @@ import React from "react"; import HfAuthChip from "./HfAuthChip"; +import TourLauncher from "@/components/onboarding/TourLauncher"; const LandingTopBar: React.FC = () => { return ( @@ -15,7 +16,10 @@ const LandingTopBar: React.FC = () => { LeLab
- +
+ + +
); diff --git a/frontend/src/components/landing/RobotTile.tsx b/frontend/src/components/landing/RobotTile.tsx index f2ffa55f..f8790c98 100644 --- a/frontend/src/components/landing/RobotTile.tsx +++ b/frontend/src/components/landing/RobotTile.tsx @@ -47,7 +47,7 @@ const RobotTile: React.FC = ({ return (
-
+
= ({ className="h-8 w-8 text-gray-300 hover:text-white" onClick={() => onConfigure(robot.name)} aria-label="Configure" + data-tour="robot-configure" > @@ -102,7 +103,7 @@ const RobotTile: React.FC = ({ {robot && ( -
+
+ +
+

+ Step {stepIndex + 1} of {totalSteps} +

+

+ {currentStep.title} +

+

+ {currentStep.body} +

+ +
+ +
+ {currentStep.optional && !isLast && ( + + )} + +
+
+
+
+ ); +}; + +export default TourCard; diff --git a/frontend/src/components/onboarding/TourLauncher.tsx b/frontend/src/components/onboarding/TourLauncher.tsx new file mode 100644 index 00000000..041d059e --- /dev/null +++ b/frontend/src/components/onboarding/TourLauncher.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { HelpCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useOnboarding } from "@/contexts/OnboardingContext"; + +interface TourLauncherProps { + /** "floating" pins a round button to the corner on every page; "inline" is + * a compact button for the landing top bar. */ + variant?: "floating" | "inline"; + className?: string; +} + +const TourLauncher: React.FC = ({ + variant = "inline", + className, +}) => { + const { start, isActive } = useOnboarding(); + + // The floating button would only get in the way while the tour is running. + if (variant === "floating" && isActive) return null; + + if (variant === "floating") { + return ( + + ); + } + + return ( + + ); +}; + +export default TourLauncher; diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx new file mode 100644 index 00000000..9814c716 --- /dev/null +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -0,0 +1,134 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; +import { ONBOARDING_STEPS, TourStep } from "@/lib/onboardingSteps"; +import SpotlightOverlay from "@/components/onboarding/SpotlightOverlay"; +import TourLauncher from "@/components/onboarding/TourLauncher"; + +// Persisted across sessions, mirroring the useUpdateCheck pattern. The `-v1` +// suffix lets a future revamp re-offer the tour to everyone by bumping it. +const STORAGE_KEY = "lelab:onboarding-v1"; +type OnboardingStatus = "dismissed" | "completed"; + +const readStatus = (): OnboardingStatus | null => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw === "dismissed" || raw === "completed" ? raw : null; + } catch { + return null; + } +}; + +const writeStatus = (status: OnboardingStatus) => { + try { + localStorage.setItem(STORAGE_KEY, status); + } catch { + // Storage may be unavailable (private mode, quota); nothing to persist. + } +}; + +interface OnboardingContextValue { + isActive: boolean; + currentStep: TourStep | null; + stepIndex: number; + totalSteps: number; + /** True once the user has finished or dismissed the tour at least once. */ + hasSeen: boolean; + start: () => void; + next: () => void; + back: () => void; + /** Advance past an optional step without performing its action. */ + skipStep: () => void; + /** Leave the tour. `completed` records how it ended for the persisted flag. */ + stop: (completed: boolean) => void; +} + +const OnboardingContext = createContext(null); + +export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [isActive, setIsActive] = useState(false); + const [stepIndex, setStepIndex] = useState(0); + const [hasSeen, setHasSeen] = useState(() => readStatus() !== null); + + const totalSteps = ONBOARDING_STEPS.length; + const currentStep = + isActive && stepIndex >= 0 && stepIndex < totalSteps + ? ONBOARDING_STEPS[stepIndex] + : null; + + const stop = useCallback((completed: boolean) => { + setIsActive(false); + writeStatus(completed ? "completed" : "dismissed"); + setHasSeen(true); + }, []); + + const start = useCallback(() => { + setStepIndex(0); + setIsActive(true); + }, []); + + const next = useCallback(() => { + setStepIndex((i) => { + if (i >= totalSteps - 1) { + stop(true); + return i; + } + return i + 1; + }); + }, [totalSteps, stop]); + + const back = useCallback(() => { + setStepIndex((i) => Math.max(0, i - 1)); + }, []); + + const skipStep = next; + + const value = useMemo( + () => ({ + isActive, + currentStep, + stepIndex, + totalSteps, + hasSeen, + start, + next, + back, + skipStep, + stop, + }), + [ + isActive, + currentStep, + stepIndex, + totalSteps, + hasSeen, + start, + next, + back, + skipStep, + stop, + ] + ); + + return ( + + {children} + + + + ); +}; + +export const useOnboarding = (): OnboardingContextValue => { + const ctx = useContext(OnboardingContext); + if (!ctx) { + throw new Error("useOnboarding must be used within an OnboardingProvider"); + } + return ctx; +}; diff --git a/frontend/src/lib/onboardingSteps.ts b/frontend/src/lib/onboardingSteps.ts new file mode 100644 index 00000000..dfe8afff --- /dev/null +++ b/frontend/src/lib/onboardingSteps.ts @@ -0,0 +1,113 @@ +// Declarative model for the beginner onboarding tour. Each step points the +// spotlight at a real element (by its `data-tour` attribute) on a given route +// and explains, in plain language, one stage of the record -> train -> run loop. + +export type Placement = "top" | "bottom" | "left" | "right" | "center"; + +export interface TourStep { + /** Stable id, also used as the log/debug label. */ + id: string; + /** Route this step lives on; the tour navigates here before showing it. */ + route: string; + /** `data-tour` value of the element to spotlight. Omit for a centered card. */ + target?: string; + title: string; + body: string; + placement?: Placement; + /** Optional steps can be skipped without doing the action (e.g. no hardware). */ + optional?: boolean; +} + +export const ONBOARDING_STEPS: TourStep[] = [ + { + id: "welcome", + route: "/", + placement: "center", + title: "Welcome to LeLab", + body: "This quick tour walks you from a fresh setup to a robot that runs a policy you trained yourself: calibrate, record, train, run. It takes about two minutes, and you can leave anytime with Esc.", + }, + { + id: "pick-robot", + route: "/", + target: "robot-selector", + placement: "bottom", + title: "Pick or name your arm", + body: "Start here. Choose an existing robot, or type a new name to create one. Everything else in the tour hangs off the arm you select.", + }, + { + id: "calibrate", + route: "/calibration", + target: "calibration-start", + placement: "bottom", + title: "Calibrate the arms", + body: "Calibration teaches the app each joint's range of motion so movements map correctly. Run it once for the leader arm, then the follower. Press Start and follow the on-screen steps.", + optional: true, + }, + { + id: "cameras", + route: "/calibration", + target: "calibration-cameras", + placement: "top", + title: "Add cameras (if your task needs sight)", + body: "If the robot has to see what it's doing, add one or more cameras here. They're saved with this robot and reused when you record. Skip this if your task doesn't need vision.", + optional: true, + }, + { + id: "teleop", + route: "/", + target: "robot-teleop", + placement: "top", + title: "Try teleoperation (optional)", + body: "Drive the follower arm by moving the leader. It's a good way to confirm calibration feels right before you record anything. This button unlocks once the arm is calibrated.", + optional: true, + }, + { + id: "record", + route: "/", + target: "dataset-picker", + placement: "bottom", + title: "Record a dataset", + body: "A dataset is a set of episodes: one attempt at your task each. Episode time is how long each attempt runs; reset time is the pause to reposition objects between attempts. Aim for 10 to 50 clean episodes to start.", + }, + { + id: "train", + route: "/", + target: "training-entry", + placement: "bottom", + title: "Train a policy", + body: "Turn your recorded episodes into a policy the robot can run. ACT is fast and a great first choice; SmolVLA is a larger vision-language model. You can train on your own GPU, or rent one in the cloud with a Hugging Face login.", + }, + { + id: "watch-training", + route: "/", + target: "jobs-section", + placement: "top", + title: "Watch it train", + body: "Your training job shows up here with live progress. Once it has a usable checkpoint, a green play button appears on the model so you can run it.", + }, + { + id: "inference", + route: "/", + target: "jobs-section", + placement: "top", + title: "Run your policy", + body: "Press the play button on a finished model to let the robot attempt the task on its own, using what it learned from your episodes. This is the payoff of the whole loop.", + optional: true, + }, + { + id: "upload", + route: "/", + target: "training-entry", + placement: "bottom", + title: "Share on the Hub (optional)", + body: "Log in to Hugging Face to push your datasets and trained models to the Hub, so you can back them up, share them, or train in the cloud.", + optional: true, + }, + { + id: "done", + route: "/", + placement: "center", + title: "That's the whole loop", + body: "Calibrate, record, train, run. You can reopen this tour anytime from the ? button in the corner. Happy building.", + }, +]; diff --git a/frontend/src/pages/Calibration.tsx b/frontend/src/pages/Calibration.tsx index acf285bc..2be17b8c 100644 --- a/frontend/src/pages/Calibration.tsx +++ b/frontend/src/pages/Calibration.tsx @@ -623,6 +623,7 @@ const Calibration = () => { onClick={handleStartCalibration} className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded-full py-6 text-lg" disabled={!robotName || !deviceType || !port} + data-tour="calibration-start" > Start Calibration @@ -904,7 +905,10 @@ const Calibration = () => {
{robotName && ( - + diff --git a/frontend/src/pages/Landing.tsx b/frontend/src/pages/Landing.tsx index 33832569..5b047647 100644 --- a/frontend/src/pages/Landing.tsx +++ b/frontend/src/pages/Landing.tsx @@ -241,7 +241,10 @@ const Landing = () => { deleteRobot={deleteRobot} />
-
+

Dataset

@@ -266,7 +269,10 @@ const Landing = () => {
-
+

Create a model

From 56b49bd0b3098dd24990070deed2db9f102293a0 Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 02:36:09 +0200 Subject: [PATCH 02/10] feat(onboarding): walk the tour across pages The engine only showed steps on whatever page happened to be open, so a step describing calibration or recording had nothing to point at unless the user was already there. Drive navigation from the step model: when a step is entered, route to the page it lives on so its target can mount and be highlighted. The calibration page needs the selected robot in navigation state to render that robot's controls, so forward the persisted selection when heading there. Navigation fires once per step entry rather than on every location change, so a user who clicks elsewhere mid-step is not dragged back. --- frontend/src/contexts/OnboardingContext.tsx | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx index 9814c716..fa5e0fa7 100644 --- a/frontend/src/contexts/OnboardingContext.tsx +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -2,9 +2,12 @@ import React, { createContext, useCallback, useContext, + useEffect, useMemo, + useRef, useState, } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { ONBOARDING_STEPS, TourStep } from "@/lib/onboardingSteps"; import SpotlightOverlay from "@/components/onboarding/SpotlightOverlay"; import TourLauncher from "@/components/onboarding/TourLauncher"; @@ -52,6 +55,9 @@ const OnboardingContext = createContext(null); export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { + const navigate = useNavigate(); + const location = useLocation(); + const [isActive, setIsActive] = useState(false); const [stepIndex, setStepIndex] = useState(0); const [hasSeen, setHasSeen] = useState(() => readStatus() !== null); @@ -89,6 +95,37 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ const skipStep = next; + // Navigate to a step's route once, when that step is entered — never during + // render. We deliberately do NOT re-navigate on later location changes, so a + // user who clicks away mid-step isn't yanked back (the spotlight just falls + // back to a centered card). The calibration page needs a robot_name in + // navigation state to show the right robot's controls, so we forward the + // currently-selected robot (persisted by useRobots). + const lastNavStepId = useRef(null); + useEffect(() => { + if (!isActive || !currentStep) { + lastNavStepId.current = null; + return; + } + if (lastNavStepId.current === currentStep.id) return; + lastNavStepId.current = currentStep.id; + if (location.pathname === currentStep.route) return; + if (currentStep.route === "/calibration") { + let robotName: string | null = null; + try { + robotName = localStorage.getItem("lelab.selectedRobot"); + } catch { + // Storage unavailable — fall through to a plain navigation. + } + navigate( + "/calibration", + robotName ? { state: { robot_name: robotName } } : undefined + ); + return; + } + navigate(currentStep.route); + }, [isActive, currentStep, location.pathname, navigate]); + const value = useMemo( () => ({ isActive, From 5c60d0d2715b475fe01c18634dd3889cbbb25a00 Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 02:37:59 +0200 Subject: [PATCH 03/10] feat(onboarding): adapt the tour to the user's real progress A fixed click-through can't tell whether the user actually calibrated, recorded, or trained, so it either nags people who are already ahead or races ahead of people who aren't. Read a live progress snapshot from the endpoints the app already exposes (robots, datasets, jobs, auth) and use it to drive the walkthrough. The snapshot only polls while the tour is open, so it costs nothing when closed and deliberately avoids a second useRobots instance. Steps carry optional predicates: gate marks an action whose prerequisite isn't met yet and surfaces a hint, isComplete marks it done and gently auto-advances on the transition, and show hides steps that don't apply (for example, the Hub step for someone already signed in). The current step is always kept visible so a changing snapshot can never strand the tour mid-view. --- .../src/components/onboarding/TourCard.tsx | 46 +++++++-- frontend/src/contexts/OnboardingContext.tsx | 91 ++++++++++++++---- frontend/src/hooks/useOnboardingProgress.ts | 95 +++++++++++++++++++ frontend/src/lib/onboardingSteps.ts | 28 ++++++ 4 files changed, 234 insertions(+), 26 deletions(-) create mode 100644 frontend/src/hooks/useOnboardingProgress.ts diff --git a/frontend/src/components/onboarding/TourCard.tsx b/frontend/src/components/onboarding/TourCard.tsx index ec1feee3..0e38bc32 100644 --- a/frontend/src/components/onboarding/TourCard.tsx +++ b/frontend/src/components/onboarding/TourCard.tsx @@ -1,5 +1,5 @@ import React, { useLayoutEffect, useRef, useState } from "react"; -import { X } from "lucide-react"; +import { Check, Lock, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useOnboarding } from "@/contexts/OnboardingContext"; @@ -17,8 +17,17 @@ interface TourCardProps { } const TourCard: React.FC = ({ rect }) => { - const { currentStep, stepIndex, totalSteps, next, back, skipStep, stop } = - useOnboarding(); + const { + currentStep, + stepIndex, + totalSteps, + currentComplete, + currentGated, + next, + back, + skipStep, + stop, + } = useOnboarding(); const cardRef = useRef(null); const [size, setSize] = useState({ w: CARD_W, h: 0 }); @@ -97,9 +106,17 @@ const TourCard: React.FC = ({ rect }) => {
-

- Step {stepIndex + 1} of {totalSteps} -

+
+

+ Step {stepIndex + 1} of {totalSteps} +

+ {currentComplete && ( + + + Done + + )} +

{currentStep.title}

@@ -107,6 +124,14 @@ const TourCard: React.FC = ({ rect }) => { {currentStep.body}

+ {currentGated && !currentComplete && ( +

+ + Finish the earlier step to unlock this. You can keep reading and move + on with Skip. +

+ )} +
diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx index fa5e0fa7..413aed6d 100644 --- a/frontend/src/contexts/OnboardingContext.tsx +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -9,12 +9,17 @@ import React, { } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { ONBOARDING_STEPS, TourStep } from "@/lib/onboardingSteps"; +import { useOnboardingProgress } from "@/hooks/useOnboardingProgress"; import SpotlightOverlay from "@/components/onboarding/SpotlightOverlay"; import TourLauncher from "@/components/onboarding/TourLauncher"; // Persisted across sessions, mirroring the useUpdateCheck pattern. The `-v1` // suffix lets a future revamp re-offer the tour to everyone by bumping it. const STORAGE_KEY = "lelab:onboarding-v1"; +// Delay before auto-advancing once a step's goal is met, so the user sees the +// "done" state register before moving on. +const AUTO_ADVANCE_MS = 1100; + type OnboardingStatus = "dismissed" | "completed"; const readStatus = (): OnboardingStatus | null => { @@ -37,8 +42,13 @@ const writeStatus = (status: OnboardingStatus) => { interface OnboardingContextValue { isActive: boolean; currentStep: TourStep | null; + /** 0-based index of the current step among the steps visible to this user. */ stepIndex: number; totalSteps: number; + /** True when the current step's goal has been achieved. */ + currentComplete: boolean; + /** True when the current step's prerequisite is not met yet (info only). */ + currentGated: boolean; /** True once the user has finished or dismissed the tour at least once. */ hasSeen: boolean; start: () => void; @@ -59,39 +69,58 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ const location = useLocation(); const [isActive, setIsActive] = useState(false); - const [stepIndex, setStepIndex] = useState(0); + const [currentStepId, setCurrentStepId] = useState(null); const [hasSeen, setHasSeen] = useState(() => readStatus() !== null); - const totalSteps = ONBOARDING_STEPS.length; + const progress = useOnboardingProgress(isActive); + + // Steps whose `show` predicate excludes this user drop out — but the current + // step is always kept so it can never vanish mid-view and strand the tour. + const visibleSteps = useMemo( + () => + ONBOARDING_STEPS.filter( + (s) => s.id === currentStepId || !s.show || s.show(progress) + ), + [progress, currentStepId] + ); + + const stepIndex = currentStepId + ? visibleSteps.findIndex((s) => s.id === currentStepId) + : -1; const currentStep = - isActive && stepIndex >= 0 && stepIndex < totalSteps - ? ONBOARDING_STEPS[stepIndex] - : null; + isActive && stepIndex >= 0 ? visibleSteps[stepIndex] : null; + + const currentComplete = currentStep?.isComplete + ? currentStep.isComplete(progress) + : false; + const currentGated = currentStep?.gate ? !currentStep.gate(progress) : false; const stop = useCallback((completed: boolean) => { setIsActive(false); + setCurrentStepId(null); writeStatus(completed ? "completed" : "dismissed"); setHasSeen(true); }, []); const start = useCallback(() => { - setStepIndex(0); + setCurrentStepId(ONBOARDING_STEPS[0]?.id ?? null); setIsActive(true); }, []); const next = useCallback(() => { - setStepIndex((i) => { - if (i >= totalSteps - 1) { - stop(true); - return i; - } - return i + 1; - }); - }, [totalSteps, stop]); + const idx = visibleSteps.findIndex((s) => s.id === currentStepId); + if (idx < 0) return; + if (idx >= visibleSteps.length - 1) { + stop(true); + return; + } + setCurrentStepId(visibleSteps[idx + 1].id); + }, [visibleSteps, currentStepId, stop]); const back = useCallback(() => { - setStepIndex((i) => Math.max(0, i - 1)); - }, []); + const idx = visibleSteps.findIndex((s) => s.id === currentStepId); + if (idx > 0) setCurrentStepId(visibleSteps[idx - 1].id); + }, [visibleSteps, currentStepId]); const skipStep = next; @@ -126,12 +155,36 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ navigate(currentStep.route); }, [isActive, currentStep, location.pathname, navigate]); + // Auto-advance when a step's goal is met — but only on a false->true + // transition while the step is shown, so returning to an already-complete + // step via Back doesn't immediately bounce forward again. + const autoRef = useRef<{ id: string | null; done: boolean }>({ + id: null, + done: false, + }); + useEffect(() => { + if (!isActive || !currentStep) return; + if (autoRef.current.id !== currentStep.id) { + // Entering a (possibly already-complete) step: set the baseline and + // never auto-advance out of a step that was already done on arrival. + autoRef.current = { id: currentStep.id, done: currentComplete }; + return; + } + if (!autoRef.current.done && currentComplete) { + autoRef.current.done = true; + const t = setTimeout(() => next(), AUTO_ADVANCE_MS); + return () => clearTimeout(t); + } + }, [isActive, currentStep, currentComplete, next]); + const value = useMemo( () => ({ isActive, currentStep, stepIndex, - totalSteps, + totalSteps: visibleSteps.length, + currentComplete, + currentGated, hasSeen, start, next, @@ -143,7 +196,9 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ isActive, currentStep, stepIndex, - totalSteps, + visibleSteps.length, + currentComplete, + currentGated, hasSeen, start, next, diff --git a/frontend/src/hooks/useOnboardingProgress.ts b/frontend/src/hooks/useOnboardingProgress.ts new file mode 100644 index 00000000..8e65854e --- /dev/null +++ b/frontend/src/hooks/useOnboardingProgress.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useApi } from "@/contexts/ApiContext"; +import { useHfAuth } from "@/contexts/HfAuthContext"; +import { listDatasets } from "@/lib/replayApi"; +import { listHubJobs, 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"; +const POLL_MS = 2000; + +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. + */ +export function useOnboardingProgress(active: boolean): ProgressSnapshot { + const { baseUrl, fetchWithHeaders } = useApi(); + const { auth } = useHfAuth(); + const [snapshot, setSnapshot] = useState(EMPTY); + const authStatus = auth.status; + + 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, hub] = await Promise.all([ + fetchWithHeaders(`${baseUrl}/robots`) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null), + listDatasets(baseUrl, fetchWithHeaders).catch(() => []), + listJobs(baseUrl, fetchWithHeaders, 20).catch(() => []), + listHubJobs(baseUrl, fetchWithHeaders).catch(() => ({ + authenticated: false, + jobs: [], + models: [], + })), + ]); + + 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") || + hub.models.length > 0, + isAuthenticated: authStatus === "authenticated" || hub.authenticated, + }); + }, [baseUrl, fetchWithHeaders, authStatus]); + + // 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; + const tick = () => { + if (!cancelled) refreshRef.current(); + }; + tick(); + const id = setInterval(tick, POLL_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [active]); + + return snapshot; +} diff --git a/frontend/src/lib/onboardingSteps.ts b/frontend/src/lib/onboardingSteps.ts index dfe8afff..88d8f75d 100644 --- a/frontend/src/lib/onboardingSteps.ts +++ b/frontend/src/lib/onboardingSteps.ts @@ -1,9 +1,23 @@ // Declarative model for the beginner onboarding tour. Each step points the // spotlight at a real element (by its `data-tour` attribute) on a given route // and explains, in plain language, one stage of the record -> train -> run loop. +// +// `gate` / `isComplete` read a live snapshot of how far the user has actually +// gotten (see useOnboardingProgress) so the tour can adapt and auto-advance. +// Both are optional: a step without them is a simple click-through. export type Placement = "top" | "bottom" | "left" | "right" | "center"; +// A read-only snapshot of the user's real progress, derived entirely from +// existing endpoints (/robots, /datasets, /jobs, /hf-auth-status). +export interface ProgressSnapshot { + hasSelectedRobot: boolean; + robotIsClean: boolean; + hasLocalDataset: boolean; + hasTrainedModel: boolean; + isAuthenticated: boolean; +} + export interface TourStep { /** Stable id, also used as the log/debug label. */ id: string; @@ -16,6 +30,12 @@ export interface TourStep { placement?: Placement; /** Optional steps can be skipped without doing the action (e.g. no hardware). */ optional?: boolean; + /** When false, the step's action isn't possible yet (informational only). */ + gate?: (s: ProgressSnapshot) => boolean; + /** When true, the user has done this step's action; enables auto-advance. */ + isComplete?: (s: ProgressSnapshot) => boolean; + /** When present and false, the step is skipped entirely for this user. */ + show?: (s: ProgressSnapshot) => boolean; } export const ONBOARDING_STEPS: TourStep[] = [ @@ -33,6 +53,7 @@ export const ONBOARDING_STEPS: TourStep[] = [ placement: "bottom", title: "Pick or name your arm", body: "Start here. Choose an existing robot, or type a new name to create one. Everything else in the tour hangs off the arm you select.", + isComplete: (s) => s.hasSelectedRobot, }, { id: "calibrate", @@ -42,6 +63,7 @@ export const ONBOARDING_STEPS: TourStep[] = [ title: "Calibrate the arms", body: "Calibration teaches the app each joint's range of motion so movements map correctly. Run it once for the leader arm, then the follower. Press Start and follow the on-screen steps.", optional: true, + isComplete: (s) => s.robotIsClean, }, { id: "cameras", @@ -60,6 +82,7 @@ export const ONBOARDING_STEPS: TourStep[] = [ title: "Try teleoperation (optional)", body: "Drive the follower arm by moving the leader. It's a good way to confirm calibration feels right before you record anything. This button unlocks once the arm is calibrated.", optional: true, + gate: (s) => s.robotIsClean, }, { id: "record", @@ -68,6 +91,8 @@ export const ONBOARDING_STEPS: TourStep[] = [ placement: "bottom", title: "Record a dataset", body: "A dataset is a set of episodes: one attempt at your task each. Episode time is how long each attempt runs; reset time is the pause to reposition objects between attempts. Aim for 10 to 50 clean episodes to start.", + gate: (s) => s.robotIsClean, + isComplete: (s) => s.hasLocalDataset, }, { id: "train", @@ -76,6 +101,8 @@ export const ONBOARDING_STEPS: TourStep[] = [ placement: "bottom", title: "Train a policy", body: "Turn your recorded episodes into a policy the robot can run. ACT is fast and a great first choice; SmolVLA is a larger vision-language model. You can train on your own GPU, or rent one in the cloud with a Hugging Face login.", + gate: (s) => s.hasLocalDataset, + isComplete: (s) => s.hasTrainedModel, }, { id: "watch-training", @@ -102,6 +129,7 @@ export const ONBOARDING_STEPS: TourStep[] = [ title: "Share on the Hub (optional)", body: "Log in to Hugging Face to push your datasets and trained models to the Hub, so you can back them up, share them, or train in the cloud.", optional: true, + show: (s) => !s.isAuthenticated, }, { id: "done", From 3942627348284391018cc71698e65e2e111e148c Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 02:38:48 +0200 Subject: [PATCH 04/10] feat(onboarding): offer the tour to first-time visitors Until now the tour only started if someone found the launcher, so most newcomers would never see it. Show a short welcome dialog on the first visit, styled like the existing install modal, offering the tour or a quiet dismissal. The choice is remembered so it never nags on return. On the hosted Space the offer is suppressed, since the non-dismissible install prompt already owns that first-run moment and the two must not stack; the corner launcher remains available everywhere so the tour can always be replayed. --- .../components/onboarding/WelcomeDialog.tsx | 77 +++++++++++++++++++ frontend/src/contexts/OnboardingContext.tsx | 25 ++++++ 2 files changed, 102 insertions(+) create mode 100644 frontend/src/components/onboarding/WelcomeDialog.tsx diff --git a/frontend/src/components/onboarding/WelcomeDialog.tsx b/frontend/src/components/onboarding/WelcomeDialog.tsx new file mode 100644 index 00000000..17c839f4 --- /dev/null +++ b/frontend/src/components/onboarding/WelcomeDialog.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Compass, Route } from "lucide-react"; + +interface WelcomeDialogProps { + open: boolean; + onStart: () => void; + onDismiss: () => void; +} + +// The stages a newcomer will walk through, shown as a quick preview so the +// tour's value is clear before they commit two minutes to it. +const STAGES = ["Calibrate", "Record", "Train", "Run"]; + +const WelcomeDialog: React.FC = ({ + open, + onStart, + onDismiss, +}) => { + return ( + (next ? undefined : onDismiss())}> + + + + + Welcome to LeLab + + + New to teaching a robot arm? A two-minute guided tour walks you + through the whole loop, no jargon required. + + + +
+
+ {STAGES.map((stage, i) => ( + + + {stage} + + {i < STAGES.length - 1 && ( + + )} + + ))} +
+
+ +
+ + +
+
+
+ ); +}; + +export default WelcomeDialog; diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx index 413aed6d..88baa7a3 100644 --- a/frontend/src/contexts/OnboardingContext.tsx +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -10,8 +10,10 @@ import React, { import { useLocation, useNavigate } from "react-router-dom"; import { ONBOARDING_STEPS, TourStep } from "@/lib/onboardingSteps"; import { useOnboardingProgress } from "@/hooks/useOnboardingProgress"; +import { isHostedSpace } from "@/lib/isHostedSpace"; import SpotlightOverlay from "@/components/onboarding/SpotlightOverlay"; import TourLauncher from "@/components/onboarding/TourLauncher"; +import WelcomeDialog from "@/components/onboarding/WelcomeDialog"; // Persisted across sessions, mirroring the useUpdateCheck pattern. The `-v1` // suffix lets a future revamp re-offer the tour to everyone by bumping it. @@ -71,6 +73,7 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ const [isActive, setIsActive] = useState(false); const [currentStepId, setCurrentStepId] = useState(null); const [hasSeen, setHasSeen] = useState(() => readStatus() !== null); + const [showWelcome, setShowWelcome] = useState(false); const progress = useOnboardingProgress(isActive); @@ -103,10 +106,27 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ }, []); const start = useCallback(() => { + setShowWelcome(false); setCurrentStepId(ONBOARDING_STEPS[0]?.id ?? null); setIsActive(true); }, []); + const dismissWelcome = useCallback(() => { + setShowWelcome(false); + writeStatus("dismissed"); + setHasSeen(true); + }, []); + + // Offer the tour once, on the first visit only. Suppressed on the hosted HF + // Space, where UsageInstructionsModal owns the first-run moment (a + // non-dismissible install prompt) — the two must never stack. The corner + // launcher stays available everywhere regardless. + useEffect(() => { + if (readStatus() === null && !isHostedSpace()) { + setShowWelcome(true); + } + }, []); + const next = useCallback(() => { const idx = visibleSteps.findIndex((s) => s.id === currentStepId); if (idx < 0) return; @@ -211,6 +231,11 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ return ( {children} + From ad224a5788045a648b40a7f46e6cbc6b314a1023 Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 02:39:36 +0200 Subject: [PATCH 05/10] feat(onboarding): polish tour motion and accessibility Round off the interaction so it feels considered rather than mechanical. Each step's card fades and scales in, honouring reduced-motion preferences, and remounts per step so the entrance replays. Focus moves to the card when a step opens and the card announces politely, so keyboard and screen-reader users follow along; Escape already exits from anywhere. --- .../src/components/onboarding/SpotlightOverlay.tsx | 2 +- frontend/src/components/onboarding/TourCard.tsx | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/onboarding/SpotlightOverlay.tsx b/frontend/src/components/onboarding/SpotlightOverlay.tsx index 7e9b86e3..e4ccd1c8 100644 --- a/frontend/src/components/onboarding/SpotlightOverlay.tsx +++ b/frontend/src/components/onboarding/SpotlightOverlay.tsx @@ -104,7 +104,7 @@ const SpotlightOverlay: React.FC = () => { ) : (
)} - +
, document.body ); diff --git a/frontend/src/components/onboarding/TourCard.tsx b/frontend/src/components/onboarding/TourCard.tsx index 0e38bc32..791ef61b 100644 --- a/frontend/src/components/onboarding/TourCard.tsx +++ b/frontend/src/components/onboarding/TourCard.tsx @@ -1,4 +1,4 @@ -import React, { useLayoutEffect, useRef, useState } from "react"; +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Check, Lock, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -38,6 +38,13 @@ const TourCard: React.FC = ({ rect }) => { } }, [currentStep?.id, rect]); + // Move focus to the card when a step opens so keyboard and screen-reader + // users land on the guidance. The card remounts per step (keyed by id), so + // this fires once per step. + useEffect(() => { + cardRef.current?.focus(); + }, []); + if (!currentStep) return null; const placement = currentStep.placement ?? "bottom"; @@ -90,9 +97,12 @@ const TourCard: React.FC = ({ rect }) => { ref={cardRef} role="dialog" aria-label="Guided tour" + aria-live="polite" + tabIndex={-1} className={cn( "pointer-events-auto fixed z-[45] w-[340px] max-w-[calc(100vw-16px)]", - "rounded-lg border border-gray-700 bg-gray-900 text-gray-300 shadow-2xl" + "rounded-lg border border-gray-700 bg-gray-900 text-gray-300 shadow-2xl outline-none", + "animate-in fade-in-0 zoom-in-95 duration-200 motion-reduce:animate-none" )} style={pos} > From 501293a8b28ca203a2a01d36653d32684431e30e Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 21:51:29 +0200 Subject: [PATCH 06/10] perf(onboarding): keep the progress poll off the Hub While the tour was open the progress snapshot polled /jobs/hub every two seconds, and each call reaches the Hugging Face Hub (list_jobs plus list_models). Over a couple of minutes that is dozens of Hub requests, which runs against the care this project already takes to avoid Hub rate limits. Drop /jobs/hub entirely. Tracked cloud jobs already appear in the local /jobs list, so a trained model is still detected there, and the only thing lost is untracked past Hub models, which the tour does not need. Authentication now comes from the existing HfAuth context rather than the hub response. The poll also eases to three seconds, pauses while the tab is hidden, and guards against overlapping requests, so nothing piles up. --- frontend/src/hooks/useOnboardingProgress.ts | 48 ++++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/frontend/src/hooks/useOnboardingProgress.ts b/frontend/src/hooks/useOnboardingProgress.ts index 8e65854e..99283938 100644 --- a/frontend/src/hooks/useOnboardingProgress.ts +++ b/frontend/src/hooks/useOnboardingProgress.ts @@ -2,13 +2,16 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useApi } from "@/contexts/ApiContext"; import { useHfAuth } from "@/contexts/HfAuthContext"; import { listDatasets } from "@/lib/replayApi"; -import { listHubJobs, listJobs } from "@/lib/jobsApi"; +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"; -const POLL_MS = 2000; +// 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, @@ -24,12 +27,16 @@ const EMPTY: ProgressSnapshot = { * 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: no /jobs/hub, 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(EMPTY); - const authStatus = auth.status; + const isAuthenticated = auth.status === "authenticated"; const refresh = useCallback(async () => { let selectedName: string | null = null; @@ -39,17 +46,12 @@ export function useOnboardingProgress(active: boolean): ProgressSnapshot { // Storage unavailable — treat as no selection. } - const [robotsBody, datasets, jobs, hub] = await Promise.all([ + const [robotsBody, datasets, jobs] = await Promise.all([ fetchWithHeaders(`${baseUrl}/robots`) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), listDatasets(baseUrl, fetchWithHeaders).catch(() => []), listJobs(baseUrl, fetchWithHeaders, 20).catch(() => []), - listHubJobs(baseUrl, fetchWithHeaders).catch(() => ({ - authenticated: false, - jobs: [], - models: [], - })), ]); const records: RobotRecord[] = robotsBody?.robots ?? []; @@ -63,12 +65,12 @@ export function useOnboardingProgress(active: boolean): ProgressSnapshot { hasLocalDataset: datasets.some( (d) => d.source === "local" || d.source === "both" ), - hasTrainedModel: - jobs.some((j) => j.checkpoint_count > 0 || j.state === "done") || - hub.models.length > 0, - isAuthenticated: authStatus === "authenticated" || hub.authenticated, + hasTrainedModel: jobs.some( + (j) => j.checkpoint_count > 0 || j.state === "done" + ), + isAuthenticated, }); - }, [baseUrl, fetchWithHeaders, authStatus]); + }, [baseUrl, fetchWithHeaders, isAuthenticated]); // Keep the latest refresh in a ref so the polling interval never tears down. const refreshRef = useRef(refresh); @@ -80,14 +82,28 @@ export function useOnboardingProgress(active: boolean): ProgressSnapshot { return; } let cancelled = false; - const tick = () => { - if (!cancelled) refreshRef.current(); + 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]); From d3ddc26890e995a8540d57edde8d24c9a571b78f Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 22:16:16 +0200 Subject: [PATCH 07/10] perf(onboarding): keep the dataset check off the Hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit stopped the tour polling /jobs/hub, but its progress snapshot still called /datasets every few seconds, and that endpoint merges in the user's Hub datasets (list_user_datasets -> api.list_datasets) for authenticated users. So the tour still issued a Hub request on every poll, undercutting the earlier fix. Give /datasets a `scope=local` mode that returns only the local-cache listing — a pure filesystem scan, no Hub call — and point the tour poll at it. Tracked cloud datasets aren't needed to answer "has the user recorded anything on this machine yet." The default scope is unchanged, so existing callers keep the merged listing. Adds coverage for both the new helper and the endpoint branch, asserting the local path never reaches the Hub. --- frontend/src/hooks/useOnboardingProgress.ts | 11 ++++---- frontend/src/lib/replayApi.ts | 16 ++++++++++++ lelab/datasets.py | 10 +++++++ lelab/server.py | 8 ++++-- tests/test_datasets.py | 16 ++++++++++++ tests/test_server.py | 29 +++++++++++++++++++++ 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/useOnboardingProgress.ts b/frontend/src/hooks/useOnboardingProgress.ts index 99283938..ed996262 100644 --- a/frontend/src/hooks/useOnboardingProgress.ts +++ b/frontend/src/hooks/useOnboardingProgress.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useApi } from "@/contexts/ApiContext"; import { useHfAuth } from "@/contexts/HfAuthContext"; -import { listDatasets } from "@/lib/replayApi"; +import { listLocalDatasets } from "@/lib/replayApi"; import { listJobs } from "@/lib/jobsApi"; import type { RobotRecord } from "@/hooks/useRobots"; import type { ProgressSnapshot } from "@/lib/onboardingSteps"; @@ -28,9 +28,10 @@ const EMPTY: ProgressSnapshot = { * (which refetches per route and owns selection) — a second instance would * double traffic and race the selection state. * - * Only cheap local endpoints are polled: no /jobs/hub, 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. + * 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(); @@ -50,7 +51,7 @@ export function useOnboardingProgress(active: boolean): ProgressSnapshot { fetchWithHeaders(`${baseUrl}/robots`) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - listDatasets(baseUrl, fetchWithHeaders).catch(() => []), + listLocalDatasets(baseUrl, fetchWithHeaders).catch(() => []), listJobs(baseUrl, fetchWithHeaders, 20).catch(() => []), ]); diff --git a/frontend/src/lib/replayApi.ts b/frontend/src/lib/replayApi.ts index 44b29c91..dadcf8ae 100644 --- a/frontend/src/lib/replayApi.ts +++ b/frontend/src/lib/replayApi.ts @@ -19,3 +19,19 @@ export async function listDatasets( action: "List datasets", }); } + +/** + * Local-cache datasets only — a pure filesystem scan on the backend, with no + * Hugging Face Hub call. For lightweight callers (e.g. the onboarding poll) + * that only need to know what exists on disk. + */ +export async function listLocalDatasets( + baseUrl: string, + fetcher: Fetcher, + signal?: AbortSignal, +): Promise { + return apiRequest(baseUrl, fetcher, "/datasets?scope=local", { + signal, + action: "List local datasets", + }); +} diff --git a/lelab/datasets.py b/lelab/datasets.py index b34c85c3..a93fd20e 100644 --- a/lelab/datasets.py +++ b/lelab/datasets.py @@ -133,6 +133,16 @@ def list_user_datasets() -> list[dict[str, Any]]: return out +def list_local_datasets_with_source() -> list[dict[str, Any]]: + """Local-cache datasets tagged with source="local" — a pure filesystem scan. + + Same entry shape as `list_all_datasets`, but never contacts the Hub. For + lightweight callers that only need to know what exists on disk (e.g. a + poller) and must not add Hub API load. + """ + return [{**d, "source": "local"} for d in list_local_datasets()] + + def list_all_datasets() -> list[dict[str, Any]]: """Merged listing: Hub datasets + local cache, with `source` field. diff --git a/lelab/server.py b/lelab/server.py index 519e0c72..5b5fb4d3 100644 --- a/lelab/server.py +++ b/lelab/server.py @@ -379,11 +379,15 @@ def hf_auth_login(body: HfLoginBody): @app.get("/datasets") -def datasets_list(): +def datasets_list(scope: str = "all"): """List datasets available to the user — Hub-owned + local cache. - Each entry carries a `source` field: "local", "hub", or "both". + Each entry carries a `source` field: "local", "hub", or "both". Pass + `scope=local` for a local-only listing (a pure filesystem scan, no Hub + call) — for lightweight pollers that only need what exists on disk. """ + if scope == "local": + return dataset_browser.list_local_datasets_with_source() return dataset_browser.list_all_datasets() diff --git a/tests/test_datasets.py b/tests/test_datasets.py index 2b7cf7f0..c4e20b18 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -86,6 +86,22 @@ def test_list_user_datasets_returns_empty_when_not_logged_in( assert list_user_datasets() == [] +def test_list_local_datasets_with_source_tags_local_without_hub( + tmp_lerobot_home: Path, +) -> None: + from lelab.datasets import list_local_datasets_with_source + + _make_dataset(tmp_lerobot_home, "pusht") + + # The local-only listing must never consult the Hub. + with patch("lelab.datasets.list_user_datasets") as hub: + result = list_local_datasets_with_source() + hub.assert_not_called() + + by_id = {d["repo_id"]: d for d in result} + assert by_id["pusht"]["source"] == "local" + + def test_list_all_datasets_merges_hub_and_local( tmp_lerobot_home: Path, ) -> None: diff --git a/tests/test_server.py b/tests/test_server.py index 8b10033f..cd2a8d45 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -248,3 +248,32 @@ def boom(source, name=None): resp = client.post("/jobs/import", json={"source": "/tmp/x"}) assert resp.status_code == 400 assert "No usable model" in resp.json()["detail"] + + +def test_datasets_local_scope_skips_hub_merge(client, monkeypatch) -> None: + """`?scope=local` returns the local-only listing and never runs the + Hub-merging path (which would issue a Hugging Face API call).""" + from lelab import datasets as datasets_mod + + local = [{"repo_id": "pusht", "last_modified": None, "private": False, "source": "local"}] + + def boom() -> list: + raise AssertionError("list_all_datasets must not run for scope=local") + + monkeypatch.setattr(datasets_mod, "list_local_datasets_with_source", lambda: local) + monkeypatch.setattr(datasets_mod, "list_all_datasets", boom) + + resp = client.get("/datasets?scope=local") + assert resp.status_code == 200 + assert resp.json() == local + + +def test_datasets_default_scope_uses_merged_listing(client, monkeypatch) -> None: + from lelab import datasets as datasets_mod + + merged = [{"repo_id": "x", "last_modified": None, "private": False, "source": "both"}] + monkeypatch.setattr(datasets_mod, "list_all_datasets", lambda: merged) + + resp = client.get("/datasets") + assert resp.status_code == 200 + assert resp.json() == merged From 5cc6866f087edb1f2430f74c0a244e2564c351aa Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 22:16:34 +0200 Subject: [PATCH 08/10] fix(onboarding): scroll a step's target into view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step whose target sits below the fold (the cameras step on the calibration page, the jobs step on a busy landing page) was highlighted where it actually is — off-screen — leaving the tour card pointing at nothing the user could see. When a target is found, scroll it into view with block "nearest", which moves the minimum needed and does nothing when the element is already visible. The existing scroll listener keeps the cutout and card glued to it as the smooth scroll settles. --- frontend/src/components/onboarding/SpotlightOverlay.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/onboarding/SpotlightOverlay.tsx b/frontend/src/components/onboarding/SpotlightOverlay.tsx index e4ccd1c8..739f7e4b 100644 --- a/frontend/src/components/onboarding/SpotlightOverlay.tsx +++ b/frontend/src/components/onboarding/SpotlightOverlay.tsx @@ -42,6 +42,11 @@ const SpotlightOverlay: React.FC = () => { const attachTrackers = (el: Element) => { tracked = el; + // Bring the target on-screen if it's below the fold (e.g. the cameras or + // jobs step). "nearest" scrolls the minimum needed and does nothing when + // it's already visible; the scroll listener below keeps the cutout and + // card glued to it as the smooth scroll settles. + el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); measure(); setReady(true); window.addEventListener("scroll", measure, true); From edbab16dc4f85d1e9cb17e3fc5ce1b11cde5821b Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 22:16:37 +0200 Subject: [PATCH 09/10] fix(onboarding): correct the gated-step hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint on a locked step told the user they could move on with Skip, but Skip only renders for optional steps. The gated steps that most need the hint (record, train) are required, so they show no Skip button — only Next, which always advances. Point the hint at Next, which is present on every step, so the guidance matches the buttons actually on screen. --- frontend/src/components/onboarding/TourCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/onboarding/TourCard.tsx b/frontend/src/components/onboarding/TourCard.tsx index 791ef61b..f2df2309 100644 --- a/frontend/src/components/onboarding/TourCard.tsx +++ b/frontend/src/components/onboarding/TourCard.tsx @@ -137,8 +137,8 @@ const TourCard: React.FC = ({ rect }) => { {currentGated && !currentComplete && (

- Finish the earlier step to unlock this. You can keep reading and move - on with Skip. + Finish the earlier step to unlock this, or continue with Next + whenever you like.

)} From 6205209653311cc30c164a21272ab8f798abb15e Mon Sep 17 00:00:00 2001 From: Chandran Date: Sun, 26 Jul 2026 22:32:10 +0200 Subject: [PATCH 10/10] fix(onboarding): clear a stale spotlight on navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tour deliberately does not re-navigate when the user clicks away mid-step, expecting the spotlight to fall back to a centered card. But the overlay only re-tracked on step or target changes, not on route changes, so after a manual navigation it kept rendering the last-measured rect — a highlight pinned over wherever the element used to be on the new page. Re-run the tracking effect on route changes so it re-queries the target (and falls back to centered when it isn't on the new page), and drop the rect if the tracked element leaves the DOM under us, rather than pinning it to a stale position. --- .../components/onboarding/SpotlightOverlay.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/onboarding/SpotlightOverlay.tsx b/frontend/src/components/onboarding/SpotlightOverlay.tsx index 739f7e4b..e5c3d9d3 100644 --- a/frontend/src/components/onboarding/SpotlightOverlay.tsx +++ b/frontend/src/components/onboarding/SpotlightOverlay.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useLayoutEffect, useState } from "react"; import { createPortal } from "react-dom"; +import { useLocation } from "react-router-dom"; import { useOnboarding } from "@/contexts/OnboardingContext"; import TourCard from "@/components/onboarding/TourCard"; @@ -11,6 +12,7 @@ const MOUNT_TIMEOUT_MS = 3000; const SpotlightOverlay: React.FC = () => { const { isActive, currentStep, stop } = useOnboarding(); + const location = useLocation(); const [rect, setRect] = useState(null); // Becomes true once we've either located the target or given up (fallback). const [ready, setReady] = useState(false); @@ -19,7 +21,8 @@ const SpotlightOverlay: React.FC = () => { const target = currentStep?.target ?? null; // Locate the step's target, tracking it across scroll/resize/layout shifts. - // Re-runs whenever the step changes. + // Re-runs whenever the step changes, and on route changes so a manual + // navigation away mid-step re-queries (rather than pinning a stale rect). useLayoutEffect(() => { setReady(false); setRect(null); @@ -37,7 +40,15 @@ const SpotlightOverlay: React.FC = () => { const startedAt = performance.now(); const measure = () => { - if (tracked) setRect(tracked.getBoundingClientRect()); + if (!tracked) return; + // The element can leave the DOM under us (e.g. the user navigated away + // before this effect re-runs). Drop the spotlight instead of pinning it + // to a stale position. + if (!tracked.isConnected) { + setRect(null); + return; + } + setRect(tracked.getBoundingClientRect()); }; const attachTrackers = (el: Element) => { @@ -77,7 +88,7 @@ const SpotlightOverlay: React.FC = () => { window.removeEventListener("resize", measure); observer?.disconnect(); }; - }, [isActive, stepId, target]); + }, [isActive, stepId, target, location.pathname]); // Esc leaves the tour from anywhere. useEffect(() => {