/** * @file OnboardingTour.tsx * @description The step-by-step spotlight tour after the first-run setup * (docs/plans/onboarding): it dims the page around ONE real control and says * what to do with it — Add workspace → the folder → Add → the prompt box → * the live session → done. The overlay never takes a click (the user works * the real UI); a step moves on when what it asked for has happened (the * dialog opened, the workspace exists, the session started), and steps back * when the user backed out (the dialog closed with nothing made). Targets are * existing `data-testid`s plus `data-tour` marks in AddLaneModal and * FolderBrowseModal. The * step reached is `localStorage["agenthub.tour"]`, so it is synced to the * account and survives a reload; `done` ends it. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; const KEY = "agenthub.tour"; const EVENT = "agenthub-tour"; const POLL_MS = 250; type StepId = "add" | "folder" | "create" | "prompt" | "session" | "finish"; const ORDER: StepId[] = ["add", "folder", "create", "prompt", "session", "finish"]; const $ = (sel: string) => document.querySelector(sel); /** An element that is on screen (a console collapsed with CSS has no box). */ function shown(sel: string): Element | null { const el = $(sel); if (!el) return null; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 ? el : null; } const modalOpen = () => !!$('[data-tour="add-lane"]'); const composer = () => shown('[data-testid="workspace-home-composer"]'); const sessionLive = () => !!new URLSearchParams(window.location.search).get("run") && !!shown('[data-testid="pane-grid"]'); /** Where each step points, and what moves it on (or back). */ const STEPS: Record< StepId, { target: () => Element | null; next?: () => StepId | null; info?: boolean } > = { add: { // The sidebar's +; folded to a rail, its tree icon (which unfolds it). target: () => shown('[data-testid="workspace-tree-add"]') || shown('[data-testid="sidebar-tree-rail"]'), next: () => (modalOpen() ? "folder" : null), }, folder: { // The folder browser while it is open: it sits over the field. target: () => $('[data-tour="folder-browse"]') || $('[data-tour="add-lane-folder"]'), next: () => (modalOpen() ? null : composer() ? "prompt" : "add"), info: true, }, create: { target: () => $('[data-tour="add-lane-create"]'), next: () => (modalOpen() ? null : composer() ? "prompt" : "add"), }, prompt: { target: composer, next: () => (sessionLive() ? "session" : null), }, session: { target: () => shown('[data-testid="pane-grid"]'), info: true }, finish: { target: () => null, info: true }, }; function read(): StepId | null { try { const v = localStorage.getItem(KEY); return (ORDER as string[]).includes(v ?? "") ? (v as StepId) : null; } catch { return null; } } function write(v: StepId | "done") { try { localStorage.setItem(KEY, v); } catch { /* storage blocked: the tour just ends with the page */ } window.dispatchEvent(new Event(EVENT)); } /** Begin the tour at its first step (the setup's last button). */ export const startTour = () => write("add"); /** Mounted once in the layout; renders nothing unless a tour is running. */ export function OnboardingTour() { const { t } = useTranslation("onboarding"); const navigate = useNavigate(); const location = useLocation(); const [step, setStep] = useState(read); const [rect, setRect] = useState(null); // The dialog the target sits in, if any: the card goes beside the dialog. const [box, setBox] = useState(null); useEffect(() => { const sync = () => setStep(read()); window.addEventListener(EVENT, sync); window.addEventListener("storage", sync); return () => { window.removeEventListener(EVENT, sync); window.removeEventListener("storage", sync); }; }, []); useEffect(() => { if (!step) return; const tick = () => { const def = STEPS[step]; const moved = def.next?.(); if (moved && moved !== step) return write(moved); const el = def.target(); let r = el ? el.getBoundingClientRect() : null; // The folder field's suggestions hang below it: light them too. const list = el?.querySelector("[data-cwd-suggestions]")?.getBoundingClientRect(); if (r && list) { const top = Math.min(r.top, list.top); const left = Math.min(r.left, list.left); r = new DOMRect( left, top, Math.max(r.right, list.right) - left, Math.max(r.bottom, list.bottom) - top ); } const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null; setBox((prev) => (prev && d && prev.right === d.right ? prev : d)); // Only when it moved: a new DOMRect every tick would re-render forever. setRect((prev) => prev && r && prev.x === r.x && prev.y === r.y && prev.width === r.width && prev.height === r.height ? prev : r ); }; tick(); const id = window.setInterval(tick, POLL_MS); return () => window.clearInterval(id); }, [step, location]); if (!step) return null; const n = ORDER.indexOf(step) + 1; const end = () => write("done"); const advance = () => { if (step === "folder") return write("create"); if (step === "session") return write("finish"); end(); }; const lost = !rect && step !== "finish"; // Beside a narrow target (a field in a dialog: below it, the card would // cover the rest of the dialog), else under it when there is room, else // above; centred when there is no target (the finish, or the page moved). const W = 320; let style: React.CSSProperties = { left: "50%", top: "50%", transform: "translate(-50%, -50%)" }; if (rect) { const right = (box ?? rect).right; const top = Math.min(Math.max(12, rect.top), window.innerHeight - 220); const left = Math.min(Math.max(12, rect.left), window.innerWidth - W - 12); if (rect.width < window.innerWidth / 2 && right + W + 24 < window.innerWidth) { style = { left: right + 16, top }; } else if (rect.bottom + 180 < window.innerHeight) { style = { left, top: rect.bottom + 12 }; } else { style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" }; } } return createPortal(
{rect ? (
) : ( step === "finish" &&
)}

{t("tour.progress", { n, total: ORDER.length })}

{t(`tour.${step}.title`)}

{t(`tour.${step}.body`)}

{step !== "finish" && ( )} {lost && !location.pathname.startsWith("/workspace") && ( )} {STEPS[step].info && ( )}
, document.body ); }