From 747a387cdcd305b2353bf88e9ca109805f66ca02 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 17 Jun 2026 13:23:49 -0700 Subject: [PATCH 1/2] feat: Onboarding Checklist --- react-compiler.config.js | 2 + src/components/Learn/LearnSearchBar.tsx | 3 + src/components/Learn/OnboardingHero.tsx | 169 +++++++---------- .../Onboarding/OnboardingChecklist.tsx | 104 +++++++++++ src/components/layout/RootLayout.tsx | 27 +-- src/hooks/useDocsVisitTracking.test.tsx | 58 ++++++ src/hooks/useDocsVisitTracking.ts | 22 +++ .../OnboardingProvider.test.tsx | 175 ++++++++++++++++++ .../OnboardingProvider/OnboardingProvider.tsx | 160 ++++++++++++++++ .../onboardingProgress.test.tsx | 143 ++++++++++++++ .../OnboardingProvider/onboardingProgress.ts | 112 +++++++++++ .../OnboardingProvider/onboardingSteps.json | 30 +++ .../OnboardingProvider/steps.test.ts | 21 +++ src/providers/OnboardingProvider/steps.ts | 54 ++++++ src/providers/TourProvider/tourCompletion.ts | 6 +- .../Dashboard/Learn/LearnHomeView.test.tsx | 8 +- src/routes/Dashboard/Learn/LearnHomeView.tsx | 10 +- src/routes/Settings/SettingsFlagsContext.tsx | 5 +- src/services/pipelineStorage/PipelineFile.ts | 3 + src/utils/componentStore.ts | 14 +- src/utils/constants.ts | 2 + src/utils/userPipelineWriteEvents.ts | 11 ++ 22 files changed, 1011 insertions(+), 128 deletions(-) create mode 100644 src/components/Onboarding/OnboardingChecklist.tsx create mode 100644 src/hooks/useDocsVisitTracking.test.tsx create mode 100644 src/hooks/useDocsVisitTracking.ts create mode 100644 src/providers/OnboardingProvider/OnboardingProvider.test.tsx create mode 100644 src/providers/OnboardingProvider/OnboardingProvider.tsx create mode 100644 src/providers/OnboardingProvider/onboardingProgress.test.tsx create mode 100644 src/providers/OnboardingProvider/onboardingProgress.ts create mode 100644 src/providers/OnboardingProvider/onboardingSteps.json create mode 100644 src/providers/OnboardingProvider/steps.test.ts create mode 100644 src/providers/OnboardingProvider/steps.ts create mode 100644 src/utils/userPipelineWriteEvents.ts diff --git a/react-compiler.config.js b/react-compiler.config.js index 8cbb25164..eade54013 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -6,6 +6,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/components/Home", "src/components/Editor", "src/components/Learn", + "src/components/Onboarding", // 0 useCallback/useMemo - ready to enable "src/components/layout", @@ -72,6 +73,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/providers/DialogProvider", "src/providers/TourProvider", + "src/providers/OnboardingProvider", "src/routes/EditorV2", // 11-20 useCallback/useMemo diff --git a/src/components/Learn/LearnSearchBar.tsx b/src/components/Learn/LearnSearchBar.tsx index 5937832a4..18c6f4f34 100644 --- a/src/components/Learn/LearnSearchBar.tsx +++ b/src/components/Learn/LearnSearchBar.tsx @@ -4,16 +4,19 @@ import { Icon } from "@/components/ui/icon"; import { Input, InputGroup } from "@/components/ui/input"; import { Text } from "@/components/ui/typography"; import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; import { TANGLE_WEBSITE_URL } from "@/utils/constants"; export function LearnSearchBar() { const [value, setValue] = useState(""); const { track } = useAnalytics(); + const { markDocsRead } = useOnboarding(); const handleSubmit = () => { const query = value.trim(); if (!query) return; track("learning_hub.search.submitted"); + markDocsRead(); const url = `${TANGLE_WEBSITE_URL}search/?q=${encodeURIComponent(query)}`; window.open(url, "_blank", "noopener,noreferrer"); }; diff --git a/src/components/Learn/OnboardingHero.tsx b/src/components/Learn/OnboardingHero.tsx index c9b5f12d2..b77034e70 100644 --- a/src/components/Learn/OnboardingHero.tsx +++ b/src/components/Learn/OnboardingHero.tsx @@ -1,116 +1,83 @@ -import { Link } from "@tanstack/react-router"; - +import { OnboardingChecklist } from "@/components/Onboarding/OnboardingChecklist"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; -import { Heading, Paragraph, Text } from "@/components/ui/typography"; -import { cn } from "@/lib/utils"; -import { tracking } from "@/utils/tracking"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; -interface OnboardingStep { - id: string; - label: string; - completed: boolean; +function scrollNearestScrollableToTop(el: HTMLElement | null) { + let node = el?.parentElement ?? null; + while (node) { + const { overflowY } = getComputedStyle(node); + if ( + (overflowY === "auto" || overflowY === "scroll") && + node.scrollHeight > node.clientHeight + ) { + node.scrollTo({ top: 0, behavior: "smooth" }); + return; + } + node = node.parentElement; + } + window.scrollTo({ top: 0, behavior: "smooth" }); } -const STUB_STEPS: OnboardingStep[] = [ - { id: "configure-backend", label: "Connect a backend", completed: true }, - { id: "import-sample", label: "Import a sample pipeline", completed: true }, - { id: "run-pipeline", label: "Run your first pipeline", completed: false }, - { id: "edit-component", label: "Edit a component", completed: false }, - { - id: "create-pipeline", - label: "Build a pipeline from scratch", - completed: false, - }, -]; - export function OnboardingHero() { - const completed = STUB_STEPS.filter((s) => s.completed).length; - const total = STUB_STEPS.length; - const isComplete = completed === total; - const nextStep = STUB_STEPS.find((s) => !s.completed); + const { isComplete, dismissed, dismiss, reopen } = useOnboarding(); - return ( -
- - - - - - - {isComplete - ? "Onboarding complete — explore tours and tips below to keep going." - : "Follow a few quick steps to get from zero to your first pipeline run."} - - - {!isComplete && nextStep && ( - + if (dismissed) { + return ( + + + + ); + } - - - - {completed} of {total} steps - -
-
-
- + return ( +
+ -
    - {STUB_STEPS.map((step) => ( -
  • -
  • - ))} -
+ + + + + + {isComplete + ? "Onboarding complete - explore tours and tips below to keep going." + : "Follow a few quick steps to get from zero to your first pipeline run."} + + +
); diff --git a/src/components/Onboarding/OnboardingChecklist.tsx b/src/components/Onboarding/OnboardingChecklist.tsx new file mode 100644 index 000000000..9871b547b --- /dev/null +++ b/src/components/Onboarding/OnboardingChecklist.tsx @@ -0,0 +1,104 @@ +import { Link } from "@tanstack/react-router"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Link as ExternalLink } from "@/components/ui/link"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { + type OnboardingStep, + useOnboarding, +} from "@/providers/OnboardingProvider/OnboardingProvider"; +import { DOCUMENTATION_URL } from "@/utils/constants"; +import { tracking } from "@/utils/tracking"; + +function StepCta({ step }: { step: OnboardingStep }) { + if (step.id === "read_docs") { + return ( + + {step.cta.label} + + ); + } + + return ( + + ); +} + +function StepRow({ step }: { step: OnboardingStep }) { + return ( + + + ); +} + +export function OnboardingChecklist() { + const { steps, completedCount, total } = useOnboarding(); + + return ( + + + + {completedCount} of {total} steps + +
+
+
+ + + + {steps.map((step) => ( + + ))} + + + ); +} diff --git a/src/components/layout/RootLayout.tsx b/src/components/layout/RootLayout.tsx index 823b3ff05..d4f3a52fc 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -10,6 +10,7 @@ import { useSessionPipelineStats } from "@/hooks/useSessionPipelineStats"; import { AnalyticsProvider } from "@/providers/AnalyticsProvider"; import { BackendProvider } from "@/providers/BackendProvider"; import { ComponentSpecProvider } from "@/providers/ComponentSpecProvider"; +import { OnboardingProvider } from "@/providers/OnboardingProvider/OnboardingProvider"; import { TourProvider } from "@/providers/TourProvider/TourProvider"; import { PipelineStorageProvider } from "@/services/pipelineStorage/PipelineStorageProvider"; @@ -29,21 +30,23 @@ function RootLayoutContent() { - - - + + + + -
- +
+ -
- -
+
+ +
- {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( - - )} -
+ {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( + + )} +
+
diff --git a/src/hooks/useDocsVisitTracking.test.tsx b/src/hooks/useDocsVisitTracking.test.tsx new file mode 100644 index 000000000..750f02161 --- /dev/null +++ b/src/hooks/useDocsVisitTracking.test.tsx @@ -0,0 +1,58 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DOCUMENTATION_URL, PRIVACY_POLICY_URL } from "@/utils/constants"; + +import { useDocsVisitTracking } from "./useDocsVisitTracking"; + +function clickLink(href: string) { + const anchor = document.createElement("a"); + anchor.href = href; + // Prevent jsdom from attempting real navigation; the capture-phase listener + // under test has already run by the time this fires. + anchor.addEventListener("click", (e) => e.preventDefault()); + document.body.append(anchor); + anchor.dispatchEvent(new MouseEvent("click", { bubbles: true })); + anchor.remove(); +} + +describe("useDocsVisitTracking", () => { + let onVisit: ReturnType; + + beforeEach(() => { + onVisit = vi.fn(); + renderHook(() => useDocsVisitTracking(onVisit)); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("fires when a docs link is clicked", () => { + clickLink(DOCUMENTATION_URL); + expect(onVisit).toHaveBeenCalledTimes(1); + }); + + it("fires for nested docs pages too", () => { + clickLink(`${DOCUMENTATION_URL}getting-started/first-pipeline/`); + expect(onVisit).toHaveBeenCalledTimes(1); + }); + + it("does not fire for the privacy policy (lives under the docs URL)", () => { + clickLink(PRIVACY_POLICY_URL); + expect(onVisit).not.toHaveBeenCalled(); + }); + + it("does not fire for non-docs links", () => { + clickLink("https://example.com/"); + expect(onVisit).not.toHaveBeenCalled(); + }); + + it("ignores clicks that are not on a link", () => { + const button = document.createElement("button"); + document.body.append(button); + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + button.remove(); + expect(onVisit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useDocsVisitTracking.ts b/src/hooks/useDocsVisitTracking.ts new file mode 100644 index 000000000..f9f71f213 --- /dev/null +++ b/src/hooks/useDocsVisitTracking.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; + +import { DOCUMENTATION_URL, PRIVACY_POLICY_URL } from "@/utils/constants"; + +export function useDocsVisitTracking(onVisit: () => void): void { + useEffect(() => { + const handleClick = (event: MouseEvent) => { + if (!(event.target instanceof Element)) return; + const anchor = event.target.closest("a"); + if (!anchor) return; + const { href } = anchor; + if ( + href.startsWith(DOCUMENTATION_URL) && + !href.startsWith(PRIVACY_POLICY_URL) + ) { + onVisit(); + } + }; + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, [onVisit]); +} diff --git a/src/providers/OnboardingProvider/OnboardingProvider.test.tsx b/src/providers/OnboardingProvider/OnboardingProvider.test.tsx new file mode 100644 index 000000000..02990fad7 --- /dev/null +++ b/src/providers/OnboardingProvider/OnboardingProvider.test.tsx @@ -0,0 +1,175 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { emitUserPipelineWritten } from "@/utils/userPipelineWriteEvents"; + +import { OnboardingProvider, useOnboarding } from "./OnboardingProvider"; + +const fetchWithErrorHandling = vi.hoisted(() => + vi.fn<(url: string, options?: RequestInit) => Promise>(), +); +const track = vi.hoisted(() => vi.fn()); + +vi.mock("@/utils/fetchWithErrorHandling", () => ({ + fetchWithErrorHandling: (url: string, options?: RequestInit) => + fetchWithErrorHandling(url, options), +})); + +let backend = { available: true, backendUrl: "https://backend.example" }; +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => backend, +})); + +vi.mock("@/providers/AnalyticsProvider", () => ({ + useAnalytics: () => ({ track }), +})); + +let tourCompletions: Record | undefined = {}; +vi.mock("@/providers/TourProvider/tourCompletion", () => ({ + useTourCompletions: () => ({ data: tourCompletions }), +})); + +let settingsPayload: unknown = {}; +let runsPayload: unknown = { pipeline_runs: [] }; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + {children} + + ); +} + +function render() { + return renderHook(() => useOnboarding(), { wrapper }); +} + +function completedSteps(result: { current: ReturnType }) { + return result.current.steps + .filter((step) => step.completed) + .map((step) => step.id); +} + +function lastPatchBody() { + const patch = fetchWithErrorHandling.mock.calls.find( + ([, options]) => options?.method === "PATCH", + ); + return JSON.parse(patch?.[1]?.body as string); +} + +function patched() { + return fetchWithErrorHandling.mock.calls.some( + ([, options]) => options?.method === "PATCH", + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + backend = { available: true, backendUrl: "https://backend.example" }; + tourCompletions = {}; + settingsPayload = {}; + runsPayload = { pipeline_runs: [] }; + + fetchWithErrorHandling.mockImplementation((url, options) => { + if (options?.method === "PATCH") return Promise.resolve({}); + if (url.includes("/api/users/me/settings")) + return Promise.resolve(settingsPayload); + if (url.includes("/api/pipeline_runs/")) + return Promise.resolve(runsPayload); + return Promise.resolve({}); + }); +}); + +describe("OnboardingProvider", () => { + it("reports no progress for a brand-new user and does not persist", async () => { + const { result } = render(); + + await waitFor(() => expect(result.current.total).toBe(4)); + expect(result.current.completedCount).toBe(0); + expect(result.current.isComplete).toBe(false); + expect(patched()).toBe(false); + }); + + it("derives tour and run completion live without persisting them", async () => { + tourCompletions = { "first-pipeline": { completedAt: "x" } }; + runsPayload = { pipeline_runs: [{ id: "run-1" }] }; + + const { result } = render(); + + await waitFor(() => + expect(completedSteps(result).sort()).toEqual([ + "complete_tour", + "execute_run", + ]), + ); + expect(patched()).toBe(false); + }); + + it("persists create_pipeline when the user writes a pipeline", async () => { + const { result } = render(); + + act(() => emitUserPipelineWritten()); + + await waitFor(() => + expect(completedSteps(result)).toContain("create_pipeline"), + ); + expect(lastPatchBody().settings.onboarding.steps.create_pipeline).toBe( + true, + ); + expect(track).toHaveBeenCalledWith("onboarding.step.completed", { + step_id: "create_pipeline", + }); + }); + + it("marks the docs step read on demand", async () => { + settingsPayload = { onboarding: { steps: { create_pipeline: true } } }; + const { result } = render(); + await waitFor(() => + expect(completedSteps(result)).toContain("create_pipeline"), + ); + + act(() => result.current.markDocsRead()); + + await waitFor(() => expect(completedSteps(result)).toContain("read_docs")); + expect(lastPatchBody().settings.onboarding.steps.read_docs).toBe(true); + expect(track).toHaveBeenCalledWith("onboarding.step.completed", { + step_id: "read_docs", + }); + }); + + it("dismisses and restores onboarding", async () => { + settingsPayload = { onboarding: { steps: { create_pipeline: true } } }; + const { result } = render(); + await waitFor(() => + expect(completedSteps(result)).toContain("create_pipeline"), + ); + expect(result.current.dismissed).toBe(false); + + act(() => result.current.dismiss()); + await waitFor(() => expect(result.current.dismissed).toBe(true)); + expect(lastPatchBody().settings.onboarding.dismissed).toBe(true); + expect(track).toHaveBeenCalledWith("onboarding.dismissed"); + + act(() => result.current.reopen()); + await waitFor(() => expect(result.current.dismissed).toBe(false)); + expect(track).toHaveBeenCalledWith("onboarding.reopened"); + }); + + it("is complete once persisted and derived steps are all satisfied", async () => { + settingsPayload = { + onboarding: { steps: { read_docs: true, create_pipeline: true } }, + }; + tourCompletions = { "first-pipeline": { completedAt: "x" } }; + runsPayload = { pipeline_runs: [{ id: "run-1" }] }; + + const { result } = render(); + + await waitFor(() => expect(result.current.isComplete).toBe(true)); + expect(result.current.completedCount).toBe(4); + }); +}); diff --git a/src/providers/OnboardingProvider/OnboardingProvider.tsx b/src/providers/OnboardingProvider/OnboardingProvider.tsx new file mode 100644 index 000000000..596ff9110 --- /dev/null +++ b/src/providers/OnboardingProvider/OnboardingProvider.tsx @@ -0,0 +1,160 @@ +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useEffect, useState } from "react"; + +import type { ListPipelineJobsResponse } from "@/api/types.gen"; +import { useDocsVisitTracking } from "@/hooks/useDocsVisitTracking"; +import { + createRequiredContext, + useRequiredContext, +} from "@/hooks/useRequiredContext"; +import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { useBackend } from "@/providers/BackendProvider"; +import { useTourCompletions } from "@/providers/TourProvider/tourCompletion"; +import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; +import { + filtersToFilterQuery, + parseFilterParam, +} from "@/utils/pipelineRunFilterUtils"; +import { subscribeUserPipelineWritten } from "@/utils/userPipelineWriteEvents"; + +import { + type OnboardingSteps, + useOnboardingProgress, + usePersistOnboardingProgress, +} from "./onboardingProgress"; +import { + ONBOARDING_STEP_IDS, + ONBOARDING_STEPS, + type OnboardingStepMeta, +} from "./steps"; + +const PIPELINE_RUNS_QUERY_URL = "/api/pipeline_runs/"; +const STALE_MS = 1000 * 60 * 5; + +export interface OnboardingStep extends OnboardingStepMeta { + completed: boolean; +} + +interface OnboardingContextValue { + steps: OnboardingStep[]; + completedCount: number; + total: number; + isComplete: boolean; + dismissed: boolean; + markDocsRead: () => void; + dismiss: () => void; + reopen: () => void; +} + +const OnboardingContext = + createRequiredContext("OnboardingProvider"); + +function useHasMyRun(): boolean { + const { available, backendUrl } = useBackend(); + const filterQuery = filtersToFilterQuery(parseFilterParam("created_by:me")); + + const { data } = useQuery({ + queryKey: ["onboarding", "myRunCount", backendUrl], + enabled: available && Boolean(backendUrl), + staleTime: STALE_MS, + refetchOnWindowFocus: false, + queryFn: async () => { + const url = new URL(PIPELINE_RUNS_QUERY_URL, backendUrl); + if (filterQuery) url.searchParams.set("filter_query", filterQuery); + const payload = (await fetchWithErrorHandling( + url.toString(), + )) as ListPipelineJobsResponse; + return payload.pipeline_runs?.length ?? 0; + }, + }); + return (data ?? 0) > 0; +} + +export function OnboardingProvider({ children }: { children: ReactNode }) { + const { track } = useAnalytics(); + const { data: progress } = useOnboardingProgress(); + const persist = usePersistOnboardingProgress(); + + const { data: tourCompletions } = useTourCompletions(); + const hasCompletedTour = Boolean( + tourCompletions && Object.keys(tourCompletions).length > 0, + ); + const hasMyRun = useHasMyRun(); + + const stored = progress?.steps; + const desiredSteps: OnboardingSteps = { + read_docs: stored?.read_docs ?? false, + create_pipeline: stored?.create_pipeline ?? false, + complete_tour: hasCompletedTour, + execute_run: hasMyRun, + }; + + const isComplete = ONBOARDING_STEP_IDS.every((id) => desiredSteps[id]); + + const [pipelineWriteCount, setPipelineWriteCount] = useState(0); + + useEffect( + () => + subscribeUserPipelineWritten(() => + setPipelineWriteCount((count) => count + 1), + ), + [], + ); + + useEffect(() => { + if ( + pipelineWriteCount === 0 || + !progress || + progress.steps.create_pipeline + ) { + return; + } + persist({ + ...progress, + steps: { ...progress.steps, create_pipeline: true }, + }); + track("onboarding.step.completed", { step_id: "create_pipeline" }); + }, [pipelineWriteCount, progress, persist, track]); + + const markDocsRead = () => { + if (!progress || progress.steps.read_docs) return; + persist({ ...progress, steps: { ...progress.steps, read_docs: true } }); + track("onboarding.step.completed", { step_id: "read_docs" }); + }; + + useDocsVisitTracking(markDocsRead); + + const dismiss = () => { + if (!progress || progress.dismissed) return; + persist({ ...progress, dismissed: true }); + track("onboarding.dismissed"); + }; + + const reopen = () => { + if (!progress || !progress.dismissed) return; + persist({ ...progress, dismissed: false }); + track("onboarding.reopened"); + }; + + const steps: OnboardingStep[] = ONBOARDING_STEPS.map((meta) => ({ + ...meta, + completed: desiredSteps[meta.id], + })); + + const value: OnboardingContextValue = { + steps, + completedCount: steps.filter((step) => step.completed).length, + total: steps.length, + isComplete, + dismissed: progress?.dismissed ?? false, + markDocsRead, + dismiss, + reopen, + }; + + return {children}; +} + +export function useOnboarding() { + return useRequiredContext(OnboardingContext); +} diff --git a/src/providers/OnboardingProvider/onboardingProgress.test.tsx b/src/providers/OnboardingProvider/onboardingProgress.test.tsx new file mode 100644 index 000000000..e6fdc4106 --- /dev/null +++ b/src/providers/OnboardingProvider/onboardingProgress.test.tsx @@ -0,0 +1,143 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + emptyProgress, + type OnboardingProgress, + parseProgress, + useOnboardingProgress, + usePersistOnboardingProgress, +} from "./onboardingProgress"; + +const fetchWithErrorHandling = vi.hoisted(() => + vi.fn<(url: string, options?: RequestInit) => Promise>(() => + Promise.resolve({}), + ), +); + +vi.mock("@/utils/fetchWithErrorHandling", () => ({ + fetchWithErrorHandling: (url: string, options?: RequestInit) => + fetchWithErrorHandling(url, options), +})); + +let backend = { available: true, backendUrl: "https://backend.example" }; + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => backend, +})); + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function render() { + return renderHook( + () => ({ + progress: useOnboardingProgress(), + persist: usePersistOnboardingProgress(), + }), + { wrapper: makeWrapper() }, + ); +} + +const progressWithDocs: OnboardingProgress = { + steps: { + read_docs: true, + complete_tour: false, + create_pipeline: false, + execute_run: false, + }, + dismissed: false, +}; + +beforeEach(() => { + vi.clearAllMocks(); + fetchWithErrorHandling.mockResolvedValue({}); + backend = { available: true, backendUrl: "https://backend.example" }; +}); + +describe("parseProgress", () => { + it("defaults missing or unknown fields", () => { + expect(parseProgress(undefined)).toEqual(emptyProgress()); + expect(parseProgress("not json")).toEqual(emptyProgress()); + expect(parseProgress(42)).toEqual(emptyProgress()); + }); + + it("coerces step flags to booleans and ignores unknown keys", () => { + const parsed = parseProgress({ + steps: { read_docs: true, create_pipeline: "yes", bogus: true }, + dismissed: true, + }); + expect(parsed.steps).toEqual({ + read_docs: true, + complete_tour: false, + create_pipeline: false, + execute_run: false, + }); + expect(parsed.dismissed).toBe(true); + }); + + it("parses a JSON string payload", () => { + const parsed = parseProgress( + JSON.stringify({ steps: { complete_tour: true }, dismissed: false }), + ); + expect(parsed.steps.complete_tour).toBe(true); + }); +}); + +describe("onboardingProgress hooks (backend)", () => { + it("reads progress from the settings endpoint", async () => { + fetchWithErrorHandling.mockResolvedValueOnce({ + onboarding: { steps: { read_docs: true }, dismissed: false }, + }); + + const { result } = render(); + + await waitFor(() => + expect(result.current.progress.data?.steps.read_docs).toBe(true), + ); + }); + + it("persists by PATCHing the settings endpoint and flips the cache", async () => { + const { result } = render(); + await waitFor(() => expect(result.current.progress.isSuccess).toBe(true)); + + act(() => result.current.persist(progressWithDocs)); + + await waitFor(() => + expect(result.current.progress.data?.steps.read_docs).toBe(true), + ); + + const patchCall = fetchWithErrorHandling.mock.calls.find( + ([, options]) => options?.method === "PATCH", + ); + expect(patchCall?.[0]).toBe( + "https://backend.example/api/users/me/settings", + ); + const body = JSON.parse(patchCall?.[1]?.body as string); + expect(body.settings.onboarding.steps.read_docs).toBe(true); + expect(patchCall?.[1]?.keepalive).toBe(true); + }); +}); + +describe("onboardingProgress hooks (offline)", () => { + it("neither fetches nor persists when no backend is available", async () => { + backend = { available: false, backendUrl: "" }; + const { result } = render(); + + expect(result.current.progress.fetchStatus).toBe("idle"); + + act(() => result.current.persist(progressWithDocs)); + + expect(fetchWithErrorHandling).not.toHaveBeenCalled(); + }); +}); diff --git a/src/providers/OnboardingProvider/onboardingProgress.ts b/src/providers/OnboardingProvider/onboardingProgress.ts new file mode 100644 index 000000000..1280af4dd --- /dev/null +++ b/src/providers/OnboardingProvider/onboardingProgress.ts @@ -0,0 +1,112 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useBackend } from "@/providers/BackendProvider"; +import { USER_SETTINGS_PATH } from "@/utils/constants"; +import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; + +import { ONBOARDING_STEP_IDS, type OnboardingStepId } from "./steps"; + +export type OnboardingSteps = Record; + +export interface OnboardingProgress { + steps: OnboardingSteps; + dismissed: boolean; +} + +const ONBOARDING_KEY = "onboarding"; +const QUERY_KEY = "onboardingProgress"; +const STALE_MS = 1000 * 60 * 5; + +function emptySteps(): OnboardingSteps { + return Object.fromEntries( + ONBOARDING_STEP_IDS.map((id) => [id, false]), + ) as OnboardingSteps; +} + +export function emptyProgress(): OnboardingProgress { + return { steps: emptySteps(), dismissed: false }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function parseProgress(value: unknown): OnboardingProgress { + let raw = value; + if (typeof raw === "string") { + try { + raw = JSON.parse(raw); + } catch { + return emptyProgress(); + } + } + if (!isRecord(raw)) return emptyProgress(); + + const rawSteps = isRecord(raw.steps) ? raw.steps : {}; + const steps = emptySteps(); + for (const id of ONBOARDING_STEP_IDS) { + steps[id] = rawSteps[id] === true; + } + + return { + steps, + dismissed: raw.dismissed === true, + }; +} + +function extractProgress(payload: unknown): OnboardingProgress { + if (!isRecord(payload)) return emptyProgress(); + const wrapped = isRecord(payload.settings) + ? payload.settings[ONBOARDING_KEY] + : undefined; + return parseProgress(payload[ONBOARDING_KEY] ?? wrapped); +} + +async function fetchProgress(backendUrl: string): Promise { + const url = new URL(USER_SETTINGS_PATH, backendUrl); + url.searchParams.set("setting_names", ONBOARDING_KEY); + const payload = await fetchWithErrorHandling(url.toString()); + return extractProgress(payload); +} + +function queryKey(backendUrl: string) { + return [QUERY_KEY, backendUrl] as const; +} + +export function useOnboardingProgress() { + const { available, backendUrl } = useBackend(); + const hasBackend = available && Boolean(backendUrl); + + return useQuery({ + queryKey: queryKey(backendUrl), + queryFn: () => fetchProgress(backendUrl), + enabled: hasBackend, + staleTime: STALE_MS, + refetchOnWindowFocus: false, + }); +} + +export function usePersistOnboardingProgress() { + const queryClient = useQueryClient(); + const { available, backendUrl } = useBackend(); + const hasBackend = available && Boolean(backendUrl); + + const { mutate } = useMutation({ + mutationFn: async (next: OnboardingProgress) => { + const url = new URL(USER_SETTINGS_PATH, backendUrl); + await fetchWithErrorHandling(url.toString(), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: { [ONBOARDING_KEY]: next } }), + // Often fired just before a navigation/reload; survive page unload. + keepalive: true, + }); + }, + }); + + return (next: OnboardingProgress) => { + if (!hasBackend) return; + queryClient.setQueryData(queryKey(backendUrl), next); + mutate(next); + }; +} diff --git a/src/providers/OnboardingProvider/onboardingSteps.json b/src/providers/OnboardingProvider/onboardingSteps.json new file mode 100644 index 000000000..58822e347 --- /dev/null +++ b/src/providers/OnboardingProvider/onboardingSteps.json @@ -0,0 +1,30 @@ +[ + { + "id": "read_docs", + "label": "Read the documentation", + "description": "Skim the docs to get familiar with Tangle's core concepts.", + "icon": "BookOpen", + "cta": { "label": "Browse docs", "to": "/learn" } + }, + { + "id": "complete_tour", + "label": "Take a guided tour", + "description": "Follow an interactive walkthrough right inside the editor.", + "icon": "Compass", + "cta": { "label": "Start a tour", "to": "/learn/tours" } + }, + { + "id": "create_pipeline", + "label": "Create a pipeline", + "description": "Edit an existing pipeline or create a new one from scratch.", + "icon": "Workflow", + "cta": { "label": "Browse examples", "to": "/learn/examples" } + }, + { + "id": "execute_run", + "label": "Run a pipeline", + "description": "Execute a pipeline and watch it run end to end.", + "icon": "Play", + "cta": { "label": "View runs", "to": "/runs" } + } +] diff --git a/src/providers/OnboardingProvider/steps.test.ts b/src/providers/OnboardingProvider/steps.test.ts new file mode 100644 index 000000000..319ca92e3 --- /dev/null +++ b/src/providers/OnboardingProvider/steps.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { ONBOARDING_STEP_IDS, ONBOARDING_STEPS } from "./steps"; + +describe("onboarding steps", () => { + it("defines exactly one step per id, in canonical order", () => { + expect(ONBOARDING_STEPS.map((step) => step.id)).toEqual([ + ...ONBOARDING_STEP_IDS, + ]); + }); + + it("gives every step the metadata the UI needs", () => { + for (const step of ONBOARDING_STEPS) { + expect(step.label).toBeTruthy(); + expect(step.description).toBeTruthy(); + expect(step.icon).toBeTruthy(); + expect(step.cta.label).toBeTruthy(); + expect(step.cta.to).toBeTruthy(); + } + }); +}); diff --git a/src/providers/OnboardingProvider/steps.ts b/src/providers/OnboardingProvider/steps.ts new file mode 100644 index 000000000..0096c38c2 --- /dev/null +++ b/src/providers/OnboardingProvider/steps.ts @@ -0,0 +1,54 @@ +import type { IconName } from "@/components/ui/icon"; + +import rawSteps from "./onboardingSteps.json"; + +export const ONBOARDING_STEP_IDS = [ + "read_docs", + "complete_tour", + "create_pipeline", + "execute_run", +] as const; + +export type OnboardingStepId = (typeof ONBOARDING_STEP_IDS)[number]; + +interface OnboardingStepCta { + label: string; + to: string; +} + +export interface OnboardingStepMeta { + id: OnboardingStepId; + label: string; + description: string; + icon: IconName; + cta: OnboardingStepCta; +} + +function isStepId(value: string): value is OnboardingStepId { + return ONBOARDING_STEP_IDS.some((id) => id === value); +} + +function parseSteps(raw: typeof rawSteps): OnboardingStepMeta[] { + const byId = new Map(); + for (const step of raw) { + if (!isStepId(step.id)) { + throw new Error( + `Unknown onboarding step id "${step.id}" in onboardingSteps.json. ` + + `Expected one of: ${ONBOARDING_STEP_IDS.join(", ")}.`, + ); + } + byId.set(step.id, { ...step, id: step.id, icon: step.icon as IconName }); + } + + return ONBOARDING_STEP_IDS.map((id) => { + const step = byId.get(id); + if (!step) { + throw new Error( + `Missing onboarding step "${id}" in onboardingSteps.json.`, + ); + } + return step; + }); +} + +export const ONBOARDING_STEPS: OnboardingStepMeta[] = parseSteps(rawSteps); diff --git a/src/providers/TourProvider/tourCompletion.ts b/src/providers/TourProvider/tourCompletion.ts index 8ae5b5c74..327743355 100644 --- a/src/providers/TourProvider/tourCompletion.ts +++ b/src/providers/TourProvider/tourCompletion.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useBackend } from "@/providers/BackendProvider"; +import { USER_SETTINGS_PATH } from "@/utils/constants"; import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; interface TourCompletionRecord { @@ -10,7 +11,6 @@ interface TourCompletionRecord { type TourCompletionMap = Record; -const SETTINGS_PATH = "/api/users/me/settings"; const COMPLETED_TOURS_KEY = "completed_tours"; const QUERY_KEY = "tourCompletions"; const STALE_MS = 1000 * 60 * 5; @@ -57,7 +57,7 @@ function extractCompletedTours(payload: unknown): TourCompletionMap { async function fetchCompletions( backendUrl: string, ): Promise { - const url = new URL(SETTINGS_PATH, backendUrl); + const url = new URL(USER_SETTINGS_PATH, backendUrl); url.searchParams.set("setting_names", COMPLETED_TOURS_KEY); const payload = await fetchWithErrorHandling(url.toString()); return extractCompletedTours(payload); @@ -110,7 +110,7 @@ export function useRecordTourCompletion() { }, }; - const url = new URL(SETTINGS_PATH, backendUrl); + const url = new URL(USER_SETTINGS_PATH, backendUrl); await fetchWithErrorHandling(url.toString(), { method: "PATCH", headers: { "Content-Type": "application/json" }, diff --git a/src/routes/Dashboard/Learn/LearnHomeView.test.tsx b/src/routes/Dashboard/Learn/LearnHomeView.test.tsx index 4563b3367..ebf94b5f6 100644 --- a/src/routes/Dashboard/Learn/LearnHomeView.test.tsx +++ b/src/routes/Dashboard/Learn/LearnHomeView.test.tsx @@ -4,6 +4,8 @@ import { cleanup, render } from "@testing-library/react"; import type { ReactElement, ReactNode } from "react"; import { afterEach, describe, expect, test, vi } from "vitest"; +import { OnboardingProvider } from "@/providers/OnboardingProvider/OnboardingProvider"; + import { LearnHomeView } from "./LearnHomeView"; vi.mock("@tanstack/react-router", async (importOriginal) => ({ @@ -41,7 +43,9 @@ const queryClient = new QueryClient({ const renderWithClient = (component: ReactElement) => render( - {component}, + + {component} + , ); describe("", () => { @@ -64,7 +68,7 @@ describe("", () => { ).toBeInTheDocument(); }); - test.skip("renders the onboarding hero with progress", () => { + test("renders the onboarding hero with progress", () => { renderWithClient(); expect( screen.getByRole("heading", { level: 2, name: /welcome to tangle/i }), diff --git a/src/routes/Dashboard/Learn/LearnHomeView.tsx b/src/routes/Dashboard/Learn/LearnHomeView.tsx index 771922be8..8d71fe93d 100644 --- a/src/routes/Dashboard/Learn/LearnHomeView.tsx +++ b/src/routes/Dashboard/Learn/LearnHomeView.tsx @@ -8,12 +8,10 @@ import { LearnSearchBar } from "@/components/Learn/LearnSearchBar"; import { OnboardingHero } from "@/components/Learn/OnboardingHero"; import { TipOfTheDay } from "@/components/Learn/TipOfTheDay"; import { BlockStack } from "@/components/ui/layout"; - -// Learning Hub Milestone 1: Documentation, FAQ, Example Pipelines & Tips -// Not included: Guided Tours & Onboarding -const SHOW_WIP_FEATURES = false; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; export function LearnHomeView() { + const { dismissed } = useOnboarding(); return ( @@ -28,7 +26,7 @@ export function LearnHomeView() { - {SHOW_WIP_FEATURES && } + {!dismissed && }
@@ -45,6 +43,8 @@ export function LearnHomeView() {
+ + {dismissed && }
); } diff --git a/src/routes/Settings/SettingsFlagsContext.tsx b/src/routes/Settings/SettingsFlagsContext.tsx index 7e4d5035e..0f75bd3de 100644 --- a/src/routes/Settings/SettingsFlagsContext.tsx +++ b/src/routes/Settings/SettingsFlagsContext.tsx @@ -8,10 +8,9 @@ import { } from "@/hooks/useRequiredContext"; import { useBackend } from "@/providers/BackendProvider"; import type { Flag } from "@/types/configuration"; +import { USER_SETTINGS_PATH } from "@/utils/constants"; import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; -const USER_SETTINGS_URL = "/api/users/me/settings"; - interface SettingsFlagsContextValue { betaFlags: Flag[]; settings: Flag[]; @@ -30,7 +29,7 @@ export function SettingsFlagsProvider({ children }: { children: ReactNode }) { dispatch({ type: "setFlag", payload: { key: flag, enabled } }); if (available) { - const url = new URL(USER_SETTINGS_URL, backendUrl); + const url = new URL(USER_SETTINGS_PATH, backendUrl); fetchWithErrorHandling(url.toString(), { method: "PATCH", headers: { "Content-Type": "application/json" }, diff --git a/src/services/pipelineStorage/PipelineFile.ts b/src/services/pipelineStorage/PipelineFile.ts index bec305024..ca3c6adb3 100644 --- a/src/services/pipelineStorage/PipelineFile.ts +++ b/src/services/pipelineStorage/PipelineFile.ts @@ -1,5 +1,7 @@ import { action, makeObservable, observable, runInAction } from "mobx"; +import { emitUserPipelineWritten } from "@/utils/userPipelineWriteEvents"; + import { emitPipelineFileChanged } from "./pipelineFileEvents"; import type { PipelineFolder } from "./PipelineFolder"; import { deleteEntry, updateEntry } from "./pipelineRegistry"; @@ -37,6 +39,7 @@ export class PipelineFile { async write(content: string): Promise { await this.folder.driver.write(this.storageKey, content); emitPipelineFileChanged({ storageKey: this.storageKey, source: "v2" }); + emitUserPipelineWritten(); } @action diff --git a/src/utils/componentStore.ts b/src/utils/componentStore.ts index 5f01b5d85..a5eb17bbd 100644 --- a/src/utils/componentStore.ts +++ b/src/utils/componentStore.ts @@ -5,8 +5,12 @@ import { fetchComponentTextFromUrl } from "@/services/componentService"; import type { DownloadDataType } from "./cache"; import { downloadDataWithCache } from "./cache"; import type { ComponentReference, ComponentSpec } from "./componentSpec"; -import { USER_COMPONENTS_LIST_NAME } from "./constants"; +import { + USER_COMPONENTS_LIST_NAME, + USER_PIPELINES_LIST_NAME, +} from "./constants"; import { getIdOrTitleFromPath } from "./URL"; +import { emitUserPipelineWritten } from "./userPipelineWriteEvents"; import { componentSpecFromYaml, componentSpecToYaml } from "./yaml"; // IndexedDB: DB and table names @@ -500,7 +504,13 @@ export const writeComponentToFileListFromText = async ( componentText: string | ArrayBuffer, ) => { const componentRef = await storeComponentText(componentText); - return writeComponentRefToFile(listName, fileName, componentRef); + const result = await writeComponentRefToFile( + listName, + fileName, + componentRef, + ); + if (listName === USER_PIPELINES_LIST_NAME) emitUserPipelineWritten(); + return result; }; export const renameComponentFileInList = async ( diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 1ed15666f..d2cbe36da 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -20,6 +20,8 @@ export const DOCUMENTATION_URL = export const API_URL = import.meta.env.VITE_BACKEND_API_URL || ""; export const BASE_URL = import.meta.env.VITE_BASE_URL || "/"; + +export const USER_SETTINGS_PATH = "/api/users/me/settings"; export const IS_GITHUB_PAGES = import.meta.env.VITE_GITHUB_PAGES === "true"; export const GIT_REPO_URL = diff --git a/src/utils/userPipelineWriteEvents.ts b/src/utils/userPipelineWriteEvents.ts new file mode 100644 index 000000000..1d39ae237 --- /dev/null +++ b/src/utils/userPipelineWriteEvents.ts @@ -0,0 +1,11 @@ +const target = new EventTarget(); +const EVENT_NAME = "written"; + +export function emitUserPipelineWritten(): void { + target.dispatchEvent(new Event(EVENT_NAME)); +} + +export function subscribeUserPipelineWritten(listener: () => void): () => void { + target.addEventListener(EVENT_NAME, listener); + return () => target.removeEventListener(EVENT_NAME, listener); +} From 07759d02972fd4accb73238a10351003987bab19 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 19 Jun 2026 15:56:53 -0700 Subject: [PATCH 2/2] address pr feedback - Render OnboardingHero once, drive placement via CSS order toggle - Document why derived onboarding steps don't emit step.completed - Reconcile onboarding progress cache on PATCH failure via onError Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Learn/OnboardingHero.tsx | 15 ++++-- .../Submitters/Tangle/TangleSubmitter.tsx | 6 +++ .../OnboardingProvider.test.tsx | 47 +++++++++++++++++++ .../OnboardingProvider/OnboardingProvider.tsx | 16 ++++--- .../OnboardingProvider/onboardingProgress.ts | 4 +- .../OnboardingProvider/onboardingQueryKeys.ts | 7 +++ src/routes/Dashboard/Learn/LearnHomeView.tsx | 4 +- .../components/AiChat/toolBridge.test.ts | 4 ++ .../components/AiChat/toolBridge/runBridge.ts | 6 +++ 9 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 src/providers/OnboardingProvider/onboardingQueryKeys.ts diff --git a/src/components/Learn/OnboardingHero.tsx b/src/components/Learn/OnboardingHero.tsx index b77034e70..9552b8617 100644 --- a/src/components/Learn/OnboardingHero.tsx +++ b/src/components/Learn/OnboardingHero.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Heading, Paragraph } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; function scrollNearestScrollableToTop(el: HTMLElement | null) { @@ -21,12 +22,15 @@ function scrollNearestScrollableToTop(el: HTMLElement | null) { window.scrollTo({ top: 0, behavior: "smooth" }); } -export function OnboardingHero() { +export function OnboardingHero({ className }: { className?: string }) { const { isComplete, dismissed, dismiss, reopen } = useOnboarding(); if (dismissed) { return ( - +
- - {dismissed && }
); } diff --git a/src/routes/v2/pages/Editor/components/AiChat/toolBridge.test.ts b/src/routes/v2/pages/Editor/components/AiChat/toolBridge.test.ts index cc8921a20..c8a65546b 100644 --- a/src/routes/v2/pages/Editor/components/AiChat/toolBridge.test.ts +++ b/src/routes/v2/pages/Editor/components/AiChat/toolBridge.test.ts @@ -8,6 +8,7 @@ import { Output, Task, } from "@/models/componentSpec"; +import { ONBOARDING_MY_RUN_COUNT_KEY } from "@/providers/OnboardingProvider/onboardingQueryKeys"; import type { UndoGroupable } from "@/routes/v2/shared/nodes/types"; vi.mock("@/services/componentService", () => ({ @@ -444,6 +445,9 @@ describe("createEditorToolBridge", () => { expect(urlArg).toBe(TEST_BACKEND_URL); expect(optionsArg.authorizationToken).toBe("auth-token"); expect(invalidate).toHaveBeenCalledWith({ queryKey: ["pipelineRuns"] }); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: ONBOARDING_MY_RUN_COUNT_KEY, + }); }); it("returns submission failure with the helper's error message", async () => { diff --git a/src/routes/v2/shared/components/AiChat/toolBridge/runBridge.ts b/src/routes/v2/shared/components/AiChat/toolBridge/runBridge.ts index 368a714f9..253e0dbf6 100644 --- a/src/routes/v2/shared/components/AiChat/toolBridge/runBridge.ts +++ b/src/routes/v2/shared/components/AiChat/toolBridge/runBridge.ts @@ -26,6 +26,7 @@ import { truncateExecutionDetails, } from "@/agent/util/truncate"; import { serializeComponentSpec } from "@/models/componentSpec/serialization/serialize"; +import { ONBOARDING_MY_RUN_COUNT_KEY } from "@/providers/OnboardingProvider/onboardingQueryKeys"; import { fetchContainerExecutionState, fetchContainerLog, @@ -85,6 +86,11 @@ export function createRunBridgeHandlers(deps: BridgeDeps): RunHandlers { } // Refresh both the editor list (per pipeline) and the home runs page. deps.queryClient?.invalidateQueries({ queryKey: ["pipelineRuns"] }); + // Keep the onboarding checklist's run-count fresh so a first run flips + // `execute_run` immediately rather than after its stale window. + deps.queryClient?.invalidateQueries({ + queryKey: ONBOARDING_MY_RUN_COUNT_KEY, + }); return { success: true, runId: String(submission.run.id),