diff --git a/src/components/Onboarding/OnboardingNavPill.test.tsx b/src/components/Onboarding/OnboardingNavPill.test.tsx
new file mode 100644
index 000000000..48fbb8965
--- /dev/null
+++ b/src/components/Onboarding/OnboardingNavPill.test.tsx
@@ -0,0 +1,58 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { OnboardingNavPill } from "./OnboardingNavPill";
+
+vi.mock("@tanstack/react-router", () => ({
+ Link: ({ children }: { children: ReactNode }) => {children},
+}));
+
+let onboarding = {
+ steps: [],
+ completedCount: 1,
+ total: 4,
+ isComplete: false,
+ dismissed: false,
+ isResolved: true,
+ markDocsRead: vi.fn(),
+};
+vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({
+ useOnboarding: () => onboarding,
+}));
+
+function resetState() {
+ onboarding = {
+ steps: [],
+ completedCount: 1,
+ total: 4,
+ isComplete: false,
+ dismissed: false,
+ isResolved: true,
+ markDocsRead: vi.fn(),
+ };
+}
+
+beforeEach(resetState);
+afterEach(cleanup);
+
+const pill = () => screen.queryByText(/Onboarding/);
+
+describe("OnboardingNavPill", () => {
+ it("shows progress while onboarding is in progress", () => {
+ render();
+ expect(pill()).toHaveTextContent("Onboarding · 1/4");
+ });
+
+ it("is hidden once onboarding is complete", () => {
+ onboarding.isComplete = true;
+ render();
+ expect(pill()).toBeNull();
+ });
+
+ it("is hidden once onboarding is dismissed", () => {
+ onboarding.dismissed = true;
+ render();
+ expect(pill()).toBeNull();
+ });
+});
diff --git a/src/components/Onboarding/OnboardingNavPill.tsx b/src/components/Onboarding/OnboardingNavPill.tsx
new file mode 100644
index 000000000..3e8d49f21
--- /dev/null
+++ b/src/components/Onboarding/OnboardingNavPill.tsx
@@ -0,0 +1,38 @@
+import { OnboardingChecklist } from "@/components/Onboarding/OnboardingChecklist";
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { BlockStack } from "@/components/ui/layout";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Heading } from "@/components/ui/typography";
+import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
+import { tracking } from "@/utils/tracking";
+
+export function OnboardingNavPill() {
+ const { completedCount, total, isComplete, dismissed, isResolved } =
+ useOnboarding();
+
+ if (!isResolved || isComplete || dismissed) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ Get started with Tangle
+
+
+
+
+ );
+}
diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx
index cea3f0515..2cc453527 100644
--- a/src/components/layout/AppMenu.tsx
+++ b/src/components/layout/AppMenu.tsx
@@ -6,6 +6,7 @@ import {
import { useState } from "react";
import logo from "/Tangle_white.png";
+import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill";
import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication";
import { CopyText } from "@/components/shared/CopyText/CopyText";
@@ -110,6 +111,8 @@ const DefaultAppMenu = () => {
+
+
{/* Settings & status */}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 4a775bc94..02b165f76 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -25,6 +25,7 @@ const buttonVariants = cva(
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"link-info": "text-info underline decoration-dotted",
+ nav: "bg-stone-700 text-white text-xs font-semibold hover:bg-stone-600 hover:text-white",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
@@ -35,10 +36,15 @@ const buttonVariants = cva(
icon: "size-9",
min: "h-fit w-fit p-1 rounded-sm",
},
+ shape: {
+ default: "",
+ pill: "rounded-full",
+ },
},
defaultVariants: {
variant: "default",
size: "default",
+ shape: "default",
},
},
);
@@ -52,6 +58,7 @@ function Button({
className,
variant,
size,
+ shape,
asChild = false,
...props
}: ButtonProps) {
@@ -60,7 +67,7 @@ function Button({
return (
);
diff --git a/src/providers/OnboardingProvider/OnboardingProvider.tsx b/src/providers/OnboardingProvider/OnboardingProvider.tsx
index 469be6607..15f8c99dd 100644
--- a/src/providers/OnboardingProvider/OnboardingProvider.tsx
+++ b/src/providers/OnboardingProvider/OnboardingProvider.tsx
@@ -6,6 +6,7 @@ import {
createRequiredContext,
useRequiredContext,
} from "@/hooks/useRequiredContext";
+import useToastNotification from "@/hooks/useToastNotification";
import { useAnalytics } from "@/providers/AnalyticsProvider";
import { useBackend } from "@/providers/BackendProvider";
import { useTourCompletions } from "@/providers/TourProvider/tourCompletion";
@@ -28,6 +29,7 @@ import {
ONBOARDING_STEPS,
type OnboardingStepMeta,
} from "./steps";
+import { useStepCompletionToasts } from "./useStepCompletionToasts";
const PIPELINE_RUNS_QUERY_URL = "/api/pipeline_runs/";
const STALE_MS = 1000 * 60 * 5;
@@ -47,6 +49,8 @@ interface OnboardingContextValue {
total: number;
isComplete: boolean;
dismissed: boolean;
+ isReady: boolean;
+ isResolved: boolean;
markDocsRead: () => void;
dismiss: () => void;
reopen: () => void;
@@ -55,11 +59,14 @@ interface OnboardingContextValue {
const OnboardingContext =
createRequiredContext("OnboardingProvider");
-function useHasMyRun(): boolean {
+function useHasMyRun(): {
+ hasRun: boolean;
+ isLoading: boolean;
+} {
const { available, backendUrl } = useBackend();
const filterQuery = filtersToFilterQuery(parseFilterParam("created_by:me"));
- const { data } = useQuery({
+ const { data, isLoading } = useQuery({
queryKey: [...ONBOARDING_MY_RUN_COUNT_KEY, backendUrl],
enabled: available && Boolean(backendUrl),
staleTime: STALE_MS,
@@ -71,19 +78,23 @@ function useHasMyRun(): boolean {
return countPipelineRuns(payload);
},
});
- return (data ?? 0) > 0;
+ return { hasRun: (data ?? 0) > 0, isLoading };
}
export function OnboardingProvider({ children }: { children: ReactNode }) {
const { track } = useAnalytics();
- const { data: progress } = useOnboardingProgress();
+ const notify = useToastNotification();
+ const { ready: backendReady, configured } = useBackend();
+ const { data: progress, isLoading: progressLoading } =
+ useOnboardingProgress();
const persist = usePersistOnboardingProgress();
- const { data: tourCompletions } = useTourCompletions();
+ const { data: tourCompletions, isLoading: toursLoading } =
+ useTourCompletions();
const hasCompletedTour = Boolean(
tourCompletions && Object.keys(tourCompletions).length > 0,
);
- const hasMyRun = useHasMyRun();
+ const { hasRun: hasMyRun, isLoading: runsLoading } = useHasMyRun();
const stored = progress?.steps;
const desiredSteps: OnboardingSteps = {
@@ -94,6 +105,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
};
const isComplete = ONBOARDING_STEP_IDS.every((id) => desiredSteps[id]);
+ const isReady = !progressLoading && !toursLoading && !runsLoading;
+ const isResolved = (backendReady || !configured) && isReady;
const [pipelineWriteCount, setPipelineWriteCount] = useState(0);
@@ -120,6 +133,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
track("onboarding.step.completed", { step_id: "create_pipeline" });
}, [pipelineWriteCount, progress, persist, track]);
+ useStepCompletionToasts({ isResolved, desiredSteps, isComplete });
+
const markDocsRead = () => {
if (!progress || progress.steps.read_docs) return;
persist({ ...progress, steps: { ...progress.steps, read_docs: true } });
@@ -132,6 +147,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
if (!progress || progress.dismissed) return;
persist({ ...progress, dismissed: true });
track("onboarding.dismissed");
+ notify("You can resume onboarding from the Learning Hub", "info");
};
const reopen = () => {
@@ -151,6 +167,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
total: steps.length,
isComplete,
dismissed: progress?.dismissed ?? false,
+ isReady,
+ isResolved,
markDocsRead,
dismiss,
reopen,
diff --git a/src/providers/OnboardingProvider/onboardingCompletion.test.ts b/src/providers/OnboardingProvider/onboardingCompletion.test.ts
new file mode 100644
index 000000000..33b04ec3e
--- /dev/null
+++ b/src/providers/OnboardingProvider/onboardingCompletion.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ completedStepIds,
+ newlyCompletedIds,
+ stepLabel,
+} from "./onboardingCompletion";
+import { ONBOARDING_STEPS } from "./steps";
+
+describe("completedStepIds", () => {
+ it("returns completed ids in canonical order", () => {
+ expect(
+ completedStepIds({
+ read_docs: true,
+ complete_tour: false,
+ create_pipeline: true,
+ execute_run: false,
+ }),
+ ).toEqual(["read_docs", "create_pipeline"]);
+ });
+
+ it("returns an empty array when nothing is complete", () => {
+ expect(
+ completedStepIds({
+ read_docs: false,
+ complete_tour: false,
+ create_pipeline: false,
+ execute_run: false,
+ }),
+ ).toEqual([]);
+ });
+});
+
+describe("newlyCompletedIds", () => {
+ it("returns ids present in current but not previous", () => {
+ expect(
+ newlyCompletedIds(
+ new Set(["read_docs"]),
+ new Set(["read_docs", "execute_run"]),
+ ),
+ ).toEqual(["execute_run"]);
+ });
+
+ it("returns empty when nothing newly completed", () => {
+ expect(
+ newlyCompletedIds(new Set(["read_docs"]), new Set(["read_docs"])),
+ ).toEqual([]);
+ });
+});
+
+describe("stepLabel", () => {
+ it("resolves the label for a known step", () => {
+ expect(stepLabel("read_docs")).toBe(
+ ONBOARDING_STEPS.find((step) => step.id === "read_docs")?.label,
+ );
+ });
+});
diff --git a/src/providers/OnboardingProvider/onboardingCompletion.ts b/src/providers/OnboardingProvider/onboardingCompletion.ts
new file mode 100644
index 000000000..6fca90731
--- /dev/null
+++ b/src/providers/OnboardingProvider/onboardingCompletion.ts
@@ -0,0 +1,21 @@
+import type { OnboardingSteps } from "./onboardingProgress";
+import {
+ ONBOARDING_STEP_IDS,
+ ONBOARDING_STEPS,
+ type OnboardingStepId,
+} from "./steps";
+
+export function completedStepIds(steps: OnboardingSteps): OnboardingStepId[] {
+ return ONBOARDING_STEP_IDS.filter((id) => steps[id]);
+}
+
+export function newlyCompletedIds(
+ previous: ReadonlySet,
+ current: ReadonlySet,
+): OnboardingStepId[] {
+ return [...current].filter((id) => !previous.has(id));
+}
+
+export function stepLabel(id: OnboardingStepId): string | undefined {
+ return ONBOARDING_STEPS.find((step) => step.id === id)?.label;
+}
diff --git a/src/providers/OnboardingProvider/useStepCompletionToasts.ts b/src/providers/OnboardingProvider/useStepCompletionToasts.ts
new file mode 100644
index 000000000..9130715cc
--- /dev/null
+++ b/src/providers/OnboardingProvider/useStepCompletionToasts.ts
@@ -0,0 +1,51 @@
+import { useEffect, useRef } from "react";
+
+import useToastNotification from "@/hooks/useToastNotification";
+
+import {
+ completedStepIds,
+ newlyCompletedIds,
+ stepLabel,
+} from "./onboardingCompletion";
+import type { OnboardingSteps } from "./onboardingProgress";
+import type { OnboardingStepId } from "./steps";
+
+interface UseStepCompletionToastsArgs {
+ isResolved: boolean;
+ desiredSteps: OnboardingSteps;
+ isComplete: boolean;
+}
+
+/**
+ * Fires a toast the first time each step flips to complete (and a single
+ * "all set up" toast once everything is done). The first resolved render
+ * only seeds the baseline, so already-complete steps don't toast on mount.
+ */
+export function useStepCompletionToasts({
+ isResolved,
+ desiredSteps,
+ isComplete,
+}: UseStepCompletionToastsArgs) {
+ const notify = useToastNotification();
+ const previousRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (!isResolved) return;
+ const current = new Set(completedStepIds(desiredSteps));
+ const previous = previousRef.current;
+ previousRef.current = current;
+ if (previous === null) return;
+
+ const newly = newlyCompletedIds(previous, current);
+ if (newly.length === 0) return;
+
+ if (isComplete) {
+ notify("You're all set up - onboarding complete!", "success");
+ return;
+ }
+ for (const id of newly) {
+ const label = stepLabel(id);
+ if (label) notify(`Completed: ${label}`, "success");
+ }
+ }, [isResolved, desiredSteps, isComplete, notify]);
+}
diff --git a/src/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx
index 3fe3fc8ee..0fe78a982 100644
--- a/src/routes/v2/shared/components/AppMenuActions.tsx
+++ b/src/routes/v2/shared/components/AppMenuActions.tsx
@@ -1,5 +1,6 @@
import { Link as RouterLink } from "@tanstack/react-router";
+import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill";
import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication";
import TooltipButton from "@/components/shared/Buttons/TooltipButton";
@@ -23,6 +24,7 @@ export function AppMenuActions() {
className="shrink-0"
data-testid="app-menu-actions"
>
+ {!tourMode && }
{tourMode ? (