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..f49a555ad
--- /dev/null
+++ b/src/components/Onboarding/OnboardingNavPill.tsx
@@ -0,0 +1,42 @@
+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/providers/OnboardingProvider/OnboardingProvider.tsx b/src/providers/OnboardingProvider/OnboardingProvider.tsx
index 596ff9110..cf824b4f1 100644
--- a/src/providers/OnboardingProvider/OnboardingProvider.tsx
+++ b/src/providers/OnboardingProvider/OnboardingProvider.tsx
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
-import { type ReactNode, useEffect, useState } from "react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
import type { ListPipelineJobsResponse } from "@/api/types.gen";
import { useDocsVisitTracking } from "@/hooks/useDocsVisitTracking";
@@ -7,6 +7,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";
@@ -41,6 +42,8 @@ interface OnboardingContextValue {
total: number;
isComplete: boolean;
dismissed: boolean;
+ isReady: boolean;
+ isResolved: boolean;
markDocsRead: () => void;
dismiss: () => void;
reopen: () => void;
@@ -49,11 +52,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", "myRunCount", backendUrl],
enabled: available && Boolean(backendUrl),
staleTime: STALE_MS,
@@ -67,19 +73,23 @@ function useHasMyRun(): boolean {
return payload.pipeline_runs?.length ?? 0;
},
});
- 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 = {
@@ -90,6 +100,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);
@@ -116,6 +128,32 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
track("onboarding.step.completed", { step_id: "create_pipeline" });
}, [pipelineWriteCount, progress, persist, track]);
+ const completedRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (!isResolved) return;
+ const current = new Set(
+ ONBOARDING_STEP_IDS.filter((id) => desiredSteps[id]),
+ );
+ const previous = completedRef.current;
+ completedRef.current = current;
+ if (previous === null) return;
+
+ const newlyCompleted = ONBOARDING_STEP_IDS.filter(
+ (id) => current.has(id) && !previous.has(id),
+ );
+ if (newlyCompleted.length === 0) return;
+
+ if (isComplete) {
+ notify("You're all set up - onboarding complete!", "success");
+ return;
+ }
+ for (const id of newlyCompleted) {
+ const label = ONBOARDING_STEPS.find((step) => step.id === id)?.label;
+ if (label) notify(`Completed: ${label}`, "success");
+ }
+ }, [isResolved, desiredSteps, isComplete, notify]);
+
const markDocsRead = () => {
if (!progress || progress.steps.read_docs) return;
persist({ ...progress, steps: { ...progress.steps, read_docs: true } });
@@ -128,6 +166,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 = () => {
@@ -147,6 +186,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
total: steps.length,
isComplete,
dismissed: progress?.dismissed ?? false,
+ isReady,
+ isResolved,
markDocsRead,
dismiss,
reopen,
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 ? (